·14 min read

Building an open-source JIRA using Firebase, Upstash and SvelteKit

Rishi Raj JainRishi Raj JainTechnical CSM @Edgio (Guest Author)

In this post, I talk about how I built a open-source alternative to Jira Kanban Board using Upstash, SvelteKit, and Firebase Storage.

Screenshot 2023-05-29 at 2.27.44 PM.png

What we’ll be using

What you'll need

Setting up Upstash Redis

Once you have created an Upstash account and are logged in you are going to go to the Redis tab and create a database.

Untitled

Untitled

After you have created your database, you are then going to the Details tab. Scroll down until you find the Connect your database section. Copy the content and save it somewhere safe.

Untitled

Also, scroll down until you find the REST API section and select the .env button. Copy the content and save it somewhere safe.

Untitled

Setting up the project

To set up, just clone the app repo and follow this tutorial to learn everything that's in it. To fork the project, run:

git clone https://github.com/rishi-raj-jain/jira-sveltekit-firebase-storage-upstash-starter
cd jira-sveltekit-firebase-storage-upstash-starter
npm install

Once you have cloned the repo, you are going to create a .env file. You are going to add the items we saved from the above sections.

It should look something like this:

# .env
 
# Obtained from Google OAuth 2.0 setup
# https://support.google.com/cloud/answer/6158849?hl=en
GOOGLE_ID="..."
GOOGLE_SECRET="..."
 
# SvelteKit Auth
AUTH_SECRET="..." # A random 32 char string
AUTH_TRUST_HOST=true
 
# Obtained from Upstash as from the steps done above
UPSTASH_REDIS_REST_URL="your_upstash_redis_rest__url_from_above"
UPSTASH_REDIS_REST_TOKEN="your_upstash_redis_rest__token_from_above"
// firebase-adminsdk.json
// with the firebase config obtained from your firebase project
// Read more about firebase config
// https://firebase.google.com/docs/web/learn-more#config-object
 
{
	"type": "...",
	"project_id": "...",
	"private_key_id": "...",
	"private_key": "...",
	"client_email": "...",
	"client_id": "...",
	"auth_uri": "...",
	"token_uri": "...",
	"auth_provider_x509_cert_url": "...",
	"client_x509_cert_url": "...",
	"universe_domain": "...",
	"storageBucket": "..."
}

After these steps, you should be able to start the local environment using the following command:

npm run dev

Repository Structure

This is the main folder structure for the project. I have squared in red the files that will be discussed further in this post that deals with CRUD Operations, SvelteKit Auth, and File Upload Handler, with the files they are referenced in.

image.png

Protecting SvelteKit’s Edge Function By User Authentication

A great work by the team at Auth.js has made Auth with SvelteKit a seamless operation. The project implements:

Authorization on all the pages using Google OAuth 2.0

Using SvelteKit’s Server Hooks, we enforce Auth on all incoming requests (to any page):

// File: @/src/hooks.server.ts
 
import type { Handle } from '@sveltejs/kit'
import { SvelteKitAuth } from '@auth/sveltekit'
import Google from '@auth/core/providers/google'
import { GOOGLE_ID, GOOGLE_SECRET } from '$env/static/private'
 
// Read more on
// https://kit.svelte.dev/docs/hooks#server-hooks-handle
export const handle = SvelteKitAuth({
	// @ts-ignore
	providers: [Google({ clientId: GOOGLE_ID, clientSecret: GOOGLE_SECRET })]
}) satisfies Handle

Authorization on Edge Function(s) using SvelteKit’s Server Locals

Using SvelteKit’s Server Locals, we can opt-in to check if the user is authenticated in any server side only operation. Below is the example of using it in validating if the user is authenticated while creating a new issue:

import { json } from '@sveltejs/kit'
import { isAuth } from '@/src/lib/auth'
import type { RequestEvent } from './$types'
import { getTask, getTasks } from '@/src/lib/issues'
import type { LayoutServerLoadEvent } from '../routes/$types'
import type { RequestEvent, ServerLoadEvent } from '@sveltejs/kit'
 
// Get user session if available in event locals
const isAuth = async (event: LayoutServerLoadEvent | ServerLoadEvent | RequestEvent) => {
	const session = await event.locals.getSession()
	if (session?.user?.image) {
		return { session }
	}
	return false
}
 
