From a753e8d0a04ae9d4d8c83f7cee5cc53fcf5e08e4 Mon Sep 17 00:00:00 2001 From: Michael Mitchell Date: Mon, 23 Jan 2023 23:03:41 +1100 Subject: [PATCH 1/4] feat: bulk create on POST / with a body --- .gitignore | 1 + index.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 615e5ff..351e9c9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ worker/ node_modules/ .cargo-ok wrangler.prod.toml +.mf \ No newline at end of file diff --git a/index.js b/index.js index 3e542b0..08cad73 100644 --- a/index.js +++ b/index.js @@ -19,8 +19,8 @@ async function handleRequest(event) { let path = new URL(request.url).pathname // Trim trailing slash - let key = path !== '/' ? path.replace(/\/$/, '') : path; - let shorturl = new URL(request.url).origin + key + let key = path !== '/' ? path.replace(/\/$/, '') : path + let shorturl = new URL(key, request.url).origin if (request.method == 'PUT') { return await putLink( @@ -41,14 +41,65 @@ async function handleRequest(event) { }, ) } - key = '/' + Math.random().toString(36).slice(5) - shorturl = new URL(request.url).origin + key - return await putLink( - request.headers.get('Authorization'), - shorturl, - key, - request.headers.get('URL'), - ) + const body = await request.text() + + if (!body) { + key = '/' + Math.random().toString(36).slice(5) + shorturl = new URL(key, request.url) + return await putLink( + request.headers.get('Authorization'), + shorturl, + key, + request.headers.get('URL'), + ) + } else { + let json + try { + json = JSON.parse(body) + } catch { + return Response.json( + { + code: '400 Bad Request', + message: 'Body must be valid JSON, or none at all.', + }, + { + status: 400, + }, + ) + } + + const valid = validateBulkBody(json) + if (!valid) { + return Response.json( + { + code: '400 Bad Request', + message: + 'Body must be a standard JSON object mapping keys to urls.', + example: { + '/short': 'https://example.com/really-really-really-long-1', + '/other': 'https://subdomain.example.com/and-some-long-path', + }, + }, + { status: 400 }, + ) + } + + for (const [key, url] of Object.entries(json)) { + await kv.put(key, url) + } + + return Response.json( + { + message: 'URLs created successfully', + entries: Object.entries(json).map(([key, longurl]) => ({ + key: key.slice(1), + shorturl: new URL(key, request.url), + longurl, + })), + }, + { status: 200 }, + ) + } } else if (request.method == 'GET' || request.method == 'HEAD') { let url = await kv.get(key) if (url == null) { @@ -99,7 +150,7 @@ async function handleRequest(event) { ) } - shorturl = new URL(request.url).origin + key + shorturl = new URL(key, request.url) let url = await kv.get(key) if (url == null) { return Response.json( @@ -116,7 +167,7 @@ async function handleRequest(event) { return Response.json( { message: 'Short URL deleted succesfully.', - key: key.substr(1), + key: key.slice(1), shorturl: shorturl, longurl: url, }, @@ -152,6 +203,17 @@ function validateUrl(url) { return true } +// zod and other validation libs too +function validateBulkBody(body) { + // Starting `/` and no ending `/` + const keyRe = /^\/.*?[^\/]$/ + + if (!body || typeof body !== 'object' || Array.isArray(body)) return false + return Object.entries(body).every( + ([key, url]) => keyRe.test(key) && validateUrl(url), + ) +} + async function putLink(givenSecret, shorturl, key, url) { if (givenSecret != secret) { return Response.json( @@ -181,7 +243,7 @@ async function putLink(givenSecret, shorturl, key, url) { return Response.json( { message: 'URL created succesfully.', - key: key.substr(1), + key: key.slice(1), shorturl: shorturl, longurl: url, }, From de4b7ab48492497b684b14661b928f509f650b39 Mon Sep 17 00:00:00 2001 From: Michael Mitchell Date: Mon, 23 Jan 2023 23:09:17 +1100 Subject: [PATCH 2/4] fix: authenticate bulk --- index.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/index.js b/index.js index 08cad73..4e8dfd7 100644 --- a/index.js +++ b/index.js @@ -53,6 +53,18 @@ async function handleRequest(event) { request.headers.get('URL'), ) } else { + if (request.headers.get('Authorization') != secret) { + return Response.json( + { + code: '401 Unauthorized', + message: 'Unauthorized.', + }, + { + status: 401, + }, + ) + } + let json try { json = JSON.parse(body) From 4e90be65ebf712cd04cfedf6b7f489b284c169fc Mon Sep 17 00:00:00 2001 From: Michael Mitchell Date: Mon, 23 Jan 2023 23:22:57 +1100 Subject: [PATCH 3/4] docs: update readme for bulk --- README.md | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 41da30f..ffa7866 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ server: cloudflare ..other ephemeral headers.. ``` -To create or update a custom short URl, send a `PUT` to the intended target URL: +To create or update a custom short URL, send a `PUT` to the intended target URL: ```json erisa@Tuturu:~$ curl -X PUT -H "Authorization: mysecret" -H "URL: https://erisa.uk" https://erisa.link/mywebsite @@ -79,15 +79,38 @@ erisa@Tuturu:~$ curl -X DELETE -H "Authorization: mysecret" https://erisa.link/k } ``` +You can also bulk create multiple shortlinks at once by sending a `POST` to `/` with no `URL` header and with a JSON body instead: + +```json +erisa@Tuturu:~$ curl -X POST -H "Authorization: mysecret" https://erisa.link/ \ + -H 'Content-Type: application/json' \ + -d '{ "/short1": "https://example.com", "/mywebsite": "https://erisa.uk" }' +{ + "message": "URLs created successfully", + "entries": [ + { + "key": "short1", + "shorturl": "https://erisa.link/short1", + "longurl": "https://example.com" + }, + { + "key": "mywebsite", + "shorturl": "http://erisa.link/mywebsite", + "longurl": "https://erisa.uk" + } + ] +} +``` + It is a planned feature to be able to list all URLs via a `GET` on `/` with `Authorization`. -For the time being you can view them from your Cloudflare Dashboard: +For the time being you can view them from your Cloudflare Dashboard: Cloudflare Dashboard -> Workers -> KV -> View on the namespace. ## Plausible Analytics To get statistics for your short URLs with Plausible Analytics, define a `PLAUSIBLE_HOST` secret set to the URL of your Plausible instance. For example, `https://plausible.io/`. - + ## Security This code is relatively simple but still, if you find any security issues that can be exploited publicly, please reach out to me via email: `erisa (at) erisa.uk` with any relevant details. @@ -95,3 +118,7 @@ This code is relatively simple but still, if you find any security issues that c If you don't have access to Workers KV you're welcome to test these issues on my live `erisa.link`, provided you don't send excessive (constant) requests or delete/modify any keys except ones created by you or the `/sample` key. If I don't respond to your email for whatever reason please feel free to publicly open an issue. + +``` + +``` From b1ce2650276e5bf460dee9e5b7e140aae054706c Mon Sep 17 00:00:00 2001 From: Michael Mitchell Date: Tue, 24 Jan 2023 20:16:32 +1100 Subject: [PATCH 4/4] docs: remove accidental empty codeblock --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index ffa7866..5b08823 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,3 @@ This code is relatively simple but still, if you find any security issues that c If you don't have access to Workers KV you're welcome to test these issues on my live `erisa.link`, provided you don't send excessive (constant) requests or delete/modify any keys except ones created by you or the `/sample` key. If I don't respond to your email for whatever reason please feel free to publicly open an issue. - -``` - -```