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/README.md b/README.md index 41da30f..5b08823 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. diff --git a/index.js b/index.js index 08d284b..78e6bf6 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,77 @@ 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 { + if (request.headers.get('Authorization') != secret) { + return Response.json( + { + code: '401 Unauthorized', + message: 'Unauthorized.', + }, + { + status: 401, + }, + ) + } + + 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) { @@ -106,7 +169,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( @@ -123,7 +186,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, }, @@ -159,6 +222,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( @@ -188,7 +262,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, },