export async function GET(event: RequestEvent) {
	// If user is not authenticated throw a 403
	if (!(await isAuth(event))) {
		return new Response(undefined, {
			status: 403
		})
	}
	const url = event.url
	const idSearchParam = url.searchParams.get('id')
	if (idSearchParam) {
		const res = await getTask(idSearchParam)
		return json(res)
	} else if (url.searchParams.get('all')) {
		const res = await getTasks()
		return json(res)
	}
	return new Response(JSON.stringify({ code: 0, error: 'Invalid Request.' }), {
		status: 400,
		headers: {
			'content-type': 'application/json'
		}
	})
}

Issue(s) CRUD Operations via Upstash Redis

In this section, we'll be diving deep into how the data fetching, updating and deletion for each issue on Kanban board is done. We make constant use of Upstash DB(via @upstash/redis) to fetch, display and refresh data.

getTask: Fetching the issue data function

The getTask function uses Upstash’s hget via id as the key to make an API request to Upstash for the relevant issue ata, identified by a unique id. If that issue is not present (or there is an error), the function is set to return an object with a { code: 0 } so that then the user can be redirected to 404 (issue not found) automatically in SvelteKit’s dynamic route.

type Task = { [key: string]: any } | null
 
// Get Issue Data
// File: @/src/lib/issues/get.ts
export async function getTask(id: string) {
	try {
		const redis = (await import('../upstash/setup')).default
		const task: Task = await redis.hget('issues', id)
		if (!task) {
			return {
				code: 0,
				error: 'No such issue found.'
			}
		}
		return { ...task, code: 1 }
	} catch (e: any) {
		const error = e.message || e.toString()
		console.log(error)
		return {
			code: 0,
			error
		}
	}
}

Similarly, the remaining CRUD operations are as follows:

// Create Issue
// File: @/src/lib/issues/create.ts
export async function createTask(info: any) {
	try {
		const redis = (await import('../upstash/setup')).default
		const id = Math.random().toString().slice(2) + new Date().getUTCMilliseconds()
		await redis.hset('issues', { [id]: info })
		return { code: 1, id, message: 'Issue Created Succesfully ✅' }
	} catch (e: any) {
		const error = e.message || e.toString()
		console.log(error)
		return {
			code: 0,
			error
		}
	}
}
// Delete Issue
// File: @/src/lib/issues/delete.ts
export async function deleteTask(id: string) {
	try {
		const redis = (await import('../upstash/setup')).default
		await redis.hdel('issues', id)
		return { code: 1, message: 'Deleted Succesfully!' }
	} catch (e: any) {
		const error = e.message || e.toString()
		console.log(error)
		return {
			code: 0,
			error
		}
	}
}
// Update Issue Data
// File: @/src/lib/issues/update.ts
export async function updateTask(info: any, id: string) {
	try {
		const redis = (await import('../upstash/setup')).default
		if (id) {
			const task = await redis.hget('issues', id)
			if (task) {
				await redis.hset('issues', { [id]: info })
				return { code: 1, message: 'Updated Successfully' }
			}
		}
		return {
			code: 0,
			error: 'No such issue was found.'
		}
	} catch (e: any) {
		const error = e.message || e.toString()
		console.log(error)
		return {
			code: 0,
			error
		}
	}
}

Rate Limiting

To implement rate-limiting at the edge, we use Upstash Redis database client and a rate limiter library called @upstash/ratelimit.

// Reference Function to ratelimiting
// File: @/src/lib/upstash/ratelimit.ts
import redis from './setup'
import { Ratelimit } from '@upstash/ratelimit'
 
export const ratelimit = {
	upload: new Ratelimit({
		redis,
		limiter: Ratelimit.slidingWindow(2, '60s')
	}),
	issues: new Ratelimit({
		redis,
		limiter: Ratelimit.slidingWindow(5, '60s')
	})
}

Using Rate Limiting, I was able to achieve the following:

A. Limit No. Of Issue Creation Per User Per Minute

Using Rate Limiting, I am able to limit creation of five issues per authenticated user per minute. We’re able to enforce this rate limit based on the user email of the authenticated user.

// File: @/src/routes/api/issue/+server.ts
// Issue Creation POST API SvelteKit Handler
import { ratelimit } from '@/src/lib/upstash/ratelimit'
 
export async function POST(event: RequestEvent) {
	const user = await isAuth(event)
	if (!user) {
		return new Response(undefined, {
			status: 403
		})
	}
	if (user.session.user?.email) {
		// Look at the user email of authenticated user at edge
		// Rate limit 5 issues creation per minute
		const result = await ratelimit.issues.limit(user.session.user.email)
		if (!result.success) {
			return new Response(JSON.stringify({ code: 0, error: `You can't create more than 5 issues per minute.` }), {
				status: 403,
				headers: {
					'content-type': 'application/json'
				}
			})
		}
		const { info } = await event.request.json()
		const res = await createTask(info)
		return json(res)
	}
	return new Response(undefined, {
		status: 403
	})
}

