commit 79ed58ee23ae147c5a17feb4f6400d43a079eed7 Author: Akbar Rahman Date: Sat May 9 21:49:10 2026 +0100 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92f91b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +urls.txt diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e14fc67 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,131 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[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.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "urls-txt" +version = "0.1.0" +dependencies = [ + "tokio", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bacf4b0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "urls-txt" +version = "0.1.0" +edition = "2024" + +[dependencies] +tokio = { version = "1.52.3", features = ["io-util", "macros", "net", "rt-multi-thread"] } diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..efe76d2 --- /dev/null +++ b/readme.md @@ -0,0 +1,64 @@ +# urls-txt + +a _really_ simple url shortener: less than 200 lines of Rust and one dependency (tokio). + +## usage + +0. clone and install: + + ``` + git clone https://git.alv.cx/alvierahman90/urls-txt.git + cd urls-txt + cargo install --path . + ``` +1. create a list of links: + + ``` + lines that don't start with a slash (/) are ignore (they're comments) + shortlink followed by redirect link, separated by a space character + / http://alv.cx everything after the link is ignored (they're comments) + /urls-txt http://git.alv.cx/alvierahman90/urls-txt + ``` + +2. run: + + ``` + urls-txt path/to/urls.txt + ``` + +## why? + +all url shorteners i've come across are too complicated: +they have account management and remote APIs and whatnot. +too complicated. + +this includes my own [sus url shortener](https://pls.cx/sus), +which for some reason i decided should use redis for its database and have a HTTP API for +updating it. + +### why not just configure your reverse proxy? + +good question. +i'm not sure, but i think i want to separate my concerns? +you might just want to configure your reverse proxy instead. + +#### caddy + +``` +pls.cx { + redir / http://alv.cx + redir /urls-txt https://git.alv.cx/alvierahman90/urls-txt +} +``` + +#### nginx + +``` +location / { return 301 http://alv.cx; } +location /urls-txt { return 301 https://git.alv.cx/alvierahman90/urls-txt; } + +``` + +##### apache + +there's probably a way ¯\_(ツ)_/¯ diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..76a74fa --- /dev/null +++ b/src/main.rs @@ -0,0 +1,116 @@ +use { + std::{collections::HashMap, env, sync::Arc}, + tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufStream}, + net::{TcpListener, TcpStream}, + task::spawn_blocking, + }, +}; + +enum ResponseCode { + BadRequest, + Forbidden, + NotFound, +} + +async fn simple_response(mut socket: BufStream, rc: ResponseCode) { + let rc = match rc { + ResponseCode::BadRequest => "400 Bad Request", + ResponseCode::Forbidden => "403 Forbidden", + ResponseCode::NotFound => "404 Not Found", + }; + let content_length = rc.len(); + let response = format!( + "HTTP/1.1 {rc}\r\nContent-Type: text/plaintext; charset=utf-8\r\nContent-Length: {content_length}\r\nX-Served-By: urls-txt\r\n\r\n{rc}" + ); + + if let Err(e) = socket.write(response.as_bytes()).await { + println!("process_request: failed: error writing response: {e}"); + }; + + if let Err(e) = socket.flush().await { + println!("process_request: failed: error flushing socket: {e}"); + }; +} + +async fn process_socket(mut socket: BufStream, urlmap: Arc>) { + let mut request_line = String::default(); + if let Err(e) = socket.read_line(&mut request_line).await { + println!("process_request: failed to read line: {e}"); + return; + } + + let mut request_line = request_line.split(' '); + let (Some(http_method), Some(path)) = (request_line.next(), request_line.next()) else { + println!("process_request: malformed request"); + simple_response(socket, ResponseCode::BadRequest).await; + return; + }; + + if http_method != "GET" { + println!("process_request: forbidden method"); + simple_response(socket, ResponseCode::Forbidden).await; + return; + } + + let Some(location) = urlmap.get(path) else { + println!("process_request: not found"); + simple_response(socket, ResponseCode::NotFound).await; + return; + }; + + let content = format!("Found\r\n"); + let content_length = content.len(); + let response = format!( + "HTTP/1.1 302 Found\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {content_length}\r\nLocation: {location}\r\nX-Served-By: urls-txt\r\n\r\n{content}" + ); + if let Err(e) = socket.write(response.as_bytes()).await { + println!("process_request: failed: error writing response: {e}"); + return; + }; + + if let Err(e) = socket.flush().await { + println!("process_request: failed: error flushing socket: {e}"); + }; +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args: Vec = env::args().collect(); + if args.len() != 2 { + println!("USAGE: {} ", args[0]); + Err("Incorrect arguments")? + } + + let config = spawn_blocking(|| std::fs::read_to_string("urls.txt")).await??; + let mut urlmap = HashMap::new(); + for url_pair in config.split('\n') { + if url_pair.len() == 0 { + continue; + } + + if url_pair.chars().next().expect("String is longer than 1 character") != '/' { + continue; + } + + let mut split = url_pair.split_whitespace(); + + let (Some(slug), Some(redirect)) = (split.next(), split.next()) else { + Err(format!("Unable to parse line: '{}'", url_pair))? + }; + + urlmap.insert(slug.to_string(), redirect.to_string()); + } + + let urlmap = Arc::new(urlmap); + + println!("Starting TCP Listener..."); + let listener = TcpListener::bind("127.0.0.1:3000").await?; + loop { + let (socket, _addr) = listener.accept().await?; + let urlmap = urlmap.clone(); + tokio::spawn(async move { + process_socket(BufStream::new(socket), urlmap).await; + }); + } +}