v2: Rewrite in TypeScript and Hono
This commit is contained in:
parent
5556cea72f
commit
c6b5d4d61c
6 changed files with 685 additions and 649 deletions
273
index.js
273
index.js
|
|
@ -1,273 +0,0 @@
|
|||
let secret
|
||||
|
||||
addEventListener('fetch', (event) => {
|
||||
event.respondWith(handleRequest(event))
|
||||
})
|
||||
|
||||
/**
|
||||
* Respond to the request
|
||||
* @param {Event} event
|
||||
*/
|
||||
async function handleRequest(event) {
|
||||
const { request } = event
|
||||
// Set this in your worker's environment. wrangler.toml or cloudflare dashboard
|
||||
if (WORKERLINKS_SECRET === undefined) {
|
||||
return new Response('Secret is not defined. Please add WORKERLINKS_SECRET.')
|
||||
} else {
|
||||
secret = WORKERLINKS_SECRET
|
||||
}
|
||||
|
||||
let path = new URL(request.url).pathname
|
||||
// Trim trailing slash
|
||||
let key = path !== '/' ? path.replace(/\/$/, '') : path
|
||||
let shorturl = new URL(key, request.url).origin
|
||||
|
||||
if (request.method == 'PUT') {
|
||||
return await putLink(
|
||||
request.headers.get('Authorization'),
|
||||
shorturl,
|
||||
key,
|
||||
request.headers.get('URL'),
|
||||
)
|
||||
} else if (request.method == 'POST') {
|
||||
if (key != '/') {
|
||||
return Response.json(
|
||||
{
|
||||
code: '405 Method Not Allowed',
|
||||
message: 'POST not valid for individual keys. Did you mean PUT?',
|
||||
},
|
||||
{
|
||||
status: 405,
|
||||
},
|
||||
)
|
||||
}
|
||||
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) {
|
||||
return Response.json(
|
||||
{
|
||||
code: '404 Not Found',
|
||||
message: 'Key does not exist or has not propagated.',
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// PLAUSIBLE_HOST should be the full URL to your Plausible Analytics instance
|
||||
// e.g. https://plausible.io/
|
||||
if (typeof PLAUSIBLE_HOST !== 'undefined') {
|
||||
const url = PLAUSIBLE_HOST + 'api/event'
|
||||
const headers = new Headers()
|
||||
headers.append('User-Agent', request.headers.get('User-Agent'))
|
||||
headers.append(
|
||||
'X-Forwarded-For',
|
||||
request.headers.get('X-Forwarded-For'),
|
||||
)
|
||||
headers.append('Content-Type', 'application/json')
|
||||
|
||||
const data = {
|
||||
name: 'pageview',
|
||||
url: request.url,
|
||||
domain: new URL(request.url).hostname,
|
||||
referrer: request.referrer,
|
||||
}
|
||||
event.waitUntil(
|
||||
fetch(url, { method: 'POST', headers, body: JSON.stringify(data) }),
|
||||
)
|
||||
}
|
||||
|
||||
const searchParams = new URL(request.url).searchParams
|
||||
url = new URL(url)
|
||||
searchParams.forEach((value, key) => {
|
||||
url.searchParams.append(key, value)
|
||||
})
|
||||
|
||||
return new Response(null, { status: 302, headers: { Location: url } })
|
||||
}
|
||||
} else if (request.method == 'DELETE') {
|
||||
if (request.headers.get('Authorization') != secret) {
|
||||
return Response.json(
|
||||
{
|
||||
code: '401 Unauthorized',
|
||||
message: 'Unauthorized.',
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
shorturl = new URL(key, request.url)
|
||||
let url = await kv.get(key)
|
||||
if (url == null) {
|
||||
return Response.json(
|
||||
{
|
||||
code: '404 Not Found',
|
||||
message: 'Key does not exist or has not propagated.',
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
await kv.delete(key)
|
||||
return Response.json(
|
||||
{
|
||||
message: 'Short URL deleted succesfully.',
|
||||
key: key.slice(1),
|
||||
shorturl: shorturl,
|
||||
longurl: url,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
code: '405 Method Not Allowed',
|
||||
message:
|
||||
'Unsupported method. Please use one of GET, PUT, POST, DELETE, HEAD.',
|
||||
},
|
||||
{
|
||||
status: 405,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function validateUrl(url) {
|
||||
// quick and dirty validation
|
||||
if (url == '') {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
new URL(url)
|
||||
} catch (TypeError) {
|
||||
return false
|
||||
}
|
||||
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(
|
||||
{
|
||||
code: '401 Unauthorized',
|
||||
message: 'Unauthorized.',
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (url == null || !validateUrl(url)) {
|
||||
return Response.json(
|
||||
{
|
||||
code: '400 Bad Request',
|
||||
message: "No valid URL given. Please set a 'URL' header.",
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
await kv.put(key, url)
|
||||
return Response.json(
|
||||
{
|
||||
message: 'URL created succesfully.',
|
||||
key: key.slice(1),
|
||||
shorturl: shorturl,
|
||||
longurl: url,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
)
|
||||
}
|
||||
24
package.json
24
package.json
|
|
@ -1,19 +1,21 @@
|
|||
{
|
||||
"private": true,
|
||||
"name": "worker-links",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"description": "Simple link shortener for Cloudflare Workers.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"format": "prettier --write '**/*.{js,css,json,md}'",
|
||||
"deploy": "wrangler publish",
|
||||
"dev": "wrangler dev --local",
|
||||
"addsecret": "wrangler secret put WORKERLINKS_SECRET"
|
||||
},
|
||||
"author": "Erisa A",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"format": "prettier --write '**/*.{ts,js,css,json,md}'",
|
||||
"deploy": "wrangler publish",
|
||||
"dev": "wrangler dev",
|
||||
"addsecret": "wrangler secret put WORKERLINKS_SECRET"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^2.6.1",
|
||||
"wrangler": "^2.6.2"
|
||||
"@cloudflare/workers-types": "^4.20221111.1",
|
||||
"prettier": "^2.8.4",
|
||||
"wrangler": "^2.12.0"
|
||||
}
|
||||
}
|
||||
270
src/index.ts
Normal file
270
src/index.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { Context, Hono } from "hono";
|
||||
|
||||
type Variables = {
|
||||
path: string;
|
||||
key: string;
|
||||
shortUrl: string;
|
||||
};
|
||||
|
||||
type Bindings = {
|
||||
WORKERLINKS_SECRET: string;
|
||||
KV: KVNamespace;
|
||||
kv: KVNamespace;
|
||||
};
|
||||
|
||||
type BulkUpload = {
|
||||
[id: string]: string;
|
||||
};
|
||||
|
||||
const app = new Hono<{ Variables: Variables; Bindings: Bindings }>();
|
||||
|
||||
// store the path, key and short url for reference in requeests
|
||||
// e.g. c.get('key')
|
||||
app.use("*", async (c, next) => {
|
||||
const path = new URL(c.req.url).pathname;
|
||||
let key = path !== "/" ? path.replace(/\/$/, "") : path;
|
||||
let shortUrl = new URL(key, c.req.url).origin;
|
||||
|
||||
c.set("path", path);
|
||||
c.set("key", key);
|
||||
c.set("shortUrl", shortUrl);
|
||||
|
||||
// backwards compat
|
||||
// i could have left env.kv alone, but i like them being CAPITALS.
|
||||
// what can i say?
|
||||
if (c.env.KV == undefined && c.env.kv !== undefined){
|
||||
c.env.KV = c.env.kv
|
||||
console.warn('WARN: Please change your kv binding to be called KV.')
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// handle auth
|
||||
app.use("*", async (c, next) => {
|
||||
if (c.env.WORKERLINKS_SECRET === undefined) {
|
||||
return c.text("Secret is not defined. Please add WORKERLINKS_SECRET.");
|
||||
}
|
||||
|
||||
if (
|
||||
!["GET", "HEAD"].includes(c.req.method) &&
|
||||
c.req.headers.get("Authorization") !== c.env.WORKERLINKS_SECRET
|
||||
) {
|
||||
return c.json({ code: "401 Unauthorized", message: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// retrieve key
|
||||
app.get("*", handleGetHead);
|
||||
|
||||
// same but for HEAD
|
||||
app.head("*", handleGetHead);
|
||||
|
||||
// handle both GET and HEAD
|
||||
async function handleGetHead(c: Context) {
|
||||
// actual logic goes here
|
||||
const urlResult = await c.env.KV.get(c.get("key"));
|
||||
|
||||
if (urlResult == null) {
|
||||
return c.json(
|
||||
{ code: "404 Not Found", message: " Key does not exist." },
|
||||
404
|
||||
);
|
||||
} else {
|
||||
const searchParams = new URL(c.req.url).searchParams;
|
||||
const newUrl = new URL(urlResult);
|
||||
searchParams.forEach((value, key) => {
|
||||
newUrl.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
if (c.env.PLAUSIBLE_HOST !== undefined) {
|
||||
c.executionCtx.waitUntil(sendToPlausible(c));
|
||||
}
|
||||
|
||||
return c.redirect(newUrl.toString(), 302);
|
||||
}
|
||||
}
|
||||
|
||||
// delete specific key
|
||||
app.delete("*", async (c) => {
|
||||
const urlResult = await c.env.KV.get(c.get("key"));
|
||||
|
||||
if (urlResult == null) {
|
||||
return c.json(
|
||||
{ code: "404 Not Found", message: " Key does not exist." },
|
||||
404
|
||||
);
|
||||
} else {
|
||||
await c.env.KV.delete(c.get("key"));
|
||||
return c.json({
|
||||
message: "Short URL deleted succesfully.",
|
||||
key: c.get("key").slice(1),
|
||||
shorturl: c.get("shortUrl"),
|
||||
longurl: urlResult,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// add specific key
|
||||
app.put("*", createLink);
|
||||
|
||||
// add random key
|
||||
app.post("/", async (c) => {
|
||||
const body = await c.req.text();
|
||||
|
||||
if (!body) {
|
||||
c.set("key", "/" + Math.random().toString(36).slice(5));
|
||||
c.set('shortUrl', new URL(c.get('key'), c.req.url))
|
||||
return await createLink(c);
|
||||
} else {
|
||||
// bulk upload from body
|
||||
|
||||
// REMOVE THIS when bulk upload has been migrated
|
||||
|
||||
return c.json({
|
||||
code: "501 Not Implemented",
|
||||
message: "Bulk upload has not been reimplemented yet.",
|
||||
});
|
||||
|
||||
// what type should this be???
|
||||
let json: any;
|
||||
try {
|
||||
json = JSON.parse(body);
|
||||
} catch {
|
||||
return c.json(
|
||||
{
|
||||
code: "400 Bad Request",
|
||||
message: "Body must be valid JSON, or none at all.",
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// change this
|
||||
const valid = true; //validateBulkBody(json);
|
||||
if (!valid) {
|
||||
return c.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",
|
||||
},
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
for (const [key, url] of json.entries) {
|
||||
await c.env.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, c.req.url),
|
||||
longurl,
|
||||
})),
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async function createLink(c: Context) {
|
||||
const url = c.req.headers.get("URL");
|
||||
|
||||
if (url == null || !validateUrl(url)) {
|
||||
return c.json(
|
||||
{
|
||||
code: "400 Bad Request",
|
||||
message: "No valid URL given. Please set a 'URL' header.",
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
await c.env.KV.put(c.get("key"), url);
|
||||
return Response.json(
|
||||
{
|
||||
message: "URL created succesfully.",
|
||||
key: c.get("key").slice(1),
|
||||
shorturl: c.get("shortUrl"),
|
||||
longurl: url,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
app.post("/*", async (c) =>
|
||||
c.json(
|
||||
{
|
||||
code: "405 Method Not Allowed",
|
||||
message: "POST not valid for individual keys. Did you mean PUT?",
|
||||
},
|
||||
405
|
||||
)
|
||||
);
|
||||
|
||||
app.all("*", (c) =>
|
||||
c.json(
|
||||
{
|
||||
code: "405 Method Not Allowed",
|
||||
message:
|
||||
"Unsupported method. Please use one of GET, PUT, POST, DELETE, HEAD.",
|
||||
},
|
||||
405
|
||||
)
|
||||
);
|
||||
|
||||
export default app;
|
||||
|
||||
// PLAUSIBLE_HOST should be the full URL to your Plausible Analytics instance
|
||||
// e.g. https://plausible.io/
|
||||
async function sendToPlausible(c: Context) {
|
||||
const url = c.env.PLAUSIBLE_HOST + "api/event";
|
||||
const headers = new Headers();
|
||||
headers.append("User-Agent", c.req.headers.get("User-Agent") || "");
|
||||
headers.append("X-Forwarded-For", c.req.headers.get("X-Forwarded-For") || "");
|
||||
headers.append("Content-Type", "application/json");
|
||||
|
||||
const data = {
|
||||
name: "pageview",
|
||||
url: c.req.url,
|
||||
domain: new URL(c.req.url).hostname,
|
||||
referrer: c.req.referrer,
|
||||
};
|
||||
await fetch(url, { method: "POST", headers, body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
function validateUrl(url: string) {
|
||||
// quick and dirty validation
|
||||
if (url == "") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (TypeError) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// zod and other validation libs too
|
||||
// function validateBulkBody(body: Record<string, string>) {
|
||||
// // Starting `/` and no ending `/`
|
||||
// const keyRe = /^\/.*?[^\/]$/;
|
||||
|
||||
// if (!body || typeof body !== "object" || Array.isArray(body)) return false;
|
||||
// return true;
|
||||
// return body.map(
|
||||
// ([key, url]) => keyRe.test(key) && validateUrl(url)
|
||||
// );
|
||||
// }
|
||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"lib": ["esnext"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"jsx": "react-jsx",
|
||||
// "jsxFragmentFactory": "Fragment",
|
||||
"jsxImportSource": "hono/jsx"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
name = "worker-links"
|
||||
main = "index.js"
|
||||
no_bundle = true
|
||||
main = "src/index.ts"
|
||||
|
||||
# optional
|
||||
#logpush = true
|
||||
|
||||
# Change these!!
|
||||
account_id = "ece1d09b06af2ced51407c97505ea0cc"
|
||||
kv_namespaces = [ { binding = "kv", id = "1be44406edc142a084435e24dbf8ae1d", preview_id = "15cfb90ecd654b8f8a9ccd600832093f" }]
|
||||
kv_namespaces = [ { binding = "KV", id = "1be44406edc142a084435e24dbf8ae1d", preview_id = "15cfb90ecd654b8f8a9ccd600832093f" }]
|
||||
|
||||
compatibility_flags = []
|
||||
compatibility_date = "2022-07-05"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue