Trong bài đăng này, tôi nói về cách tôi xây dựng một giải pháp thay thế nguồn mở cho Jira Kanban Board bằng cách sử dụng Upstash, SvelteKit và Firebase Storage.

Những gì chúng tôi sẽ sử dụng
- SvelteKit (Các tuyến giao diện người dùng và API)
- Upstash (Hoạt động CRUD)
- CSS Tailwind (Tạo kiểu)
- Bộ nhớ Firebase (Bộ nhớ nội dung [hình ảnh, pdf, v.v.])
- SvelteKit Auth của Auth.js
Những gì bạn cần
- Tài khoản Upstash để tạo cơ sở dữ liệu
- Tài khoản Firebase để tạo vùng lưu trữ
- Thiết lập Google OAuth 2.0 để nhận thông tin xác thực OAuth
Thiết lập Upstash Redis
Khi bạn đã tạo tài khoản Upstash và đăng nhập, bạn sẽ chuyển tới tab Redis và tạo cơ sở dữ liệu.


Sau khi tạo cơ sở dữ liệu, bạn sẽ chuyển đến tab Chi tiết. Cuộn xuống cho đến khi bạn tìm thấy phần Kết nối cơ sở dữ liệu của bạn. Sao chép nội dung và lưu ở nơi an toàn.

Ngoài ra, hãy cuộn xuống cho đến khi bạn tìm thấy phần API REST và chọn nút .env. Sao chép nội dung và lưu ở nơi an toàn.

Thiết lập dự án
Để thiết lập, chỉ cần sao chép kho ứng dụng và làm theo hướng dẫn này để tìm hiểu mọi thứ có trong đó. Để phân nhánh dự án, hãy chạy:
git clone https://github.com/rishi-raj-jain/jira-sveltekit-firebase-storage-upstash-starter
cd jira-sveltekit-firebase-storage-upstash-starter
npm install Khi bạn đã sao chép kho lưu trữ, bạn sẽ tạo một tệp .env. Bạn sẽ thêm các mục chúng tôi đã lưu từ các phần trên.
Nó sẽ trông giống như thế này:
# .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": "..."
} Sau các bước này, bạn sẽ có thể khởi động môi trường cục bộ bằng lệnh sau:
npm run dev Cấu trúc kho lưu trữ
Đây là cấu trúc thư mục chính của dự án. Tôi đã đánh dấu bình phương màu đỏ cho các tệp sẽ được thảo luận thêm trong bài đăng này liên quan đến Hoạt động CRUD, SvelteKit Auth và Trình xử lý tải lên tệp, cùng với các tệp mà chúng được tham chiếu.