B. Limit No. Of File Uploads Per User Per Issue Per Minute

Using Rate Limiting, I am able to limit file uploads to upto 2 per authenticated user per task per minute. We’re able to enforce this rate limit based on the user email of the authenticated user and task’s ID. Whenever the upload get’s successfully completed, we update the task in the Upstash DB with the fileURL appended to it.

// File: @/src/routes/api/content/+server.ts
// File Upload POST API SvelteKit Handler
import { ratelimit } from '@/src/lib/upstash/ratelimit'
 
export async function POST(event: RequestEvent) {
	// User Authentication Code
	if (user.session.user?.email) {
    // Validate User, Task ID and if a file is uploaded
		// Look at the user email of authenticated user and task's ID at edge
		// Rate limit 2 uploads per minute
		const result = await ratelimit.upload.limit(`${user.session.user.email}_${taskID}`)
		if (!result.success) {
			return new Response(JSON.stringify({ code: 0, error: `You can't upload more than 2 files per issue per minute.` }), {
				status: 403,
				headers: {
					'content-type': 'application/json'
				}
			})
		}
		// File upload code
		// Continue reading the blog to see how
    // file uploads are being taken care of
	}
	return new Response(undefined, {
		status: 403
	})
}

Handling File Uploads and Downloads with Firebase Storage

In this section, we'll be diving deep into how an issue’s file uploads and downloads are handled in secure and authenticated manner on SvelteKit’s edge. We leverage Firebase (v9) Storage to fetch and upload files to.

Ooh, but why not Cloudflare R2 for storage?

While I’ve seen lot of community advocacy for Cloudflare R2’s free storage plan and it’s advantages, the one thing that threw me off was the need to put my credit card at Cloudflare’s disposal before even trying the system out. This made me ponder about other storage solutions, and I landed up to Firebase Storage which offers me 5 GB of free storage, and in case I exceed it, my services will be stopped instead of charging my credit card without my approval and know how of what’s happening.

SvelteKit Edge Function to Upload Files to Firebase Storage

In the following Edge function, we’re looking at any POST request event and if the user is authenticated, we obtain the taskID and file from the event’s formData. With that done, we further evaluate whether to continue if the file size is below 5 MB. Once all the prerequisites are taken care of, we create a unique ID, and then create a firebase’s reference to the unique folder where the file be uploaded to. As soon as the file is uploaded to firebase, it returns us with a URL that can be used to access the file uploaded. We append this unique URL to files key of issue’s data.

// File: @/src/routes/api/content/+server.ts
// File Upload POST API SvelteKit Handler
import { initializeApp } from 'firebase/app'
import fireBaseConfig from '../../../../firebase-adminsdk.json'
import { getDownloadURL, getStorage, ref, uploadBytes } from 'firebase/storage'
 
export async function POST(event: RequestEvent) {
	// User Authentication Code
	if (user.session.user?.email) {
		const app = initializeApp(fireBaseConfig)
		const storage = getStorage(app)
		const data = await event.request.formData()
		const taskID = data.get('taskID')
		const file = data.get('file')
 
    // ...Validate User, Task ID and if a file is uploaded
    // ...Rate Limiting Code
 
		// File Size Restriction(s)
		if (file.size > 5 * 1024 * 1024) {
			return new Response(JSON.stringify({ code: 0, error: 'File size exceeds the limit of 5 MB.' }), {
				status: 400,
				headers: {
					'content-type': 'application/json'
				}
			})
		}
 
    // Start File Upload Code
		try {
		  // Create a unique ID
			const fileId = uuidv4()
			// If uploaded is not a File type
			if (!(file instanceof File)) return
			// Create a ref to firebase storage
			const storageRef = ref(storage, `uploads/${fileId}/${file.name}`)
			// Obtain the arrayBuffer of the file uploaded
			const fileBuffer = await file.arrayBuffer()
			// Upload file to Firebase Storage in bytes using Uint8Array
			const { metadata } = await uploadBytes(storageRef, new Uint8Array(fileBuffer))
			const { fullPath } = metadata
			// No fullPath is received, the API errored out
			if (!fullPath) {
				return new Response(
					JSON.stringify({
						code: 0,
						error: `<span>There was some error while uploading the file.</span> <span class="mt-1 text-xs text-gray-500">Report an issue with the current URL that you are on and with the code XXX.</span>`
					}),
					{
						status: 403,
						headers: {
							'content-type': 'application/json'
						}
					}
				)
			}
			// If a file is uploaded successfully, append the file to list of attachments to the issue's data
			const { code, ...taskValues } = await getTask(taskID)
			if (code === 1) {
				if (taskValues) {
					if (taskValues.hasOwnProperty('files')) {
						taskValues['files'].push(`https://storage.googleapis.com/${storageRef.bucket}/${storageRef.fullPath}`)
					} else {
						taskValues['files'] = [`https://storage.googleapis.com/${storageRef.bucket}/${storageRef.fullPath}`]
					}
				}
				// Update the task's data in Upstash
				await updateTask(taskValues, taskID)
			}
			return json({
				code: 1,
				message: 'Uploaded Successfully'
			})
		} catch (error) {
			return new Response(JSON.stringify({ code: 0, error: error.message || error.toString() }), {
				status: 403,
				headers: {
					'content-type': 'application/json'
				}
			})
		}
	}
	return new Response(undefined, {
		status: 403
	})
}

