rewrite URL shortener in Rust
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.gitignore
|
||||
target
|
||||
data
|
||||
config.toml
|
||||
config.json
|
||||
*.db
|
||||
*.db-*
|
||||
*.key
|
||||
*.log
|
||||
+9
-2
@@ -1,2 +1,9 @@
|
||||
data/
|
||||
*.log
|
||||
/target/
|
||||
/config.toml
|
||||
/data/*
|
||||
!/data/.gitignore
|
||||
*.db
|
||||
*.db-*
|
||||
*.key
|
||||
*.log
|
||||
.env
|
||||
|
||||
Generated
+890
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -1,321 +1,211 @@
|
||||
# URL Shortener
|
||||
# ushort
|
||||
|
||||
A minimal URL shortener written in pure Python 3.8+ (zero third-party dependencies) backed by SQLite.
|
||||
`ushort` is a compact URL shortener written in Rust and backed by SQLite. It is
|
||||
a behavior-compatible replacement for the original stdlib-only Python service:
|
||||
the routes, base-path handling, response shapes, database schema, retention,
|
||||
API-key checks, throttling, redirects, and frontend are preserved.
|
||||
|
||||
The frontend under `static/` is embedded into the executable at compile time.
|
||||
The final Linux image contains only one statically linked executable and runs
|
||||
without Python, a shell, a package manager, or external static files.
|
||||
|
||||
## Quick start
|
||||
|
||||
### Run locally
|
||||
|
||||
```bash
|
||||
python urlshort.py config.json
|
||||
cp config.example.toml config.toml
|
||||
# Edit base_url and api_key, then:
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
### Run with Docker Compose
|
||||
The service listens on `127.0.0.1:18082` on the host. Its SQLite database is
|
||||
stored at `./data/urlshort.db` and survives container replacement.
|
||||
|
||||
Edit `config.json` first (especially `api_key` and `base_url`), then:
|
||||
The container runs as UID/GID `1001:1001` by default. Override
|
||||
`USHORT_UID`/`USHORT_GID` if needed, and ensure `./data` is writable by that
|
||||
identity.
|
||||
|
||||
## Configuration
|
||||
|
||||
TOML is the preferred format; see `config.example.toml`. Existing JSON files
|
||||
remain supported, including the old `short_length` alias for
|
||||
`min_short_length`.
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
ushort config.toml
|
||||
ushort legacy-config.json
|
||||
```
|
||||
|
||||
The SQLite database is stored in `./data/urlshort.db` on the host — it survives container restarts.
|
||||
| Key | Required | Default | Description |
|
||||
|---|---:|---:|---|
|
||||
| `base_url` | yes | — | Public URL. Its path becomes the routing prefix. |
|
||||
| `api_key` | yes | — | Secret used by list and delete operations. |
|
||||
| `host` | no | `0.0.0.0` | Bind address. |
|
||||
| `port` | no | `8080` | Bind port. |
|
||||
| `db_path` | no | `data/urlshort.db` | SQLite database path. |
|
||||
| `retention_days` | no | `0` | Default lifetime for new URLs; zero never expires. |
|
||||
| `min_short_length` | no | `6` | Initial generated code length. |
|
||||
| `max_short_length` | no | `32` | Maximum generated and accepted code length. |
|
||||
| `max_url_length` | no | `2048` | Maximum original URL length. |
|
||||
| `max_retention_days` | no | `3650` | Maximum per-URL retention value. |
|
||||
| `rate_limit_requests` | no | `60` | Requests allowed per client/window. |
|
||||
| `rate_limit_window` | no | `60` | Sliding-window length in seconds. |
|
||||
| `production` | no | `false` | When true, disable embedded frontend routes for compatibility with deployments that serve static files separately. |
|
||||
|
||||
---
|
||||
For example, `base_url = "https://example.com/go/links"` limits routing to
|
||||
`/go/links` and produces URLs such as
|
||||
`https://example.com/go/links/aB3xYz`. Requests outside that prefix return 404.
|
||||
|
||||
## Configuration (`config.json`)
|
||||
Real API keys belong only in the deployment's ignored `config.toml`, never in
|
||||
the repository or container image.
|
||||
|
||||
| Key | Required | Default | Description |
|
||||
|----------------------|----------|----------------------|-------------------------------------------------------------------|
|
||||
| `base_url` | ✅ | — | Public base URL. The path component (e.g. `/s` in `http://example.com/s`) is automatically used as the server's routing prefix. |
|
||||
| `api_key` | ✅ | — | Secret key to protect write/read operations |
|
||||
| `host` | | `"0.0.0.0"` | Bind address |
|
||||
| `port` | | `8080` | Bind port |
|
||||
| `db_path` | | `"data/urlshort.db"` | Path to the SQLite database file |
|
||||
| `retention_days` | | `0` | Default retention period in days for new URLs. `0` means never expire. |
|
||||
| `min_short_length` | | `6` | Minimum character length for generated short codes |
|
||||
| `max_short_length` | | `32` | Maximum character length for generated short codes |
|
||||
| `max_url_length` | | `2048` | Maximum allowed length for original URLs |
|
||||
| `max_retention_days` | | `3650` | Maximum allowed retention_days value per URL |
|
||||
| `rate_limit_requests`| | `60` | Max requests per IP per rate-limit window |
|
||||
| `rate_limit_window` | | `60` | Rate-limit window in seconds |
|
||||
## API
|
||||
|
||||
> **Backward compat:** If an old config contains `short_length`, it is automatically used as `min_short_length`.
|
||||
All paths below are relative to the path in `base_url`. Trailing slashes are
|
||||
accepted. Every response includes the legacy CORS and security headers.
|
||||
|
||||
---
|
||||
### Health and frontend
|
||||
|
||||
## Deploying behind a sub-path (e.g. `http://example.com/s`)
|
||||
- `GET /` serves the byte-identical embedded frontend when `production=false`.
|
||||
- `GET /api/health` returns HTTP 200:
|
||||
|
||||
Just set `base_url` to include the desired path prefix — the server derives its routing prefix automatically from it:
|
||||
```json
|
||||
{"status":"ok","service":"url-shortener"}
|
||||
```
|
||||
|
||||
When the configured base path is non-empty, a GET of the bare path (for
|
||||
example `/s`) redirects to `/s/`.
|
||||
|
||||
### Create a short URL
|
||||
|
||||
`POST /api/shorten` and `GET /api/shorten` are both supported and do not
|
||||
require an API key. Fields may be supplied as query parameters or in a JSON
|
||||
body; query parameters take precedence.
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---:|---|
|
||||
| `url` | yes | Absolute `http://` or `https://` URL. |
|
||||
| `retention_days` | no | Per-URL lifetime, or zero to keep forever. |
|
||||
|
||||
Success is HTTP 201 with only the short URL as UTF-8 plain text:
|
||||
|
||||
```text
|
||||
https://example.com/s/aB3xYz
|
||||
```
|
||||
|
||||
Codes begin at `min_short_length`. After ten collisions at one length the
|
||||
service tries the next length, through `max_short_length`.
|
||||
|
||||
### List URLs
|
||||
|
||||
`GET /api/urls?api_key=<key>` returns all rows newest first. A missing or
|
||||
invalid key returns HTTP 403 with an empty body.
|
||||
|
||||
```json
|
||||
{
|
||||
"base_url": "http://example.com/s",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The path component `/s` is extracted at startup. The server will only respond to requests whose path starts with `/s`; everything else returns 404.
|
||||
|
||||
| `base_url` | Derived routing prefix | Short URL example |
|
||||
|---|---|---|
|
||||
| `http://example.com` | *(none — root)* | `http://example.com/aB3xYz` |
|
||||
| `http://example.com/s` | `/s` | `http://example.com/s/aB3xYz` |
|
||||
| `http://example.com/go/links` | `/go/links` | `http://example.com/go/links/aB3xYz` |
|
||||
|
||||
### With Docker Compose
|
||||
|
||||
```bash
|
||||
# Set base_url in config.json, then:
|
||||
docker compose up --build
|
||||
# Service is now available at http://localhost:8080/s/
|
||||
```
|
||||
|
||||
### With an existing nginx vhost
|
||||
|
||||
`nginx.conf` contains **location blocks only** — drop them into an existing `server { }` block.
|
||||
The app handles the base_path prefix internally; nginx proxies API/redirect requests and serves frontend static files.
|
||||
|
||||
```
|
||||
Browser ──► nginx /s/ ──► static/index.html
|
||||
Browser ──► nginx /s/static/… ──► static files (CSS/JS)
|
||||
Browser ──► nginx /s/api/… ──► urlshort :8080 (proxy)
|
||||
Browser ──► nginx /s/<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" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST / GET /api/shorten`
|
||||
Create a new short URL (supports both `POST` and `GET`). No API key required.
|
||||
|
||||
Fields can be passed as **query parameters** (URL-encoded) or in a **JSON request body**.
|
||||
Query parameters take precedence over body fields.
|
||||
|
||||
| Field | Required | Description |
|
||||
|------------------|----------|-----------------------------------------------------------|
|
||||
| `url` | ✅ | The URL to shorten (must start with `http://` or `https://`) |
|
||||
| `retention_days` | | Override the default retention period for this URL |
|
||||
|
||||
Short code length is determined automatically: the server starts at `min_short_length` and
|
||||
progressively tries longer codes on collision, up to `max_short_length`.
|
||||
|
||||
**Response `201`** — plain text containing only the short URL:
|
||||
```
|
||||
http://localhost:8080/s/aB3xYz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/urls` 🔒
|
||||
List all short URLs, newest first.
|
||||
|
||||
**Response `200`**
|
||||
```json
|
||||
{
|
||||
"count": 2,
|
||||
"count": 1,
|
||||
"urls": [
|
||||
{
|
||||
"short_code": "aB3xYz",
|
||||
"short_url": "http://localhost:8080/s/aB3xYz",
|
||||
"original_url": "https://example.com",
|
||||
"short_url": "https://example.com/s/aB3xYz",
|
||||
"original_url": "https://example.org",
|
||||
"created_at": 1710000000,
|
||||
"visit_count": 5,
|
||||
"retention_days": 0
|
||||
"retention_days": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### Metadata and lookup
|
||||
|
||||
### `GET /api/urls/<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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
+25
-28
@@ -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;
|
||||
add_header Cache-Control "no-store";
|
||||
proxy_pass http://127.0.0.1:18082;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_hide_header Cache-Control;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
# --- Serve frontend static assets (CSS, JS, etc.) ---
|
||||
# ^~ ensures this takes priority over the general /s/ proxy below.
|
||||
# Preserve the existing revalidation policy for CSS, JavaScript, and fonts.
|
||||
location ^~ /s/static/ {
|
||||
alias /root/repo/urlshortener/static/;
|
||||
add_header Cache-Control "no-cache";
|
||||
proxy_pass http://127.0.0.1:18082;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_hide_header Cache-Control;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# --- Proxy everything else under /s/ to the Python backend ---
|
||||
# Covers: /s/api/shorten, /s/api/urls, /s/api/lookup, /s/<code> redirects
|
||||
# API calls and short-code redirects.
|
||||
location /s/ {
|
||||
proxy_pass http://127.0.0.1:18082;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
+1452
File diff suppressed because it is too large
Load Diff
+115
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user