feat: end to end api type safety with redis rate limiting (#573)
* feat: support hono rpc * add wrangler file * update name of wrangler file * add wrangler as a dependency * add compatibility_flags * trigger ci * core: add health check to docker file * expose port 80 * update custom healthcheck * feat: implement openapi * install dependencies in root * debug yml * update yml * update port * update dockerfile with envs * completely move away from apiClient * rename backend to api * refactor: update server descriptions and comment out local spec saving * update lock file * use api.classroomio.com * add support for redis * fix: add missing line break in WelcomeModal.svelte * refactor: remove wrangler configuration and update Vite config for API integration
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { getSupabase } from '../supabase';
|
||||
|
||||
const supabase = getSupabase();
|
||||
|
||||
export async function validateUser(accessToken: string): Promise<User> {
|
||||
let user: User | null = null;
|
||||
|
||||
try {
|
||||
const { data } = await supabase.auth.getUser(accessToken);
|
||||
user = data.user;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Unauthenticated user');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { TCertificateDownload } from '$src/types/course/lesson';
|
||||
import { getCloudflarePdfBuffer } from '$src/utils/cloudflare';
|
||||
|
||||
export interface CertificateData {
|
||||
theme: string;
|
||||
studentName: string;
|
||||
courseName: string;
|
||||
courseDescription: string;
|
||||
orgLogoUrl: string;
|
||||
orgName: string;
|
||||
facilitator?: string;
|
||||
}
|
||||
|
||||
export interface CertificateGenerationResult {
|
||||
success: boolean;
|
||||
buffer?: Buffer;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const generateCertificate = async (data: TCertificateDownload) => {
|
||||
const { html, styles } = generateCertificateHtml(data);
|
||||
return getCloudflarePdfBuffer(html, styles);
|
||||
};
|
||||
|
||||
function generateCertificateHtml(data: TCertificateDownload): { html: string; styles: string } {
|
||||
const { studentName, courseName, courseDescription, orgLogoUrl, orgName, facilitator } = data;
|
||||
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Certificate of Completion</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="certificate">
|
||||
<div class="header">
|
||||
<img src="${orgLogoUrl}" alt="${orgName} Logo" class="logo">
|
||||
<h1 class="title">Certificate of Completion</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2 class="student-name">${studentName}</h2>
|
||||
<p class="course-name">${courseName}</p>
|
||||
<p class="description">${courseDescription}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="signature">
|
||||
<p>${facilitator || 'Course Facilitator'}</p>
|
||||
<p>${orgName}</p>
|
||||
</div>
|
||||
<p class="date">${new Date().toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const styles = `
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.certificate {
|
||||
width: 1123px;
|
||||
height: 794px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
position: relative;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.logo {
|
||||
max-width: 200px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.title {
|
||||
font-size: 36px;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.content {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
.student-name {
|
||||
font-size: 48px;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.course-name {
|
||||
font-size: 24px;
|
||||
color: #34495e;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.description {
|
||||
font-size: 18px;
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.signature {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.date {
|
||||
margin-top: 10px;
|
||||
color: #95a5a6;
|
||||
}
|
||||
`;
|
||||
const pdf = {
|
||||
html: html,
|
||||
styles: styles
|
||||
};
|
||||
|
||||
return pdf;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CLOUDFLARE } from '$src/constants';
|
||||
|
||||
export const getCloudflarePdfBuffer = async (html: string, styles?: string) => {
|
||||
console.log('Generating PDF with Cloudflare API...');
|
||||
try {
|
||||
const pdfResponse = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE.CONFIGS.ACCOUNT_ID}/browser-rendering/pdf`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${CLOUDFLARE.CONFIGS.RENDERING_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
html: html,
|
||||
addStyleTag: [{ content: `${styles}` }]
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
console.log('PDF response status:', pdfResponse.status);
|
||||
const arrayBuffer = await pdfResponse.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
throw new Error(error instanceof Error ? error.message : 'Failed to generate PDF');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { TCourseDownloadContent } from '$src/types/course';
|
||||
import { getCloudflarePdfBuffer } from '$src/utils/cloudflare';
|
||||
import { marked } from 'marked';
|
||||
|
||||
function getHtmlTemplate(body: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
|
||||
rel="stylesheet" />
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
|
||||
<title>Lesson Note</title>
|
||||
<style>
|
||||
html {
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
margin: 0.5rem 0 1.5rem 0;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.roboto-mono {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-top: 5%;
|
||||
}
|
||||
|
||||
.root {
|
||||
width: 95%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/tailwindcss">
|
||||
@tailwind base;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.prose {
|
||||
max-width: unset;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="w-full break-after-page">
|
||||
${body}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
type TLessonBody = TCourseDownloadContent['lessons'][0];
|
||||
|
||||
function getLessonBody(
|
||||
{ lessonTitle, lessonNumber, lessonNote, slideUrl, video }: TLessonBody,
|
||||
orgName: string,
|
||||
orgTheme: string
|
||||
): string {
|
||||
const noteHtml = marked.parse(lessonNote);
|
||||
const showExtraResources = slideUrl || (video && video.length > 0);
|
||||
return `
|
||||
<div
|
||||
class="bg-[${orgTheme ? orgTheme : '#0030FF'}] w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
|
||||
<h3
|
||||
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-[${orgTheme ? orgTheme : '#0030FF'}] bg-white text-md font-bold tracking-tighter">
|
||||
${lessonNumber}
|
||||
</h3>
|
||||
<h1 class="text-white m-0">${lessonTitle}</h1>
|
||||
<h4 class="roboto-mono text-md font-normal text-white m-0">By ${orgName}</h4>
|
||||
</div>
|
||||
|
||||
<main class="break-after-page">
|
||||
<section>
|
||||
${noteHtml}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
${
|
||||
showExtraResources
|
||||
? `
|
||||
<section>
|
||||
<h1 class='text-xl font-bold my-1'>Extra Resources</h1>
|
||||
${
|
||||
slideUrl
|
||||
? `<div class="flex mt-2 mb-1">
|
||||
<p class="m-0 mr-2">Slide Link:</p> <a href=${slideUrl} style="color: blue" class="underline">Open Here</a>
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
video && video.length > 0
|
||||
? `
|
||||
<p class="m-0">Video Link:</p>
|
||||
<div class="m-0">
|
||||
<ol class="m-0">
|
||||
${video
|
||||
.map(
|
||||
(videoData, index) => `
|
||||
<li key=${
|
||||
index + 1
|
||||
}><a href=${videoData} style="color: blue" class="underline">${videoData}</a></li>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</ol>
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
</section>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
`;
|
||||
}
|
||||
|
||||
function getCourseBody({
|
||||
courseTitle,
|
||||
orgName,
|
||||
orgTheme,
|
||||
lessons
|
||||
}: TCourseDownloadContent): string {
|
||||
return `
|
||||
<header class="flex justify-center items-center flex-col border-[${orgTheme ? orgTheme : '#0030FF'}] border-[40px] mx-3 h-[100vh] m-0">
|
||||
<h1 class="font-bold text-8xl text-black m-0 mx-96">${courseTitle}</h1>
|
||||
<h4 class="text-md text-2xl font-light text-black mt-5 ">${orgName}</h4>
|
||||
</header>
|
||||
<div class="root mx-auto mt-5 prose break-after-page">
|
||||
${lessons.map((lesson) => getLessonBody(lesson, orgName, orgTheme)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function generateSinglePdfFromHtml(
|
||||
htmlContent: string,
|
||||
courseTitle: string,
|
||||
orgTheme: string
|
||||
): Promise<Buffer> {
|
||||
return await getCloudflarePdfBuffer(htmlContent);
|
||||
}
|
||||
|
||||
export async function generateCoursePdf(params: TCourseDownloadContent): Promise<Buffer> {
|
||||
const body = getCourseBody(params);
|
||||
const html = getHtmlTemplate(body);
|
||||
console.log(html);
|
||||
return await generateSinglePdfFromHtml(html, params.courseTitle, params.orgTheme);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { SendMailClient } from 'zeptomail';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
import { env } from '$src/config/env';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
export const zohoClient = new SendMailClient({
|
||||
url: 'api.zeptomail.eu/',
|
||||
token: env.ZOHO_TOKEN!
|
||||
});
|
||||
|
||||
export const nodemailerTransporter = async (): Promise<Transporter | null> => {
|
||||
if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASSWORD) {
|
||||
console.error('SMTP configuration missing');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: parseInt(env.SMTP_PORT || '465', 10),
|
||||
secure: true,
|
||||
auth: {
|
||||
user: env.SMTP_USER,
|
||||
pass: env.SMTP_PASSWORD
|
||||
}
|
||||
});
|
||||
|
||||
await transporter.verify();
|
||||
|
||||
return transporter;
|
||||
} catch (error) {
|
||||
console.error('Transporter error:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function genUniqueId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { TLessonDownloadContent } from '$src/types/course/lesson';
|
||||
import { marked } from 'marked';
|
||||
|
||||
function getHtmlTemplate(body: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
|
||||
rel="stylesheet" />
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
|
||||
<title>Lesson Note</title>
|
||||
<style>
|
||||
html {
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: rgb(0, 48, 255);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
margin: 0.5rem 0 1.5rem 0;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.roboto-mono {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-top: 5%;
|
||||
}
|
||||
|
||||
.root {
|
||||
width: 95%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/tailwindcss">
|
||||
@tailwind base;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.prose {
|
||||
max-width: unset;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="w-full">
|
||||
${body}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function getLessonBody({
|
||||
title,
|
||||
number,
|
||||
orgName,
|
||||
note,
|
||||
slideUrl,
|
||||
video,
|
||||
courseTitle
|
||||
}: TLessonDownloadContent): string {
|
||||
const noteHtml = marked.parse(note);
|
||||
const showExtraResources = slideUrl || (video && video.length > 0);
|
||||
return `
|
||||
<div class="root mx-auto mt-5 prose">
|
||||
<header
|
||||
class="w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
|
||||
<h3
|
||||
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-blue-700 bg-white text-md font-bold tracking-tighter">
|
||||
${number}
|
||||
</h3>
|
||||
<h1 class="text-white m-0">${title}</h1>
|
||||
<h4 class="roboto-mono text-md font-normal text-white m-0">By ${orgName}</h4>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section>
|
||||
${noteHtml}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
${
|
||||
showExtraResources
|
||||
? `
|
||||
<section>
|
||||
<h1 class='text-xl font-bold my-1'>Extra Resources</h1>
|
||||
${
|
||||
slideUrl
|
||||
? `<div class="flex mt-2 mb-1">
|
||||
<p class="m-0 mr-2">Slide Link:</p> <a href=${slideUrl} style="color: blue" class="underline">Open Here</a>
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
video && video.length > 0
|
||||
? `
|
||||
<p class="m-0">Video Link:</p>
|
||||
<div class="m-0">
|
||||
<ol class="m-0">
|
||||
${video
|
||||
.map(
|
||||
(videoData, index) => `
|
||||
<li key=${
|
||||
index + 1
|
||||
}><a href=${videoData} style="color: blue" class="underline">${videoData}</a></li>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</ol>
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
</section>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function generateSinglePdfFromHtml(
|
||||
htmlContent: string,
|
||||
courseTitle: string
|
||||
): Promise<Buffer> {
|
||||
throw 'Download disabled';
|
||||
}
|
||||
|
||||
export async function generateLessonPdf(params: TLessonDownloadContent): Promise<Buffer> {
|
||||
const body = getLessonBody(params);
|
||||
const html = getHtmlTemplate(body);
|
||||
console.log(html);
|
||||
return await generateSinglePdfFromHtml(html, params.courseTitle);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
interface FromData {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// format: "ClassroomIO Developers (via ClassroomIO.com)" <notify@mail.classroomio.com>
|
||||
export function extractNameAndEmail(str: string): FromData | undefined {
|
||||
// Use regular expressions to match the name and email
|
||||
const regex = /"(.*?)"\s+<\s*(.*?)@(.*?)\s*>/;
|
||||
const match = str.match(regex);
|
||||
|
||||
if (match) {
|
||||
// Extract the name and email from the match groups
|
||||
const name = match[1];
|
||||
const email = match[2] + '@' + match[3];
|
||||
return { name, email };
|
||||
} else {
|
||||
// Return undefined if the format doesn't match
|
||||
return { name: str, email: str };
|
||||
}
|
||||
}
|
||||
|
||||
export const withEmailTemplate = (content: string): string =>
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
|
||||
<base target="_blank" />
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: #F6F9FF;
|
||||
font-family: "Poppins", "Helvetica Neue", "Segoe UI", Helvetica,
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 26px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f4f4f4;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top: 1px dashed #94a3b8;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table td {
|
||||
padding: 5px 10px 0px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.socialicons {
|
||||
background-color: #fff;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
width: initial;
|
||||
margin: 0 auto;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.brandcolor {
|
||||
color: #00c4b8;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
background-color: #f1f5f9;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
color: #475569;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tooltip a {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-top: 12px;
|
||||
background: #0233BD;
|
||||
border-radius: 8px;
|
||||
text-decoration: none !important;
|
||||
color: #fff !important;
|
||||
font-weight: 500;
|
||||
padding: 10px 30px;
|
||||
display: inline-block;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: #0233BD;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
padding: 5px 30px;
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid rgb(221, 208, 208);
|
||||
max-width: 525px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.gutter img {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.wrap {
|
||||
max-width: auto;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="
|
||||
background-color: #F6F9FF;
|
||||
font-family: 'Poppins', 'Helvetica Neue', 'Segoe UI', Helvetica,
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 26px;
|
||||
padding: 20px 0;
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
">
|
||||
<div class="gutter">
|
||||
<a href="https://classroomio.com" target="_blank">
|
||||
<img src="https://brand.cdn.clsrio.com/cio-bg-transparent.png" alt="ClassroomIO Logo" /></a>
|
||||
</div>
|
||||
<div class="wrap" style="
|
||||
background-color: #fff;
|
||||
padding: 40px 30px;
|
||||
max-width: 525px;
|
||||
margin: 0 auto;
|
||||
">
|
||||
${content}
|
||||
</div>
|
||||
|
||||
<div class="footer" style="
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
padding: 30px;
|
||||
background-color: #0542CC;
|
||||
max-width: 525px;
|
||||
margin: 0 auto;">
|
||||
<p>Find us on</p>
|
||||
<table class="socialicons">
|
||||
<tr>
|
||||
<td>
|
||||
<a target="_blank" href="https://twitter.com/classroomio"><img title="X"
|
||||
src="https://brand.cdn.clsrio.com/socials/twitter.png" alt="X" width="19" /></a>
|
||||
</td>
|
||||
<td>
|
||||
<a target="_blank" href="https://www.youtube.com/@ClassroomIO"><img title="Youtube"
|
||||
src="https://brand.cdn.clsrio.com/socials/youtube.png" alt="Youtube" width="24" /></a>
|
||||
</td>
|
||||
<td>
|
||||
<a target="_blank" href="https://www.facebook.com/classroomiohq"><img title="Facebook"
|
||||
src="https://brand.cdn.clsrio.com/socials/facebook.png" alt="Facebook" width="24" /></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="padding-top: 8px; line-height: initial">
|
||||
ClassroomIO ${new Date().getFullYear()}. All rights reserved.<br />
|
||||
<a style="text-decoration: none" href="https://app.enzuzo.com/policies/tos/958fc978-5477-11ee-a03b-7b111830c594"
|
||||
target="_blank">Terms</a>
|
||||
|
|
||||
<a style="text-decoration: none"
|
||||
href="https://app.enzuzo.com/policies/privacy/958fc978-5477-11ee-a03b-7b111830c594" target="_blank">Privacy
|
||||
Policy</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
`;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Hono } from 'hono';
|
||||
import { Scalar } from '@scalar/hono-api-reference';
|
||||
import { env } from '$src/config/env';
|
||||
|
||||
export function configureOpenAPI(app: Hono) {
|
||||
if (env.OPENAPI_URL) {
|
||||
app.get('/docs', Scalar({ url: env.OPENAPI_URL, theme: 'none' }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Key generation utilities for rate limiting
|
||||
* These functions help create consistent and secure rate limit keys
|
||||
*/
|
||||
|
||||
import type { Context } from 'hono';
|
||||
import { supabase } from '$src/utils/supabase';
|
||||
|
||||
/**
|
||||
* Extract user ID from JWT token
|
||||
* In a real implementation, you'd decode and verify the JWT
|
||||
*/
|
||||
export const extractUserIdFromToken = async (token: string): Promise<string | null> => {
|
||||
try {
|
||||
const { data } = await supabase.auth.getUser(token);
|
||||
return data.user?.id || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract real client IP address considering various proxy headers
|
||||
*/
|
||||
export const extractClientIp = (c: Context): string => {
|
||||
// Priority order (most trusted first):
|
||||
// 1. CF-Connecting-IP (Cloudflare)
|
||||
// 2. X-Real-IP (nginx)
|
||||
// 3. X-Forwarded-For (standard, but can be spoofed)
|
||||
// 4. Connection IP (least reliable)
|
||||
|
||||
const cfConnectingIp = c.req.header('cf-connecting-ip');
|
||||
const realIp = c.req.header('x-real-ip');
|
||||
const forwardedFor = c.req.header('x-forwarded-for');
|
||||
|
||||
if (cfConnectingIp) {
|
||||
return cfConnectingIp;
|
||||
}
|
||||
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
if (forwardedFor) {
|
||||
// x-forwarded-for can contain multiple IPs: "client, proxy1, proxy2"
|
||||
// The first IP is usually the original client
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
// Fallback - this might be a proxy IP
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate rate limit key for authenticated users
|
||||
*/
|
||||
export const userKeyGenerator = (c: Context): string => {
|
||||
const authHeader = c.req.header('authorization');
|
||||
|
||||
if (authHeader) {
|
||||
const token = authHeader.split(' ')[1];
|
||||
if (token && token !== 'null' && token !== 'undefined') {
|
||||
const userId = extractUserIdFromToken(token);
|
||||
if (userId) {
|
||||
return `user:${userId}`;
|
||||
}
|
||||
// Fallback to token if user ID extraction fails
|
||||
return `token:${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
// No valid auth, fall back to IP
|
||||
return `ip:${extractClientIp(c)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate rate limit key for API keys
|
||||
*/
|
||||
export const apiKeyGenerator = (c: Context): string => {
|
||||
const apiKey = c.req.header('X-API-Key');
|
||||
|
||||
if (apiKey) {
|
||||
return `api:${apiKey}`;
|
||||
}
|
||||
|
||||
// Fallback to IP if no API key
|
||||
return `ip:${extractClientIp(c)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate rate limit key based on IP only
|
||||
*/
|
||||
export const ipKeyGenerator = (c: Context): string => {
|
||||
return `ip:${extractClientIp(c)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate rate limit key for specific endpoints
|
||||
*/
|
||||
export const endpointKeyGenerator =
|
||||
(endpoint: string) =>
|
||||
(c: Context): string => {
|
||||
const baseKey = userKeyGenerator(c);
|
||||
return `${baseKey}:${endpoint}`;
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
DEFAULT_MAX_REQUESTS,
|
||||
DEFAULT_WINDOW_MS,
|
||||
ERROR_MESSAGES,
|
||||
RATE_LIMIT_KEY_PREFIX
|
||||
} from '$src/constants/rate-limiter';
|
||||
|
||||
import { Redis } from 'ioredis';
|
||||
import { redis } from './redis';
|
||||
|
||||
export class RedisRateLimiter {
|
||||
private redis: Redis;
|
||||
private windowMs: number;
|
||||
private maxRequests: number;
|
||||
|
||||
constructor(
|
||||
redis: Redis,
|
||||
windowMs: number = DEFAULT_WINDOW_MS,
|
||||
maxRequests: number = DEFAULT_MAX_REQUESTS
|
||||
) {
|
||||
this.redis = redis;
|
||||
this.windowMs = windowMs;
|
||||
this.maxRequests = maxRequests;
|
||||
}
|
||||
|
||||
async isAllowed(
|
||||
key: string
|
||||
): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
|
||||
const now = Date.now();
|
||||
const windowStart = now - this.windowMs;
|
||||
const keyWithPrefix = `${RATE_LIMIT_KEY_PREFIX}${key}`;
|
||||
|
||||
// Use Redis pipeline for atomic operations
|
||||
const pipeline = this.redis.pipeline();
|
||||
|
||||
// Remove expired entries
|
||||
pipeline.zremrangebyscore(keyWithPrefix, '-inf', windowStart);
|
||||
|
||||
// Count current requests in window
|
||||
pipeline.zcard(keyWithPrefix);
|
||||
|
||||
// Add current request
|
||||
pipeline.zadd(keyWithPrefix, now, `${now}-${Math.random()}`);
|
||||
|
||||
// Set expiration for the key
|
||||
pipeline.expire(keyWithPrefix, Math.ceil(this.windowMs / 1000));
|
||||
|
||||
const results = await pipeline.exec();
|
||||
|
||||
if (!results || results.some((result) => result[0] !== null)) {
|
||||
throw new Error(ERROR_MESSAGES.REDIS_PIPELINE_FAILED);
|
||||
}
|
||||
|
||||
const currentCount = results[1][1] as number;
|
||||
const remaining = Math.max(0, this.maxRequests - currentCount - 1);
|
||||
const resetTime = now + this.windowMs;
|
||||
|
||||
return {
|
||||
allowed: currentCount < this.maxRequests,
|
||||
remaining,
|
||||
resetTime
|
||||
};
|
||||
}
|
||||
|
||||
async reset(key: string): Promise<void> {
|
||||
const keyWithPrefix = `${RATE_LIMIT_KEY_PREFIX}${key}`;
|
||||
await this.redis.del(keyWithPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
export const rateLimiter = new RedisRateLimiter(redis);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Redis } from 'ioredis';
|
||||
import { env } from '$src/config/env';
|
||||
|
||||
export const redis = new Redis(env.REDIS_URL);
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectCommandInput,
|
||||
GetObjectCommand,
|
||||
GetObjectCommandInput,
|
||||
PutObjectCommand,
|
||||
PutObjectCommandInput,
|
||||
S3Client
|
||||
} from '@aws-sdk/client-s3';
|
||||
|
||||
import { CLOUDFLARE } from '$src/constants';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
|
||||
export type GetSignedUrlParameters = Parameters<typeof getSignedUrl>;
|
||||
|
||||
export const s3Client = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${CLOUDFLARE.CONFIGS.ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: CLOUDFLARE.CONFIGS.ACCESS_KEY,
|
||||
secretAccessKey: CLOUDFLARE.CONFIGS.SECRET_ACCESS_KEY
|
||||
}
|
||||
});
|
||||
|
||||
export async function uploadToS3(
|
||||
params: PutObjectCommandInput
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await s3Client.send(new PutObjectCommand(params));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFromS3(
|
||||
params: GetObjectCommandInput
|
||||
): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
try {
|
||||
const data = await s3Client.send(new GetObjectCommand(params));
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFromS3(
|
||||
params: DeleteObjectCommandInput
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await s3Client.send(new DeleteObjectCommand(params));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { SupabaseClient, createClient } from '@supabase/supabase-js';
|
||||
|
||||
import { env } from '$src/config/env';
|
||||
|
||||
const { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } = env;
|
||||
|
||||
export let supabase: SupabaseClient | null = null;
|
||||
|
||||
interface SupabaseConfig {
|
||||
supabaseConfig: {
|
||||
url: string;
|
||||
anonKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function getSupabase(config?: SupabaseConfig): SupabaseClient {
|
||||
if (supabase) return supabase;
|
||||
|
||||
const finalConfig = config ?? {
|
||||
supabaseConfig: {
|
||||
url: PUBLIC_SUPABASE_URL,
|
||||
anonKey: PUBLIC_SUPABASE_ANON_KEY
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
supabase = createClient(finalConfig.supabaseConfig.url, finalConfig.supabaseConfig.anonKey, {
|
||||
auth: { persistSession: false }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error starting supabase', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return supabase;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Safely extracts the file extension from a filename.
|
||||
* Returns an empty string if no extension is found.
|
||||
*/
|
||||
export function getExtension(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') return '';
|
||||
const ext = path.extname(filename).split('.');
|
||||
return ext.length > 1 ? ext[ext.length - 1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique file key for storage, preserving the file extension.
|
||||
* Throws an error if the filename is invalid or has no extension.
|
||||
*/
|
||||
export function generateFileKey(fileName: string): string {
|
||||
const ext = getExtension(fileName);
|
||||
if (!ext) {
|
||||
throw new Error('Invalid file name or missing file extension');
|
||||
}
|
||||
return `${nanoid()}.${ext}`;
|
||||
}
|
||||
Reference in New Issue
Block a user