SvelteKit Edge Function to Download File’s Public URL from Firebase Storage

As you’d recall, we appended the unique URL returned by Firebase in issue’s files key. We receive that unique URL as the image parameter in the GET request to the SvelteKit’s Edge Function for retrieving the original file. We make use of the getDownloadURL function from firebase’s library to get the public URL of the original media.

// File: @/src/routes/api/content/+server.ts
// File Upload GET API SvelteKit Handler
import { initializeApp } from 'firebase/app'
import fireBaseConfig from '../../../../firebase-adminsdk.json'
import { getDownloadURL, getStorage, ref, uploadBytes } from 'firebase/storage'
 
export async function GET(event: RequestEvent) {
	if (!(await isAuth(event))) {
		return new Response(undefined, {
			status: 403
		})
	}
	const url = event.url
	const image = url.searchParams.get('image')
	if (image) {
		try {
			const app = initializeApp(fireBaseConfig)
			const storage = getStorage(app)
			const fileRef = ref(storage, image)
			const imagePublicURL = await getDownloadURL(fileRef)
			return json({ code: 1, image: imagePublicURL })
		} catch (error) {
			return new Response(JSON.stringify({ code: 0, error: error.message || error.toString() }), {
				status: 500,
				headers: {
					'content-type': 'application/json'
				}
			})
		}
	}
	return new Response(JSON.stringify({ code: 0, error: 'Invalid Request.' }), {
		status: 400,
		headers: {
			'content-type': 'application/json'
		}
	})
}

As you’d be already thinking, there can be multiple media that can be uploaded, so to handle the trivial case of an image vs video, I’ve added the following if else to the front-end:

<!-- File: @/src/routes/issue/[slug]/+page.svelte -->
 
{#each fieldFiles as file}
	<div class="mt-8 w-full border border-white/25 p-3">
		{#if /\.(mp4|mov|mkv)/i.test(file)}
			<video class="h-auto w-full" src={file} controls>
				<track kind="captions" />
			</video>
		{:else}
			<img alt={file} src={file} class="h-auto w-full" />
		{/if}
	</div>
{/each}

But why an open source alternative to Jira Kanban Board?

There are numerous benefits which’ll make you go with the open source alternative of Jira Kanban Board instead of purchasing heavily paid solutions:

  • Much Cost Savings: One of the most significant benefits of using an open source alternative is the cost savings. Unlike paid Kanban board solutions like Jira, an open source alternative built with SvelteKit, TailwindCSS, Firebase Storage, Upstash's Serverless DB, and Rate Limiting can be used without any licensing fees.
  • Unlimited Customisability: With an open source alternative, you have full control over the codebase and can customize the Kanban board according to your specific needs. This flexibility is often not possible with paid solutions that have limited customisation options.
  • Ease in Integrations: You can leverage the power of APIs to connect your Kanban board with project management systems, version control tools, notification services, and more. Additionally, the open source nature of the project allows developers to extend its functionality and create plugins or integrations tailored to their specific requirements.

Conclusion

In conclusion, this project has provided valuable experience in implementing Granular Rate Limiting, CRUD data operations, implement Firebase Storage APIs to get and upload files, all of it done at the edge with Upstash’s @upstash/redis library!