Redis
 Computer >> コンピューター >  >> プログラミング >> Redis

Firebase・Upstash・SvelteKitで作るオープンソースJIRAクローン開発ガイド

本記事では、Upstash、SvelteKit、Firebase Storageを活用して、Jiraのカンバンボードに代わるオープンソースアプリケーションを構築した方法について詳しく解説します。

Firebase・Upstash・SvelteKitで作るオープンソースJIRAクローン開発ガイド

使用する技術スタック

  • SvelteKit(UIおよびAPIルート)
  • Upstash(CRUD操作)
  • Tailwind CSS(スタイリング)
  • Firebase Storage(画像やPDFなどのアセット保存)
  • Auth.jsによるSvelteKit Auth

事前に必要なもの

  • データベース作成用のUpstashアカウント
  • ストレージコンテナ作成用のFirebaseアカウント
  • OAuthクレデンシャル取得用のGoogle OAuth 2.0設定

Upstash Redisのセットアップ

Upstashアカウントを作成してログインしたら、「Redis」タブに移動し、データベースを作成します。

Firebase・Upstash・SvelteKitで作るオープンソースJIRAクローン開発ガイド

Firebase・Upstash・SvelteKitで作るオープンソースJIRAクローン開発ガイド

データベース作成後、「Details」タブを開き、「Connect your database」セクションが見つかるまでスクロールします。表示された内容をコピーして、安全な場所に保存しておきましょう。

Firebase・Upstash・SvelteKitで作るオープンソースJIRAクローン開発ガイド

さらに下にスクロールして「REST API」セクションを見つけ、「.env」ボタンを選択します。ここでも内容をコピーして保管してください。

Firebase・Upstash・SvelteKitで作るオープンソースJIRAクローン開発ガイド

プロジェクトのセットアップ

セットアップは簡単です。アプリのリポジトリをクローンし、このチュートリアルに沿って中身を学んでいきましょう。プロジェクトをフォークするには、以下のコマンドを実行します。

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

リポジトリをクローンしたら、.envファイルを作成し、前述の手順で保存した情報を追加します。ファイルは次のようになります。

# .env
 
# Google OAuth 2.0設定から取得
# https://support.google.com/cloud/answer/6158849?hl=en
GOOGLE_ID="..."
GOOGLE_SECRET="..."
 
# SvelteKit Auth
AUTH_SECRET="..." # ランダムな32文字の文字列
AUTH_TRUST_HOST=true
 
# 上記の手順でUpstashから取得
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
// Firebaseプロジェクトから取得した設定を使用
// 詳細はFirebaseドキュメントを参照
// 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": "..."
}

これらの手順が完了すれば、次のコマンドでローカル環境を起動できます。

npm run dev

リポジトリ構成

これはプロジェクトの主要なフォルダ構成です。赤枠で囲んだファイルは、本記事で後ほど詳しく説明するCRUD操作、SvelteKit Auth、ファイルアップロードハンドラに関わる部分で、それらが参照されるファイルも併せて示しています。

Firebase・Upstash・SvelteKitで作るオープンソースJIRAクローン開発ガイド

ユーザー認証によるSvelteKitエッジ関数の保護

Auth.jsチームの素晴らしい取り組みのおかげで、SvelteKitでの認証はシームレスに実装できます。このプロジェクトでは以下を実現しています。

Google OAuth 2.0による全ページへの認可

SvelteKitのサーバーフック(Server Hooks)を使い、あらゆるページへのリクエストに対して認証を強制しています。

// ファイル: @/hooks.server.ts
 
import Google from "@auth/core/providers/google";
import { SvelteKitAuth } from "@auth/sveltekit";
import type { Handle } from "@sveltejs/kit";
import { GOOGLE_ID, GOOGLE_SECRET } from "$env/static/private";
 
// 詳細はこちら
// 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;

Server Localsを使ったエッジ関数での認可

SvelteKitのServer Localsを利用すると、サーバーサイドのみで動作する任意の処理において、ユーザーが認証済みかどうかを選択的に確認できます。以下は、新しい課題を作成する際にユーザーの認証状態を検証する例です。

import { json } from '@sveltejs/kit'
import { isAuth } from '@/lib/auth'
import type { RequestEvent } from './$types'
import { getTask, getTasks } from '@/lib/issues'
import type { LayoutServerLoadEvent } from '../routes/$types'
import type { RequestEvent, ServerLoadEvent } from '@sveltejs/kit'
 
// 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) {
 // 未認証の場合は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'
 }
 })
}

Upstash Redisによる課題のCRUD操作

このセクションでは、カンバンボード上の各課題について、データの取得・更新・削除がどのように行われているのかを掘り下げます。データの取得、表示、更新には、Upstash DB(@upstash/redis経由)を一貫して使用しています。

getTask:課題データを取得する関数