Bảo vệ chức năng biên của SvelteKit bằng xác thực người dùng
Một tác phẩm tuyệt vời của nhóm tại Auth.js đã giúp Auth với SvelteKit trở thành một hoạt động liền mạch. Dự án thực hiện:
Ủy quyền trên tất cả các trang bằng Google OAuth 2.0
Bằng cách sử dụng Server Hooks của SvelteKit, chúng tôi thực thi Auth đối với tất cả các yêu cầu gửi đến (đến bất kỳ trang nào):
// File: @/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";
// 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; Ủy quyền đối với (các) Chức năng biên bằng cách sử dụng Máy chủ cục bộ của SvelteKit
Bằng cách sử dụng Server Locals của SvelteKit, chúng tôi có thể chọn tham gia để kiểm tra xem người dùng có được xác thực trong bất kỳ hoạt động nào chỉ phía máy chủ hay không. Dưới đây là ví dụ về việc sử dụng nó để xác thực xem người dùng có được xác thực hay không trong khi tạo sự cố mới:
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'
// 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'
}
})
} (Các) vấn đề về hoạt động CRUD thông qua Upstash Redis
Trong phần này, chúng ta sẽ đi sâu vào cách thực hiện tìm nạp, cập nhật và xóa dữ liệu cho từng vấn đề trên bảng Kanban. Chúng tôi thường xuyên sử dụng Upstash DB(thông qua @upstash/redis ) để tìm nạp, hiển thị và làm mới dữ liệu.
getTask:Đang tìm nạp chức năng dữ liệu sự cố
getTask hàm sử dụng hget của Upstash qua id làm chìa khóa để gửi yêu cầu API tới Upstash về vấn đề có liên quan, được xác định bằng một id duy nhất . Nếu vấn đề đó không xuất hiện (hoặc có lỗi), hàm sẽ được đặt để trả về một đối tượng có { code: 0 } để sau đó người dùng có thể được tự động chuyển hướng đến 404 (không tìm thấy sự cố) trong tuyến động của SvelteKit.
type Task = { [key: string]: any } | null;
// Get Issue Data
// File: @/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,
};
}
} Tương tự các thao tác CRUD còn lại như sau:
// Create Issue
// File: @/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: @/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: @/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,
};
}
} Giới hạn tỷ lệ
Để triển khai giới hạn tỷ lệ ở biên, chúng tôi sử dụng Upstash Redis máy khách cơ sở dữ liệu và thư viện giới hạn tốc độ có tên @upstash/ratelimit .
// Reference Function to ratelimiting
// File: @/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"),
}),
}; Bằng cách sử dụng Giới hạn tỷ lệ, tôi đã có thể đạt được những điều sau:
A. Giới hạn số lần tạo vấn đề cho mỗi người dùng mỗi phút
Bằng cách sử dụng Giới hạn tỷ lệ, tôi có thể giới hạn việc tạo năm vấn đề cho mỗi người dùng được xác thực mỗi phút. Chúng tôi có thể thực thi giới hạn tốc độ này dựa trên email người dùng của người dùng được xác thực.
// File: @/routes/api/issue/+server.ts
// Issue Creation POST API SvelteKit Handler
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) {
// 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. Giới hạn số lần tải tệp lên cho mỗi người dùng cho mỗi số phát hành mỗi phút
Bằng cách sử dụng Giới hạn tốc độ, tôi có thể giới hạn số lần tải tệp lên tối đa 2 cho mỗi người dùng được xác thực cho mỗi tác vụ mỗi phút. Chúng tôi có thể thực thi giới hạn tốc độ này dựa trên email người dùng của người dùng được xác thực và ID của tác vụ. Bất cứ khi nào quá trình tải lên hoàn tất thành công, chúng tôi sẽ cập nhật tác vụ trong Upstash DB với fileURL được thêm vào đó.
// File: @/routes/api/content/+server.ts
// File Upload POST API SvelteKit Handler
import { ratelimit } from "@/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,
});
} Xử lý tải lên và tải xuống tệp bằng bộ lưu trữ Firebase
Trong phần này, chúng ta sẽ đi sâu vào cách xử lý việc tải lên và tải xuống tệp của một sự cố theo cách an toàn và xác thực trên cạnh của SvelteKit. Chúng tôi tận dụng Bộ nhớ Firebase (v9) để tìm nạp và tải tệp lên.
Ồ, nhưng tại sao không có Cloudflare R2 để lưu trữ?
Mặc dù tôi đã thấy rất nhiều sự ủng hộ của cộng đồng đối với gói lưu trữ miễn phí của Cloudflare R2 và những lợi ích của nó, nhưng có một điều khiến tôi thất vọng là tôi cần phải chuyển thẻ tín dụng của mình cho Cloudflare sử dụng trước khi dùng thử hệ thống. Điều này khiến tôi suy nghĩ về các giải pháp lưu trữ khác và tôi đã chuyển sang Firebase Storage, nơi cung cấp cho tôi 5 GB dung lượng lưu trữ miễn phí. Trong trường hợp tôi vượt quá dung lượng đó, các dịch vụ của tôi sẽ bị dừng thay vì tính phí vào thẻ tín dụng mà không có sự chấp thuận của tôi và biết chuyện gì đang xảy ra.
Chức năng SvelteKit Edge để tải tệp lên bộ lưu trữ Firebase
Trong hàm Edge sau đây, chúng tôi đang xem xét bất kỳ sự kiện yêu cầu POST nào và nếu người dùng được xác thực, chúng tôi sẽ nhận được taskID và file từ formData của sự kiện. Sau khi thực hiện xong, chúng tôi sẽ đánh giá thêm xem có nên tiếp tục hay không nếu kích thước tệp dưới 5 MB. Khi tất cả các điều kiện tiên quyết đã được xử lý, chúng tôi sẽ tạo một ID duy nhất, sau đó tạo tham chiếu của firebase đến thư mục duy nhất nơi tệp được tải lên. Ngay sau khi tệp được tải lên firebase, nó sẽ trả về cho chúng tôi một URL có thể được sử dụng để truy cập tệp đã tải lên. Chúng tôi nối URL duy nhất này vào files dữ liệu chính của vấn đề.
// File: @/routes/api/content/+server.ts
// File Upload POST API SvelteKit Handler
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) {
// 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,
});
} Chức năng SvelteKit Edge để tải xuống URL công khai của tệp từ bộ lưu trữ Firebase
Như bạn còn nhớ, chúng tôi đã thêm URL duy nhất được Firebase trả về trong files của vấn đề chìa khóa. Chúng tôi nhận được URL duy nhất đó dưới dạng tham số hình ảnh trong yêu cầu GET tới Edge Function của SvelteKit để truy xuất tệp gốc. Chúng tôi sử dụng hàm getDownloadURL từ thư viện của firebase để lấy URL công khai của phương tiện gốc.
// File: @/routes/api/content/+server.ts
// File Upload GET API SvelteKit Handler
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",
},
});
} Như bạn đã nghĩ, có thể tải lên nhiều phương tiện, vì vậy, để xử lý trường hợp tầm thường giữa hình ảnh và video, tôi đã thêm phần sau vào giao diện người dùng:
<!-- File: @/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} Nhưng tại sao lại là nguồn mở thay thế cho Jira Kanban Board?
Có rất nhiều lợi ích sẽ khiến bạn sử dụng giải pháp thay thế nguồn mở của Jira Kanban Board thay vì mua các giải pháp phải trả phí cao:
- Tiết kiệm nhiều chi phí:Một trong những lợi ích đáng kể nhất của việc sử dụng giải pháp thay thế nguồn mở là tiết kiệm chi phí. Không giống như các giải pháp bảng Kanban trả phí như Jira, một giải pháp thay thế nguồn mở được xây dựng bằng SvelteKit, TailwindCSS, Firebase Storage, Serverless DB của Upstash và Rate Limiting có thể được sử dụng mà không phải trả bất kỳ khoản phí cấp phép nào.
- Khả năng tùy chỉnh không giới hạn:Với giải pháp thay thế nguồn mở, bạn có toàn quyền kiểm soát cơ sở mã và có thể tùy chỉnh bảng Kanban theo nhu cầu cụ thể của mình. Tính linh hoạt này thường không thể thực hiện được với các giải pháp trả phí có các tùy chọn tùy chỉnh hạn chế.
- Dễ dàng tích hợp:Bạn có thể tận dụng sức mạnh của API để kết nối bảng Kanban của mình với hệ thống quản lý dự án, công cụ kiểm soát phiên bản, dịch vụ thông báo, v.v. Ngoài ra, tính chất nguồn mở của dự án cho phép các nhà phát triển mở rộng chức năng của nó và tạo ra các plugin hoặc tiện ích tích hợp phù hợp với yêu cầu cụ thể của họ.
Kết luận
Tóm lại, dự án này đã cung cấp kinh nghiệm quý báu trong việc triển khai Giới hạn tỷ lệ chi tiết, hoạt động dữ liệu CRUD, triển khai API lưu trữ Firebase để tải và tải tệp lên, tất cả đều được thực hiện ở biên với @upstash/redis của Upstash thư viện!