Building a "serverless™" pastebin using cloudflare workers + kv with auth and sharing.
This pastebin will be an update to my previous pastebin project which has become the single most used self-made tool since I've built... despite being super insecure and janky. It has made sharing data between my college's lab systems and my mobile devices a breeze. So I decided to give it an update. This version has these features ->
Authentication and authorisation using jwt tokens.
Customizable sessions where I can set a particular time for jwt token expiry (one of my labs had restrictions on opening incognito tabs so this removes the need to open incognito again)
CRD -> I purposefully removed update feature for better consistency when sharing (I'll get to this point later).
CORS support and CSRF protections.
micro (<15mb) file storage solution.
I decided to go with cloudflare workers as I've used their other services before and I really like the company. Also I wanted to see what can workers KV actually so I've tried incorporating all of its features into it.
Building a cf project is pretty simple, you just need to install wrangler cli and login into your cf account. The cli will guide you through the rest of the process.
It will generate a project structure kind of like this ->
The main worker script will reside in the index.js file and the worker configurations will reside in the wrangler.toml file.
My tooling file looks like this ->
Here name is the name of project and type is webpack. cf supports(officially) building workers in rust(compiled using wasm) , pure javascript or using a bundler like webpack. Do note not all node compatible package are cross compatible with cloudflare, especially the once using node's standard library. Because unlike other server-less solutions workers run on bare isolated v8 instances instead of spinning up a node container every time a request comes through. This effectively eliminates the cold start problem I've faced with gcp .
kv namepaces variable hold our namespaces ids and their aliases along with the preview id's reuired when running the workers dev server.
The [vars] are the global environment variables which get stored in the cloudflare's secret store. I have my main passkey for the pastebin and my jwt signature stored here. It recommended to use a thrid paarty secrets manager if you are deploying it in production tho.
You can install the npm packages using the standard npm commands and then use wrangler build command to build the project.
wrangler dev -> spins up a test server for locally testing the workers
wrangler publish -> builds and publishes the workers on the cf web.
So lets look at our index.js file . Interestingly most of it (minus cors) was written in just under 4 hours and its barely 150 LOC for the WHOLE api !
import { Router } from 'itty-router'
const jwt = require('@tsndr/cloudflare-worker-jwt')
const router = Router()
//Csrf Headers -> change before prod
const myHeaders = new Headers();
myHeaders.set("Access-Control-Allow-Origin", '*');
myHeaders.set("Access-Control-Allow-Headers", '*');
myHeaders.set("Access-Control-Allow-Methods", "GET,HEAD,POST,OPTIONS");
myHeaders.set("Access-Control-Max-Age", "86400");
// myHeaders.set("Access-Control-Allow-Credentials", "true")
//Root Page
router.get("/", async () => {
myHeaders.set("content-type", "text/html")
return new Response(html, { status: 200, headers: myHeaders })
})
//Cookie Parser
function getToken(request) {
var list = {};
const cookieHeader = request.headers.get("Cookie")
if (!cookieHeader) return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" //bandaid plz fix before prod
cookieHeader.split(';')
.map(v => v.split('='))
.reduce((acc, v) => {
acc[decodeURIComponent(v[0].trim())] = decodeURIComponent(v[1].trim());
list = acc
}, {});
const token = list["passkey"]
return token
}
//Pass all the csrf preflight -> chage before prod
router.options("*", async () => {
return new Response("OK", { status: 200, headers: myHeaders })
})
//Jwt generator
router.get("/authgen", async request => {
const pass = request.headers.get("passkey")
const exp = request.headers.get("expires")
if (pass == passkey) {
const token = await jwt.sign({
name: 'doom slayer',
email: 'okdoomer@lol.com',
exp: Math.floor(Date.now() / 1000) + (parseFloat(exp) * (60 * 60)) // Expires: Now + Xh
}, signature)
myHeaders.set('Set-Cookie', `passkey=${token}; Path=/ ;Max-Age=${(parseFloat(exp) * (60 * 60))}; Secure; HttpOnly;`)//add SameSite=None to cookie props for cors "stuff"
return new Response(token, { status: 200, headers: myHeaders })
}
else {
return new Response("401, wrong passkey", { status: 401, headers: myHeaders })
}
})
//view a single post
router.get("/view", async request => {
const token = getToken(request)
const isValid = await jwt.verify(token, signature)
if (isValid == true) {
const pid = request.headers.get("pid")
const paste = await pastebin.get(pid);
return new Response(paste, { status: 200, headers: myHeaders })
}
else if (valid == false) {
return new Response("401, wrong passkey", { status: 401, headers: myHeaders })
}
})
//list all posts
router.get("/list", async request => {
const token = getToken(request)
const isValid = await jwt.verify(token, signature)
if (isValid == true) {
const value = await pastebin.list()
const json = JSON.stringify(value)
myHeaders.set("content-type", "application/json;charset=UTF-8")
return new Response(json, { status: 200, headers: myHeaders })
}
else {
return new Response("401, token expired or invalid", { status: 401, headers: myHeaders })
}
})
//view a shared post
router.get("/share/:id", async ({ params }) => {
let input = params.id
const paste = await publicbin.get(input);
return new Response(paste, { status: 200, headers: myHeaders })
})
//create a new post
router.post("/create", async request => {
const token = getToken(request)
const isValid = await jwt.verify(token, signature)
const body = await request.formData()
const ct = body.get('content')
const title_ = body.get('title')
let flag = body.get('public')
if (flag == null) {
flag = "0"
}
const ts = Date.now()
if (isValid == true && ct != null) {
await pastebin.put(ts, ct, { metadata: { title: title_, timeStamp: Date.now(), isPublic: flag } })
if (flag == "1") {
await publicbin.put(ts, ct, { metadata: { title: title_ } })
}
return new Response(Date.now(), { status: 200, headers: myHeaders })
}
else {
return new Response("401, wrong passkey or invalid request idk", { status: 401, headers: myHeaders })
}
})
//Delete a post
router.get("/delete", async request => {
const token = getToken(request)
const isValid = await jwt.verify(token, signature)
const pid = request.headers.get("pid")
let isPublic = request.headers.get("isPublic")
if (isPublic == null) {
isPublic = "0";
}
if (isValid == true && pid != null) {
if (isPublic == "1") {
await publicbin.delete(pid)
}
await pastebin.delete(pid)
return new Response("done", { status: 200, headers: myHeaders })
}
else {
return new Response("401, wrong passkey or invalid request idk", { status: 401, headers: myHeaders })
}
})
//404
router.all("*", () => new Response("404, not found!", { status: 404, headers: myHeaders }))
//Connect to router
addEventListener('fetch', (e) => {
e.respondWith(router.handle(e.request))
})
addEventListener("fetch", event => {
event.respondWith(handleRequest(event.request))
})
The code is kind of crude with some band-aids here and there. You should probably configure cors and csrf stuff as your liking. I've given a rough template on how it should happen.
I've used two namespace for kv store. One of all the private+public stuff and one for exclusively public stuff, which helps me separating the public facing - no auth side of my api more efficiently.
I've used the itty-router library for basic routing and cloudflare-worker-jwt library for generating and validating jwt tokens . both of these are specifically written for cf workers.
I also wrote a super sketchy frontend using materialise which took me more then 6 hours to finish and reminded me why I only do backend these days.
The frontend is served through the root route "/" using the worker itself thus making my cors pains a little bit better (and believe me cors is a yuuge PITA esp if you're working on compliance).
Now storing files is kinda tricky. I thought about adding dropbox/gdrve api to the worker but building such system without using o-auth is an uphill battle. I've used my previous pastebin at some extremely sketchy locations/cyber-cafes so I need a robust way of storing and accessing files quickly. Also my upload requirements are pretty basic (its mostly code I wrote someplace else, pdfs or word files).
I landed up on this solution -> encode the files in base64 url string -> chuck it into database -> use browser's built in decode function to decode and download the file -> (optional: for larger files, shard the url in chunks and store it in different kv's with its access table written in keys metadata making a adhoc linked list)
workers kv supports the value size of upto 25MB.. keeping in mind the 33% penalty for base64 encoding, it gives me 16.75MB of file storage per record which is plenty for my current use case. I may add shardng in a future update.
ggs till then
ps: here's the demo