getTask関数は、Upstashのhgetidをキーとして使用し、一意のidで識別される該当課題のデータをUpstashへAPIリクエストして取得します。課題が存在しない場合(またはエラー発生時)は、{ code: 0 }を含むオブジェクトを返すよう設計されており、これによりSvelteKitのダイナミックルートで自動的に404(課題が見つかりません)へリダイレクトされます。

type Task = { [key: string]: any } | null;
 
// 課題データの取得
// ファイル: @/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,
 };
 }
}

同様に、残りのCRUD操作は以下の通りです。

// 課題の作成
// ファイル: @/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,
 };
 }
}
// 課題の削除
// ファイル: @/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,
 };
 }
}
// 課題データの更新
// ファイル: @/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)

エッジでレート制限を実装するために、Upstash Redisデータベースクライアントと、@upstash/ratelimitというレートリミッターライブラリを使用しています。

// レート制限の参考関数
// ファイル: @/lib/upstash/ratelimit.ts
import { Ratelimit } from "@upstash/ratelimit";
 
import redis from "./setup";
 
export const ratelimit = {
 upload: new Ratelimit({
 redis,
 limiter: Ratelimit.slidingWindow(2, "60s"),
 }),
 issues: new Ratelimit({
 redis,
 limiter: Ratelimit.slidingWindow(5, "60s"),
 }),
};

レート制限を導入することで、以下を実現できました。

A. ユーザーごとの1分あたりの課題作成数の制限

レート制限により、認証済みユーザーごとに1分あたり最大5件の課題作成に制限できます。この制限は、認証済みユーザーのメールアドレスに基づいて適用されます。

// ファイル: @/routes/api/issue/+server.ts
// 課題作成POST APIのSvelteKitハンドラ
import { ratelimit } from "@/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) {
 // エッジで認証済みユーザーのメールアドレスを確認
 // 1分あたり5件の課題作成にレート制限
 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. ユーザー・課題ごとの1分あたりファイルアップロード数の制限

レート制限により、認証済みユーザーごと・タスクごとに1分あたり最大2件のファイルアップロードに制限できます。この制限は、認証済みユーザーのメールアドレスとタスクIDに基づいて適用されます。アップロードが正常に完了するたびに、fileURLを追記した形でUpstash DB内のタスクを更新します。

// ファイル: @/routes/api/content/+server.ts
// ファイルアップロードPOST APIのSvelteKitハンドラ
import { ratelimit } from "@/lib/upstash/ratelimit";
 
export async function POST(event: RequestEvent) {
 // ユーザー認証コード
 if (user.session.user?.email) {
 // ユーザー、タスクID、ファイルの有無を検証
 // エッジで認証済みユーザーのメールアドレスとタスクIDを確認
 // 1分あたり2件のアップロードにレート制限
 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",
 },
 },
 );
 }
 // ファイルアップロードコード
 // アップロード処理の詳細は記事の続きを読んでください
 }
 return new Response(undefined, {
 status: 403,
 });
}

Firebase Storageによるファイルのアップロード・ダウンロード処理

このセクションでは、課題のファイルアップロードとダウンロードを、SvelteKitのエッジ上でセキュアかつ認証付きで処理する方法を掘り下げます。ファイルの取得とアップロードには、Firebase(v9)Storageを活用しています。

なぜCloudflare R2ではなくFirebase Storageなのか?

Cloudflare R2の無料ストレージプランやその利点を支持する声をコミュニティで多く見かけますが、私が引っかかったのは、システムを試す前にクレジットカード情報をCloudflareに登録しなければならない点でした。そこで他のストレージソリューションを検討し、たどり着いたのがFirebase Storageです。Firebase Storageなら5GBの無料枠が提供され、仮に超過しても、承諾なしにクレジットカードへ請求されることなく、サービスが停止されるだけだからです。

Firebase StorageへファイルをアップロードするSvelteKitエッジ関数

次のエッジ関数では、POSTリクエストイベントを受け取り、ユーザーが認証済みであれば、イベントのformDataからtaskIDfileを取得します。その後、ファイルサイズが5MB未満であることを確認して処理を続行するかどうかを判定します。前提条件がすべて整ったら、一意のIDを生成し、ファイルのアップロード先となる一意のフォルダへのFirebase参照を作成します。ファイルがFirebaseへアップロードされると、アクセス用のURLが返却されます。この一意のURLを、課題データのfilesキーに追記します。

// ファイル: @/routes/api/content/+server.ts
// ファイルアップロードPOST APIのSvelteKitハンドラ
import { initializeApp } from "firebase/app";
import { getDownloadURL, getStorage, ref, uploadBytes } from "firebase/storage";
 
import fireBaseConfig from "../../../../firebase-adminsdk.json";
 
