diff --git a/Cargo.toml b/Cargo.toml index c0145be..828e11e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["cdylib"] [dependencies] url = "2.5.3" -serde = "1.0.214" +serde = { version = "1.0.214", features = ["derive"] } serde_json = "1.0.104" # needed to enable the "js" feature for compatibility with wasm, # see https://docs.rs/getrandom/#webassembly-support diff --git a/README.md b/README.md index 394583e..70baa20 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,82 @@ # URL Shortener - Cloudflare Worker -## Usage +## Development + +### Prerequisites + +You have two options: + +Option one (recommended): + +- [install](https://devenv.sh/getting-started/) `devenv` && run `devenv up` +- done. + +Now you can run `dev`, `fmt`, etc. (tasks are defined in [`tasks.nix`](./tasks.nix)) + +Option two: follow Cloudflare's [guide](https://developers.cloudflare.com/workers/languages/rust/) + +- [install `Node.js`](https://nodejs.org/en/learn/getting-started/how-to-install-nodejs) +- [install `wrangler`](https://bun.sh/docs/installation) +- [install `rust`](https://www.rust-lang.org/tools/install) > [!NOTE] -> When running locally use `http://localhost:8787` +> the rest of the guide assumes you're using `devenv` +> +> if you're installing stuff manully, +> take a look at [`tasks.nix`](./tasks.nix) for the commands -### Shorten a URL +Once you've installed the prerequisites, you can run: + +dev server + +```bash +dev +``` + +rowser-based sqlite viewer + +```bash +d1-viewer +``` + +seed local d1 database with data ```bash -wrangler dev --config='wrangler.toml' dev --preview +d1-seed +``` + +shorten a URL + +```bash +curl --url http://localhost:8787/create \ + --request 'POST' \ + --data-binary 'https://docs.union.build/reference/graphql/?query=%7B%20__typename%20%7D' ``` +now refresh the d1 viewer page and you should see the new record + +## Usage + +> [!NOTE] +> When running locally use `http://localhost:8787` + +### Shorten a URL + ```bash curl --url http://localhost:8787/create \ --request 'POST' \ --data-binary 'https://docs.union.build/reference/graphql/?query=%7B%20__typename%20%7D' ``` -This will return a short id, for example: +This will return a short the shortened URL, for example: ```sh -7312a5 +# example +https://localhost/26 ``` ### Expand a short URL ```bash -curl --url http://localhost:8787/7312a5 +curl --url http://localhost:8787/26 ``` diff --git a/flake.nix b/flake.nix index 8d0b6c3..43732c2 100644 --- a/flake.nix +++ b/flake.nix @@ -1,6 +1,7 @@ { description = "URL Shortener Worker"; inputs = { + nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; systems.url = "github:nix-systems/default"; @@ -26,7 +27,6 @@ packages = forEachSystem (system: { devenv-up = self.devShells.${system}.default.config.procfileScript; }); - devShells = forEachSystem ( system: let @@ -40,11 +40,7 @@ # https://devenv.sh/reference/options/ scripts = import ./tasks.nix; - dotenv = { - enable = true; - filename = [ ".env" ]; - }; - + dotenv.enable = true; languages.nix.enable = true; languages.rust = { enable = true; @@ -62,7 +58,11 @@ # for development only # this is the default location when you run d1 with `--local` - env.D1_DATABASE_FILEPATH = ".wrangler/state/v3/d1/miniflare-D1DatabaseObject/*.db"; + env.D1_DATABASE_FILEPATH = + let + dbDir = ".wrangler/state/v3/d1/miniflare-D1DatabaseObject"; + in + "${dbDir}/$(${pkgs.findutils}/bin/find ${dbDir} -maxdepth 1 -name '*.sqlite' ! -name '*-shm' ! -name '*-wal' -printf '%f\n' | head -n1)"; packages = with pkgs; [ jq @@ -70,6 +70,8 @@ bun taplo direnv + sqlite + deadnix sqlfluff binaryen nixfmt-rfc-style diff --git a/src/lib.rs b/src/lib.rs index 4f43f6b..62d9f06 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,31 +9,56 @@ struct GenericResponse { message: String, } +const DEV_ROUTES: [&str; 2] = ["/list", "/env"]; + +pub fn get_secret(name: &str, env: &Env) -> Option<String> { + match env.secret(name) { + Ok(value) => Some(value.to_string()), + Err(_) => None, + } +} + +pub fn get_var(name: &str, env: &Env) -> Option<String> { + match env.var(name) { + Ok(value) => Some(value.to_string()), + Err(_) => None, + } +} + #[event(fetch)] -async fn main(request: Request, env: Env, _context: Context) -> Result<Response> { - let environment = env.var("ENVIRONMENT").unwrap().to_string(); +async fn fetch(request: Request, env: Env, _context: Context) -> Result<Response> { + let environment = get_var("ENVIRONMENT", &env).unwrap_or_default(); if environment.trim().is_empty() { return Response::error("not allowed", 403); } - let mut router = Router::new() - // public routes - .get("/", index_route) - .post("/", index_route) + let router = Router::new() + .get("/", |_, _| Response::ok("zkgm")) + .post("/", |_, _| Response::ok("zkgm")) .post_async("/create", handle_create) .get_async("/:key", handle_url_expand); - if environment == "development" { - // dev-only routes - // quick way to check records are inserted - router = router.get_async("/list", dev_handle_list_urls); + let url = request.url()?; + if !DEV_ROUTES.contains(&url.path()) { + return router.run(request, env).await; } - return router.run(request, env).await; -} + console_log!("{}", url.query().unwrap_or_default()); -pub fn index_route(_request: Request, _context: RouteContext<()>) -> worker::Result<Response> { - Response::ok("zkgm") + let url_key = url.query().and_then(|q| q.split("key=").nth(1)); + if url_key.is_none() { + return router.run(request, env).await; + } + + let stored_key = get_secret("DEV_ROUTES_KEY", &env).unwrap_or_default(); + + if url_key != Some(&stored_key) { + return router.run(request, env).await; + } + return router + .get_async("/list", dev_handle_list_urls) + .run(request, env) + .await; } // handles `POST /create --data-binary 'https://example.com/foo/bar'` @@ -41,20 +66,33 @@ pub async fn handle_create( mut request: Request, context: RouteContext<()>, ) -> worker::Result<Response> { - let url = request.text().await?; - if Url::parse(&url).is_err() { + let payload_url = request.text().await?; + if Url::parse(&payload_url).is_err() { return Response::error("provided url is not valid", 400); } - let d1 = context.env.d1("DB"); - let statement = d1?.prepare("INSERT INTO urls (url) VALUES (?)"); - let query = statement.bind(&[url.into()]); - let result = query?.run().await?.success(); + let d1 = context.env.d1("DB")?; + let statement = d1.prepare("INSERT INTO urls (url) VALUES (?)"); + let query = statement.bind(&[payload_url.into()]); + let result = query?.run().await?; - if result { - return Response::ok("ok"); + if result.error().is_some() { + return Response::error("failed to insert new key", 500); } + let query_statement = d1.prepare("SELECT id FROM urls ORDER BY id DESC LIMIT 1"); + let query = query_statement.bind(&[]); + let result = query?.first::<Value>(None).await?.unwrap(); + + if let Value::Object(object) = result { + if let Some(Value::Number(id)) = object.get("id") { + return Response::ok(format!( + "https://{}/{}", + request.url().unwrap().host_str().unwrap(), + id + )); + } + } Response::error("failed to insert new key", 500) } @@ -63,8 +101,10 @@ pub async fn handle_url_expand( request: Request, context: RouteContext<()>, ) -> worker::Result<Response> { - let key = &request.path().to_string()[1..]; - if key.parse::<u64>().is_err() { + let url = request.url()?; + let key = url.path().trim_start_matches('/'); + + if key.parse::<u64>().is_err() || key.is_empty() { return Response::error("invalid key: ".to_string() + key, 400); } @@ -84,6 +124,7 @@ pub async fn handle_url_expand( } } +// dev-only route: quick way to check records are inserted pub async fn dev_handle_list_urls( _request: Request, context: RouteContext<()>, diff --git a/tasks.nix b/tasks.nix index 061f6dd..fce8bde 100644 --- a/tasks.nix +++ b/tasks.nix @@ -1,4 +1,7 @@ { + echo-env.exec = '' + echo $D1_DATABASE_FILEPATH + ''; wrangler.exec = '' bunx wrangler@latest --config='wrangler.toml' "$@" ''; @@ -12,6 +15,7 @@ taplo lint *.toml cargo clippy --all-targets --all-features sqlfluff lint --dialect sqlite ./schema.sql + deadnix --no-lambda-pattern-names && statix check . ''; build.exec = '' cargo build --release --target wasm32-unknown-unknown @@ -20,9 +24,12 @@ dev.exec = '' bunx wrangler@latest --config='wrangler.toml' dev "$@" ''; + d1-create-database.exec = '' + bunx wrangler@latest --config='wrangler.toml' d1 create url-short-d1 "$@" + ''; # optional: `--local`, `--remote` d1-bootstrap.exec = '' - bunx wrangler@latest --config='wrangler.toml' d1 execute url-short-d1 --file='schema.sql' "$@" + bunx wrangler@latest --config='wrangler.toml' d1 execute url-short-d1 --file='schema.sql' ''; # optional: `--local`, `--remote` # required: `--command="SELECT * FROM urls"` @@ -34,7 +41,7 @@ ''; # only works locally in development d1-viewer.exec = '' - bunx @outerbase/studio@latest $D1_DATABASE_FILEPATH --port=4000 + bunx @outerbase/studio@latest $(eval echo $D1_DATABASE_FILEPATH) --port=4000 ''; deploy.exec = '' bunx wrangler@latest deploy --env='production' --config='wrangler.toml'