export async function POST(event: RequestEvent) {
 // ユーザー認証コード
 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");
 
 // ...ユーザー、タスクID、ファイルの有無を検証
 // ...レート制限コード
 
 // ファイルサイズの制限
 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",
 },
 },
 );
 }
 
 // ファイルアップロード開始
 try {
 // 一意のIDを作成
 const fileId = uuidv4();
 // アップロードされたものがFile型でない場合
 if (!(file instanceof File)) return;
 // Firebase Storageへの参照を作成
 const storageRef = ref(storage, `uploads/${fileId}/${file.name}`);
 // アップロードされたファイルのarrayBufferを取得
 const fileBuffer = await file.arrayBuffer();
 // Uint8Arrayとしてバイト単位でFirebase Storageへアップロード
 const { metadata } = await uploadBytes(
 storageRef,
 new Uint8Array(fileBuffer),
 );
 const { fullPath } = metadata;
 // fullPathが取得できない場合はAPIエラー
 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",
 },
 },
 );
 }
 // アップロード成功時は、ファイルURLを課題データの添付リストに追記
 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}`,
 ];
 }
 }
 // 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,
 });
}

Firebase Storageからファイルの公開URLを取得するSvelteKitエッジ関数

思い出してください。Firebaseが返した一意のURLを、課題のfilesキーに追記しました。元のファイルを取得するためのSvelteKitエッジ関数へのGETリクエストでは、その一意のURLがimageパラメータとして渡されます。FirebaseライブラリのgetDownloadURL関数を使うことで、元のメディアの公開URLを取得できます。

// ファイル: @/routes/api/content/+server.ts
// ファイル取得GET APIのSvelteKitハンドラ
import { initializeApp } from "firebase/app";
import { getDownloadURL, getStorage, ref, uploadBytes } from "firebase/storage";
 
import fireBaseConfig from "../../../../firebase-adminsdk.json";
 
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",
 },
 });
}

お気づきの通り、アップロードできるメディアは複数種類あります。画像と動画という単純なケースを判別するために、フロントエンド側に以下のようなif else文を追加しています。

<!-- ファイル: @/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}

なぜJiraカンバンボードのオープンソース代替なのか?

高額な有料ソリューションを購入する代わりに、Jiraカンバンボードのオープンソース代替を選ぶべき理由は数多くあります。

  • 大幅なコスト削減: オープンソース代替の最大のメリットの一つはコスト削減です。Jiraのような有料カンバンボードとは異なり、SvelteKit、TailwindCSS、Firebase Storage、UpstashのサーバーレスDB、レート制限で構築されたオープンソース代替は、ライセンス費用なしで利用できます。
  • 無制限のカスタマイズ性: オープンソースならコードベースを完全に掌握でき、自分のニーズに合わせてカンバンボードを自由にカスタマイズできます。この柔軟性は、カスタマイズオプションが限られる有料ソリューションでは得られないことが多いでしょう。
  • 容易な統合: APIの力を借りて、カンバンボードをプロジェクト管理システム、バージョン管理ツール、通知サービスなどと連携できます。さらに、プロジェクトがオープンソースであることで、開発者は機能を拡張し、自身の要件に合わせたプラグインやインテグレーションを作成することも可能です。

まとめ

このプロジェクトを通じて、きめ細かなレート制限の実装、CRUDデータ操作、Firebase Storage APIを使ったファイルの取得・アップロードなど、すべてをUpstashの@upstash/redisライブラリでエッジ上に実装する貴重な経験を得ることができました!

  1. Redis ZINCRBYコマンドの使い方 – Redisでソート済みセットの要素スコアをインクリメントする方法

    このチュートリアルでは、Redisデータストアに保存されたソート済みセット(Sorted Set)型の値について、特定の要素のスコアをインクリメントする方法を学びます。この操作には、redis-cliでZINCRBYコマンドを使用します。 ZINCRBYコマンドは、キーに保存されているソート済みセットの値の中から指定した要素を探し、そのスコアを指定した値(increment)だけ増加させるために使用します。もし指定した要素がソート済みセット内に存在しない場合は、その要素が新しく追加され、指定したincrement値がスコアとして設定されます。また、キー自体が存在しない場合には、指定した要素のみ

  2. Ably・Upstash Redis・Node.jsで構築するリアルタイムチャットアプリの作り方

    本記事では、ユーザーがチャットグループに参加し、リアルタイムでコミュニケーションできるシンプルなリアルタイムチャットアプリケーションを作成します。 低レイテンシでユーザー間のリアルタイムメッセージングを実現する「Ably」、メッセージを永続的に保存する「Upstash Redis」、そしてアプリケーションを構築するための「Node.js」という3つの技術を組み合わせて実装していきます。 Ablyとは Ablyは、ユーザー間の双方向通信を可能にするリアルタイムエクスペリエンスプラットフォームです。 このチャットアプリでは、AblyのPub/Subチャネルを活用します。ユーザーはAblyチャネルに