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:
Rotimi Best
2025-09-21 19:35:41 +01:00
committed by GitHub
parent a6880f1b54
commit 6d68351a46
102 changed files with 2938 additions and 1633 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
npm-debug.log
+22
View File
@@ -0,0 +1,22 @@
## Supabase - DB
PUBLIC_SUPABASE_ANON_KEY=
PUBLIC_SUPABASE_URL=
## Cloudflare - Video uploads
# Not required - https://cdn.example.com
CLOUDFLARE_BUCKET_DOMAIN=
CLOUDFLARE_ACCESS_KEY=
CLOUDFLARE_SECRET_ACCESS_KEY=
CLOUDFLARE_ACCOUNT_ID=
# Email
SMTP_HOST=
SMTP_USER=
SMTP_PASSWORD=
SMTP_PORT=
# Sentry DNS
SENTRY_DNS=
OPENAPI_URL=
+2 -31
View File
@@ -1,33 +1,4 @@
# prod
dist/
# dev
.yarn/
!.yarn/releases
.vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
.idea/workspace.xml
.idea/usage.statistics.xml
.idea/shelf
# deps
node_modules/
.wrangler
# env
node_modules
.env
.env.production
.dev.vars
# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# misc
.DS_Store
dist
+85
View File
@@ -0,0 +1,85 @@
FROM node:slim AS app
# Add build arguments
ARG PUBLIC_SUPABASE_ANON_KEY
ARG PUBLIC_SUPABASE_URL
ARG CLOUDFLARE_BUCKET_DOMAIN
ARG CLOUDFLARE_ACCESS_KEY
ARG CLOUDFLARE_SECRET_ACCESS_KEY
ARG CLOUDFLARE_ACCOUNT_ID
ARG CLOUDFLARE_BUCKET_ID
ARG SMTP_HOST
ARG SMTP_USER
ARG SMTP_PASSWORD
ARG SMTP_PORT
ARG SMTP_SENDER
ARG ZOHO_TOKEN
ARG CLOUDFLARE_RENDERING_API_KEY
ARG OPENAPI_URL
RUN mkdir /app
WORKDIR /app
# Install pnpm and healthcheck tools
RUN npm install -g pnpm && \
apt-get update && \
apt-get install -y curl wget && \
rm -rf /var/lib/apt/lists/*
# Install chrome stable from sources, then remove it. Why? The
# npm install of `puppeteer` brings its own bundle chromium build,
# and puppeteer releases are only guaranteed to work with that version.
#
# But the bundled chromium implicitly needs a bunch of shared libs on
# the host. It's a little tedious to find and maintain that list; but
# the official apt distro of `google-chrome-stable` should bring the right
# set along. So do that, but immediately uninstall (to free up layer space).
#
# This is a little brittle, since the puppeteer chrome could in theory diverge
# from the official apt chrome's shared lib deps. But it works..
# RUN apt-get update \
# && apt-get install curl gnupg -y \
# && curl --location --silent https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
# && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
# && apt-get update \
# && apt-get install google-chrome-stable -y --no-install-recommends \
# && apt-get remove google-chrome-stable -y \
# && rm -rf /var/lib/apt/lists/*
# Add emoji fonts.
# Source: https://gist.github.com/win0err/9d8c7f0feabdfe8a4c9787b02c79ac51
# RUN mkdir ~/.fonts/ && \
# wget https://github.com/samuelngs/apple-emoji-linux/releases/download/ios-15.4/AppleColorEmoji.ttf -O ~/.fonts/AppleColorEmoji.ttf
COPY ./package.json /app
RUN pnpm install
COPY . /app
# Set environment variables from build args
ENV NODE_ENV=production \
PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY \
PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL \
CLOUDFLARE_BUCKET_DOMAIN=$CLOUDFLARE_BUCKET_DOMAIN \
CLOUDFLARE_ACCESS_KEY=$CLOUDFLARE_ACCESS_KEY \
CLOUDFLARE_SECRET_ACCESS_KEY=$CLOUDFLARE_SECRET_ACCESS_KEY \
CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_ACCOUNT_ID \
CLOUDFLARE_BUCKET_ID=$CLOUDFLARE_BUCKET_ID \
CLOUDFLARE_RENDERING_API_KEY=$CLOUDFLARE_RENDERING_API_KEY \
SMTP_HOST=$SMTP_HOST \
SMTP_USER=$SMTP_USER \
SMTP_PASSWORD=$SMTP_PASSWORD \
SMTP_PORT=$SMTP_PORT \
SMTP_SENDER=$SMTP_SENDER \
ZOHO_TOKEN=$ZOHO_TOKEN \
OPENAPI_URL=$OPENAPI_URL \
PORT=3002
RUN pnpm build
EXPOSE 3002
# Add healthcheck
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3002 || exit 1
CMD ["pnpm", "start"]
+9 -7
View File
@@ -1,8 +1,10 @@
```
npm install
npm run dev
```
# ClassroomIO Backend
```
npm run deploy
```
## Supported Features
1. Certificate download for students.
2. Uploading videos to cloudflare (for storage) and Muse.ai (for transcription)
## Debugging Fly.io
View logs from fly.io in real time
+431
View File
@@ -0,0 +1,431 @@
<!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=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.tailwindcss.com"></script>
<title>Certificate</title>
<style>
.student-name {
font-family: 'Qwitcher Grypen', cursive;
}
body {
position: relative;
}
</style>
</head>
<body>
<img
src="http://localhost:3002/blue-lined-background.png"
alt=""
class="absolute top-0 left-0 w-full h-[100vh] z-10"
/>
<div class="absolute z-20 left-[5%] top-16 w-[90%]">
<header class="w-full flex justify-center items-center pt-10 gap-3 text-3xl font-bold">
<img src="${orgLogoUrl}" alt="" />
<h1>${orgName}</h1>
</header>
<div class="pl-10 text-lg font-bold mt-[10%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-blue-800">${studentName}</h1>
</div>
<div class="pl-10 text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-4xl leading-[2.5rem]">${courseName}</h2>
</div>
<div class="flex border justify-between w-[70%] mx-auto text-center items-end my-14">
<div>
<p class="font-bold">${dateIssued}</p>
<h1 class="border-t border-black h-2/4 font-bold">Date Issued</h1>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm pl-10 w-[90%] pb-10">${courseDescription}</footer>
</div>
<!-- plain blueProfessionalBadgePattern -->
<!-- <img
src="http://localhost:3002/blue-professional-badge-background.png"
alt=""
class="absolute top-0 left-0 w-full z-10"
/>
<div class="absolute w-full pl-20 top-10 z-20">
<header class="w-full flex justify-start items-center pt-10 gap-3 text-3xl font-bold">
<svg
width="34"
height="27"
viewBox="0 0 24 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.2076 9.08656C21.5733 9.08656 23.4911 7.16873 23.4911 4.80302C23.4911 2.43731 21.5733 0.519531 19.2076 0.519531C16.8419 0.519531 14.9241 2.43731 14.9241 4.80302C14.9241 7.16873 16.8419 9.08656 19.2076 9.08656Z"
fill="#0542CC"
/>
<path
d="M23.5385 10.7793V16.9927H14.1243V14.7333C14.1243 12.568 15.8659 10.7793 18.0783 10.7793H23.5385Z"
fill="#0542CC"
/>
<path
d="M12.5701 0.519531V16.9945H9.03974C6.78031 16.9945 4.70918 16.0531 3.20289 14.5939C1.69661 13.0876 0.802246 11.0165 0.802246 8.75703C0.802246 4.1911 4.47382 0.519531 9.03974 0.519531H12.5701Z"
fill="#0542CC"
/>
</svg>
<h1 class="text-[#282828]">Classroom<span class="text-[#0233BD]">IO</span></h1>
</header>
<div class="text-lg font-bold mt-[9%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-blue-800">Usman Elebute Ayomide</h1>
</div>
<div class="text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-5xl mt-3 leading-[3.5rem]">
Building fullstack Applications with superbase & Svelte
</h2>
</div>
<div class="flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm w-[90%] pb-10">
Building full-stack apps with Superbase & Svelte merges robust backend services from
Superbase with Svelte's sleek frontend framework. Leveraging PostgreSQL and Svelte's
reactivity, developers create efficient, feature-rich web apps. This stack streamlines
development, offering scalability and performance for modern applications with minimal
overhead.
</footer>
</div> -->
<!-- plain purpleProfessionalBadgePattern -->
<!-- <img
src="http://localhost:3002/purple-professional-badge-background.png"
alt=""
class="absolute top-0 left-0 w-full z-10"
/>
<div class="absolute w-full pl-20 top-10 z-20">
<header class="w-full flex justify-start items-center pt-10 gap-3 text-3xl font-bold">
<svg
width="34"
height="27"
viewBox="0 0 24 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.2076 9.08656C21.5733 9.08656 23.4911 7.16873 23.4911 4.80302C23.4911 2.43731 21.5733 0.519531 19.2076 0.519531C16.8419 0.519531 14.9241 2.43731 14.9241 4.80302C14.9241 7.16873 16.8419 9.08656 19.2076 9.08656Z"
fill="#0542CC"
/>
<path
d="M23.5385 10.7793V16.9927H14.1243V14.7333C14.1243 12.568 15.8659 10.7793 18.0783 10.7793H23.5385Z"
fill="#0542CC"
/>
<path
d="M12.5701 0.519531V16.9945H9.03974C6.78031 16.9945 4.70918 16.0531 3.20289 14.5939C1.69661 13.0876 0.802246 11.0165 0.802246 8.75703C0.802246 4.1911 4.47382 0.519531 9.03974 0.519531H12.5701Z"
fill="#0542CC"
/>
</svg>
<h1 class="text-[#282828]">Classroom<span class="text-[#0233BD]">IO</span></h1>
</header>
<div class="text-lg font-bold mt-[9%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-fuchsia-800">Usman Elebute Ayomide</h1>
</div>
<div class="text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-5xl mt-3 leading-[3.5rem]">
Building fullstack Applications with superbase & Svelte
</h2>
</div>
<div class="flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm w-[90%] pb-10">
Building full-stack apps with Superbase & Svelte merges robust backend services from
Superbase with Svelte's sleek frontend framework. Leveraging PostgreSQL and Svelte's
reactivity, developers create efficient, feature-rich web apps. This stack streamlines
development, offering scalability and performance for modern applications with minimal
overhead.
</footer>
</div> -->
<!------------------------------------------------------- new secton ---------------------------------------------------- -->
<!-- PurpleProfessionalBadge certificate -->
<!-- <img
src="http://localhost:3002/purple-lined-background.png"
alt=""
class="absolute -top-1 left-0 w-full z-10"
/>
<div class="absolute z-20 left-[10%] top-16 w-[90%]">
<header class="w-full flex justify-center items-center pt-10 gap-3 text-3xl font-bold">
<svg
width="34"
height="27"
viewBox="0 0 24 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.2076 9.08656C21.5733 9.08656 23.4911 7.16873 23.4911 4.80302C23.4911 2.43731 21.5733 0.519531 19.2076 0.519531C16.8419 0.519531 14.9241 2.43731 14.9241 4.80302C14.9241 7.16873 16.8419 9.08656 19.2076 9.08656Z"
fill="#0542CC"
/>
<path
d="M23.5385 10.7793V16.9927H14.1243V14.7333C14.1243 12.568 15.8659 10.7793 18.0783 10.7793H23.5385Z"
fill="#0542CC"
/>
<path
d="M12.5701 0.519531V16.9945H9.03974C6.78031 16.9945 4.70918 16.0531 3.20289 14.5939C1.69661 13.0876 0.802246 11.0165 0.802246 8.75703C0.802246 4.1911 4.47382 0.519531 9.03974 0.519531H12.5701Z"
fill="#0542CC"
/>
</svg>
<h1 class="text-[#282828]">Classroom<span class="text-[#0233BD]">IO</span></h1>
</header>
<div class="pl-10 text-lg font-bold mt-[10%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-[#95449A]">Usman Elebute Ayomide</h1>
</div>
<div class="pl-10 text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-4xl leading-[2.5rem]">
Building fullstack Applications with superbase & Svelte
</h2>
</div>
<div class="pl-10 flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm pl-10 w-[90%] pb-10">
Building full-stack apps with Superbase & Svelte merges robust backend services from
Superbase with Svelte's sleek frontend framework. Leveraging PostgreSQL and Svelte's
reactivity, developers create efficient, feature-rich web apps. This stack streamlines
development, offering scalability and performance for modern applications with minimal
overhead.
</footer>
</div> -->
<!-- BlueProfessionalBadge certificate -->
<!-- <img
src="http://localhost:3002/blue-lined-background.png"
alt=""
class="absolute -top-1 left-0 w-full z-10"
/>
<div class="absolute z-20 left-[10%] top-16 w-[90%]">
<header class="w-full flex justify-center items-center pt-10 gap-3 text-3xl font-bold">
<img src="${orgLogoUrl}" alt="" />
<h1>${orgName}</h1>
</header>
<div class="pl-10 text-lg font-bold mt-[10%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl">${studentName}</h1>
</div>
<div class="pl-10 text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-4xl leading-[2.5rem]">${courseName}</h2>
</div>
<div class="pl-10 flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm pl-10 w-[90%] pb-10">${courseDescription}</footer>
</div> -->
<!-- --------------------- old certificates ------------------------- -->
<!-- <div
class="flex items-center justify-center border border-dashed border-blue-400 h-full w-full"
>
<div class="h-full flex border-l-8 border-blue-800">
<div class="h-full w-full px-5 py-10">
<p class="text-2xl font-normal my-2 mb-10 uppercase text-gray-500">
Certificate<br />
of completion
</p>
<div class="border-b border-gray-500 mb-4">
<p class="text-xl text-black font-normal">This is to certify that</p>
<p class="student-name text-center text-9xl">Rotimi Best</p>
</div>
<div class="mb-2">
<p class="text-xl text-black font-normal">
has successfully completed introductory courses in
</p>
<div>
<p
class="bg-transparent font-semibold text-blue-800 text-center uppercase my-10 text-3xl"
>
Figma for Beginners
</p>
</div>
</div>
<p class="text-xl font-light mb-4 text-justify">
Netlify Functions can stream data to clients as it becomes available, rather than
returning a buffered payload at the end of the computation. This lets developers and
frameworks create faster experiences by using streaming and partial hydration to get
content and interactions in front of people as quickly as possible. For an extended
example that demonstrates a more likely response streaming use case, consider this
function
</p>
<div class="flex justify-between w-[70%] mx-auto text-center my-14">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<div class="flex items-center gap-1 mt-14">
<img
src="https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/user/Coding%20Dojo1684164720355.webp"
alt="logo"
class="w-24 h-24 rounded-md"
/>
<p class="font-semibold capitalize text-5xl">Coding Dojo</p>
</div>
</div>
</div>
</div> -->
<!-- <div
class="flex flex-col items-center justify-center border-4 border-pink-700 py-3 w-full h-full"
>
<div class="flex items-center gap-1 my-2 mb-10">
<img
src="https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/user/Coding%20Dojo1684164720355.webp"
alt="logo"
class="w-24 h-24 rounded-md"
/>
<p class="font-semibold capitalize text-5xl">Coding Dojo</p>
</div>
<div class="w-full px-5 py-3">
<p
class="text-xl text-center font-normal my-2 uppercase tracking-widest text-gray-500 w-full"
>
Certificate of completion
</p>
<div class="border-b border-gray-500 mb-4">
<p class="text-xl text-black font-normal">This is to certify that</p>
<p class="student-name text-center text-8xl">Rotimi Best</p>
</div>
<div class="mb-2">
<p class="text-xl text-black font-normal">has successfully completed a courses in</p>
<div class="my-10">
<p class="bg-transparent font-semibold text-pink-700 text-center uppercase text-3xl">
Figma for Beginners
</p>
</div>
</div>
<p class="text-xl text-center text-gray-500 font-light">
Netlify Functions can stream data to clients as it becomes available, rather than
returning a buffered payload at the end of the computation. This lets developers and
frameworks create faster experiences by using streaming and partial hydration to get
content and interactions in front of people as quickly as possible. For an extended
example that demonstrates a more likely response streaming use case, consider this
function
</p>
<div class="flex justify-between w-[70%] mx-auto text-center my-14">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
</div> -->
</body>
</html>
+182
View File
@@ -0,0 +1,182 @@
<!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 {
border-color: rgb(0, 48, 255);
}
body {
font-family: 'Roboto', sans-serif;
}
.hero {
background-color: rgb(0, 48, 255);
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 3rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
h4 {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
.footer {
font-size: 3rem;
scale: 3;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full">
<header class="flex justify-center items-center flex-col border-[50px] mx-10 h-[100vh] m-0">
<h1 class="font-bold text-8xl text-black m-0 mx-96">React js for Beginners</h1>
<h4 class="text-md text-2xl font-light text-black mt-5 ">By TheSpace</h4>
</header>
<div class="root mx-auto mt-5 prose">
<div class="hero 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">
32
</h3>
<h1 class="font-bold text-5xl text-white m-0">The Yams</h1>
<h4 class="text-md font-normal text-white m-0">By The Space</h4>
</div>
<main>
<section>
<h1>Lesson Note: Introduction and Counselling</h1>
<h2>Course: The Ultimate Guide to relocating to the UK via Study Path</h2>
<h3>Lesson Overview:</h3>
<p>This lesson serves as the introduction to the course &quot;The Ultimate Guide to relocating to the UK via
Study Path.&quot; In this session, we will provide an overview of the course and discuss the importance of
effective counseling in the relocation process.</p>
<h3>Learning Objectives:</h3>
<p>By the end of this lesson, students should be able to:</p>
<ul>
<li>Understand the goals and structure of the course.</li>
<li>Recognize the significance of counseling in the relocation process.</li>
<li>Acquire knowledge about the necessary steps involved in relocating to the UK through the study path.</li>
</ul>
<h3>Lesson Content:</h3>
<ol>
<li>
<p><strong>Introduction to the Course</strong></p>
<ul>
<li>Briefly introduce the course title and explain its relevance to students&#39; interests.</li>
<li>Highlight the main topics and modules covered throughout the course.</li>
</ul>
</li>
<li>
<p><strong>Importance of Counseling</strong></p>
<ul>
<li>Define the role of counseling in the relocation process.</li>
<li>Discuss how counseling can assist students in making informed decisions regarding their studies and
life in the UK.</li>
<li>Explain the benefits of seeking professional guidance during the relocation journey.</li>
</ul>
</li>
<li>
<p><strong>Steps for Relocating to the UK via Study Path</strong></p>
<ul>
<li>Provide an overview of the necessary steps involved in relocating to the UK for study purposes.</li>
<li>Discuss the importance of thorough planning, research, and documentation in the relocation process.
</li>
<li>Highlight key points such as visa applications, university admissions, accommodation arrangements, and
financial considerations.</li>
</ul>
</li>
<li>
<p><strong>Q&amp;A and Discussion Time</strong></p>
<ul>
<li>Allocate time for students to ask questions, provide comments, or share personal experiences.</li>
<li>Encourage active participation and foster a supportive learning environment.</li>
</ul>
</li>
</ol>
<h3>Conclusion:</h3>
<p>In this lesson, we introduced the course &quot;The Ultimate Guide to relocating to the UK via Study
Path&quot; and emphasized the significance of counseling in the relocation process. We explored the essential
steps involved in relocating to the UK and highlighted the importance of thorough planning and research.
Students should now have a better understanding of the course objectives and overall expectations. In the next
session, we will delve deeper into the specific requirements for studying in the UK and provide detailed
guidance on university admissions.</p>
</section>
<section>
<h1 class="text-xl font-bold my-1">Extra Resources</h1>
<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=https://youtu.be/dyLaKhePzj8 style="color: blue"
class="underline">Open Here</a>
</div>
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
</ol>
</div>
</section>
</main>
</div>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
# fly.toml app configuration file generated for classroomio-server on 2023-07-27T21:01:09+01:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = "classroomio-server"
primary_region = "lhr"
[build]
dockerfile = "Dockerfile"
[http_service]
internal_port = 3002
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
processes = ["app"]
+186
View File
@@ -0,0 +1,186 @@
<!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-image: url("https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/course/course-pdf-background.jpg");
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border-color: rgb(0, 48, 255);
}
body {
font-family: 'Roboto', sans-serif;
}
.hero {
background-color: rgb(0, 48, 255);
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 3rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
h4 {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
.footer {
font-size: 3rem;
scale: 3;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full">
<header class="border-l-[50px] mb-[15%] h-[100vh] m-0 relative">
<h1 class="font-bold text-8xl text-black m-0 w-3/4 absolute left-5 top-40">React js for Beginners</h1>
<h4 class="text-md text-2xl font-light text-black m-0 absolute left-10 bottom-24">By TheSpace</h4>
</header>
<div class="root mx-auto mt-5 prose">
<div class="hero 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">
32
</h3>
<h1 class="font-bold text-5xl text-white m-0">The Yams</h1>
<h4 class="text-md font-normal text-white m-0">By The Space</h4>
</div>
<main>
<section>
<h1>Lesson Note: Introduction and Counselling</h1>
<h2>Course: The Ultimate Guide to relocating to the UK via Study Path</h2>
<h3>Lesson Overview:</h3>
<p>This lesson serves as the introduction to the course &quot;The Ultimate Guide to relocating to the UK via
Study Path.&quot; In this session, we will provide an overview of the course and discuss the importance of
effective counseling in the relocation process.</p>
<h3>Learning Objectives:</h3>
<p>By the end of this lesson, students should be able to:</p>
<ul>
<li>Understand the goals and structure of the course.</li>
<li>Recognize the significance of counseling in the relocation process.</li>
<li>Acquire knowledge about the necessary steps involved in relocating to the UK through the study path.</li>
</ul>
<h3>Lesson Content:</h3>
<ol>
<li>
<p><strong>Introduction to the Course</strong></p>
<ul>
<li>Briefly introduce the course title and explain its relevance to students&#39; interests.</li>
<li>Highlight the main topics and modules covered throughout the course.</li>
</ul>
</li>
<li>
<p><strong>Importance of Counseling</strong></p>
<ul>
<li>Define the role of counseling in the relocation process.</li>
<li>Discuss how counseling can assist students in making informed decisions regarding their studies and
life in the UK.</li>
<li>Explain the benefits of seeking professional guidance during the relocation journey.</li>
</ul>
</li>
<li>
<p><strong>Steps for Relocating to the UK via Study Path</strong></p>
<ul>
<li>Provide an overview of the necessary steps involved in relocating to the UK for study purposes.</li>
<li>Discuss the importance of thorough planning, research, and documentation in the relocation process.
</li>
<li>Highlight key points such as visa applications, university admissions, accommodation arrangements, and
financial considerations.</li>
</ul>
</li>
<li>
<p><strong>Q&amp;A and Discussion Time</strong></p>
<ul>
<li>Allocate time for students to ask questions, provide comments, or share personal experiences.</li>
<li>Encourage active participation and foster a supportive learning environment.</li>
</ul>
</li>
</ol>
<h3>Conclusion:</h3>
<p>In this lesson, we introduced the course &quot;The Ultimate Guide to relocating to the UK via Study
Path&quot; and emphasized the significance of counseling in the relocation process. We explored the essential
steps involved in relocating to the UK and highlighted the importance of thorough planning and research.
Students should now have a better understanding of the course objectives and overall expectations. In the next
session, we will delve deeper into the specific requirements for studying in the UK and provide detailed
guidance on university admissions.</p>
</section>
<section>
<h1 class="text-xl font-bold my-1">Extra Resources</h1>
<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=https://youtu.be/dyLaKhePzj8 style="color: blue"
class="underline">Open Here</a>
</div>
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
</ol>
</div>
</section>
</main>
</div>
</body>
</html>
+72 -7
View File
@@ -1,14 +1,79 @@
{
"name": "api",
"name": "@cio/api",
"version": "0.0.1",
"description": "ClassroomIO Backend for long processes",
"main": "dist/index.js",
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy --minify"
"build": "tsc && tsc-alias",
"dev": "pnpm --filter @cio/api --parallel \"/^dev:.*/\"",
"dev:server": "tsx watch src/index.ts",
"dev:tsc": "tsc -p tsconfig.json --watch",
"deploy": "wrangler deploy",
"start": "node dist/index.js",
"test": "vitest NODE_ENV=test",
"test:coverage": "vitest run --coverage",
"lint": "eslint . --ext .ts",
"format": "prettier --write \"src/**/*.ts\"",
"upload:openapi": "tsx scripts/upload-openapi-spec.ts"
},
"repository": {
"type": "git",
"url": "git+https://github.com/classroomio/classroomio.git"
},
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/classroomio/classroomio/issues"
},
"homepage": "https://github.com/classroomio/classroomio#readme",
"exports": {
"./rpc-types": {
"types": "./dist/rpc-types.d.ts",
"default": "./dist/rpc-types.js"
}
},
"dependencies": {
"hono": "^4.6.16"
"@aws-sdk/client-s3": "^3.388.0",
"@aws-sdk/s3-request-presigner": "^3.675.0",
"@hono/node-server": "^1.8.2",
"@hono/standard-validator": "^0.1.5",
"@hono/zod-validator": "^0.7.2",
"@scalar/hono-api-reference": "^0.9.18",
"@sentry/node": "^7.110.1",
"@sentry/profiling-node": "^7.110.1",
"@supabase/supabase-js": "^2.32.0",
"axios": "^1.4.0",
"dotenv": "^16.3.1",
"hono": "^4.9.7",
"hono-openapi": "^1.0.8",
"hono-rate-limiter": "^0.4.2",
"ioredis": "^5.7.0",
"katex": "^0.16.10",
"marked": "^7.0.3",
"nanoid": "^5.1.5",
"nodemailer": "^6.9.5",
"zeptomail": "^6.0.0",
"zod": "^4.1.8",
"zod-openapi": "^5.4.1"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250109.0",
"wrangler": "^3.101.0"
"@types/katex": "^0.16.7",
"@types/node": "^20.11.19",
"@types/nodemailer": "^6.4.17",
"@typescript-eslint/eslint-plugin": "7.0.1",
"@typescript-eslint/parser": "7.0.1",
"@vitest/coverage-v8": "^1.2.2",
"eslint": "^9.31.0",
"prettier": "^3.2.5",
"ts-node": "^10.9.2",
"tsc-alias": "^1.8.16",
"tsx": "^4.7.1",
"typescript": "^5.5.2",
"vitest": "^1.2.2",
"wrangler": "4.21.x"
},
"_moduleAliases": {
"$src": "src"
}
}
}
+74
View File
@@ -0,0 +1,74 @@
# OpenAPI Spec Generator Script
This script generates the OpenAPI specification from your Hono application and optionally uploads it to a Cloudflare R2 bucket.
## Usage
### Generate and Upload OpenAPI spec
```bash
pnpm run upload:openapi
```
### Direct script usage
```bash
tsx scripts/upload-openapi-spec.ts
```
## Examples
```bash
# Generate spec locally only
pnpm upload:openapi
```
## Environment Variables
For R2 upload functionality, set these environment variables:
```bash
CLOUDFLARE_ACCESS_KEY=your_access_key
CLOUDFLARE_SECRET_ACCESS_KEY=your_secret_key
CLOUDFLARE_ACCOUNT_ID=your_account_id
```
## Output
The script will:
1. **Generate** the OpenAPI specification from your Hono app routes
2. **Save locally** to `dist/openapi/openapi.json`
3. **Upload to R2** (if credentials provided):
- `openapi/openapi-YYYY-MM-DD.json` (dated version)
- `openapi/openapi-latest.json` (latest version)
## GitHub Action Integration
This script is integrated with a GitHub Action that automatically generates and uploads the OpenAPI spec on code changes.
### Automatic Workflow
The GitHub Action runs on:
- **Push to main** - Automatically uploads to R2
- **Pull Requests** - Generates spec for review (no upload)
- **Manual dispatch** - Allows manual triggering with custom parameters
### Setup
1. **Required secrets:**
- `CLOUDFLARE_ACCESS_KEY`
- `CLOUDFLARE_SECRET_ACCESS_KEY`
- `CLOUDFLARE_ACCOUNT_ID`
- `PUBLIC_SUPABASE_URL`
- `PUBLIC_SUPABASE_ANON_KEY`
## Features
- ✅ Automatic OpenAPI spec generation from Hono routes
- ✅ Local file generation for development
- ✅ Cloudflare R2 upload with versioning
- ✅ GitHub Action integration
- ✅ Environment-specific deployments
- ✅ PR previews and artifacts
- ✅ Error handling and logging
- ✅ CLI help with `--help` flag
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env tsx
import 'dotenv/config';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { mkdirSync, writeFileSync } from 'fs';
import { app } from '../src/app';
import { env } from '../src/config/env';
import { generateSpecs } from 'hono-openapi';
import { join } from 'path';
interface UploadOptions {
key: string;
content: string;
contentType?: string;
}
class OpenAPISpecGenerator {
private s3Client: S3Client | null = null;
constructor() {
this.initializeS3Client();
}
private initializeS3Client() {
if (
env.CLOUDFLARE_ACCESS_KEY &&
env.CLOUDFLARE_SECRET_ACCESS_KEY &&
env.CLOUDFLARE_ACCOUNT_ID
) {
this.s3Client = new S3Client({
region: 'auto',
endpoint: `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: env.CLOUDFLARE_ACCESS_KEY,
secretAccessKey: env.CLOUDFLARE_SECRET_ACCESS_KEY
}
});
console.log('✅ S3 client initialized for Cloudflare R2');
} else {
console.warn('⚠️ Cloudflare R2 credentials not found. Will only generate local spec file.');
}
}
async generateSpec(): Promise<string> {
try {
console.log('🔄 Generating OpenAPI specification...');
// Generate the OpenAPI spec from the Hono app
const spec = await generateSpecs(app, {
documentation: {
info: {
title: 'ClassroomIO API',
version: '1.0.0',
description: 'Manage your organization on classroomio via the API',
contact: {
name: 'ClassroomIO',
url: 'https://classroomio.com'
}
},
servers: [
{
url: 'https://api.classroomio.com',
description: 'Production environment'
},
{
url: 'http://localhost:3002',
description: 'Development environment'
}
]
}
});
const specString = JSON.stringify(spec, null, 2);
console.log('✅ OpenAPI specification generated successfully');
return specString;
} catch (error) {
console.error('❌ Error generating OpenAPI spec:', error);
throw error;
}
}
async saveLocalSpec(spec: string): Promise<string> {
try {
const outputDir = join(process.cwd(), 'dist', 'openapi');
mkdirSync(outputDir, { recursive: true });
const filePath = join(outputDir, 'openapi.json');
writeFileSync(filePath, spec, 'utf8');
console.log(`✅ OpenAPI spec saved locally: ${filePath}`);
return filePath;
} catch (error) {
console.error('❌ Error saving local spec:', error);
throw error;
}
}
async uploadToR2(options: UploadOptions): Promise<void> {
if (!this.s3Client) {
throw new Error('S3 client not initialized. Check your Cloudflare R2 credentials.');
}
try {
console.log(`🔄 Uploading to R2 bucket`);
const command = new PutObjectCommand({
Bucket: 'api',
Key: options.key,
Body: options.content,
ContentType: options.contentType || 'application/json',
CacheControl: 'public, max-age=3600' // Cache for 1 hour
});
await this.s3Client.send(command);
console.log(`✅ Successfully uploaded to R2`);
} catch (error) {
console.error('❌ Error uploading to R2:', error);
throw error;
}
}
async generateAndUpload(): Promise<void> {
try {
const spec = await this.generateSpec();
// await this.saveLocalSpec(spec);
if (this.s3Client) {
const uploadKey = `openapi/openapi-${new Date().toISOString().split('T')[0]}.json`;
await this.uploadToR2({
key: uploadKey,
content: spec,
contentType: 'application/json'
});
// Also upload as latest version
await this.uploadToR2({
key: 'openapi/openapi-latest.json',
content: spec,
contentType: 'application/json'
});
}
console.log('🎉 OpenAPI spec generation and upload completed successfully!');
} catch (error) {
console.error('❌ Failed to generate and upload OpenAPI spec:', error);
process.exit(1);
}
}
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`
Usage: tsx scripts/upload-openapi-spec.ts
Examples:
tsx scripts/upload-openapi-spec.ts
Environment Variables Required for R2 Upload:
CLOUDFLARE_ACCESS_KEY
CLOUDFLARE_SECRET_ACCESS_KEY
CLOUDFLARE_ACCOUNT_ID
`);
return;
}
const generator = new OpenAPISpecGenerator();
await generator.generateAndUpload();
}
// Run the script if called directly
if (process.argv[1] && process.argv[1].endsWith('upload-openapi-spec.ts')) {
main()
.then(() => {
console.log('Script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('Script failed:', error);
process.exit(1);
});
}
export { OpenAPISpecGenerator };
+44
View File
@@ -0,0 +1,44 @@
import 'dotenv/config';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { courseRouter } from '$src/routes/course/course';
import { logger } from 'hono/logger';
import { mailRouter } from '$src/routes/mail';
import { prettyJSON } from 'hono/pretty-json';
import { rateLimiterMiddleware } from '$src/middlewares/rate-limiter';
import { secureHeaders } from 'hono/secure-headers';
// Create Hono app with chaining for RPC support
export const app = new Hono()
// Middleware
.use('*', logger())
.use('*', prettyJSON())
.use('*', secureHeaders())
.use(
'*',
cors({
origin: '*',
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
maxAge: 600,
credentials: true
})
)
.use('*', rateLimiterMiddleware)
// Routes
.get('/', (c) =>
c.json({
message: '"Welcome to Classroomio.com API - docs are at https://api.classroomio.com/docs"'
})
)
.route('/course', courseRouter)
.route('/mail', mailRouter)
// Error handling
.onError((err, c) => {
console.error('Error:', err);
return c.json({ error: 'Internal Server Error' }, 500);
});
+23
View File
@@ -0,0 +1,23 @@
import { z } from 'zod';
const envSchema = z.object({
CLOUDFLARE_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
CLOUDFLARE_BUCKET_DOMAIN: z.string().optional(),
CLOUDFLARE_RENDERING_API_KEY: z.string().optional(),
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
OPENAPI_URL: z.string().optional(),
PORT: z.string().optional(),
PUBLIC_SUPABASE_ANON_KEY: z.string(),
PUBLIC_SUPABASE_URL: z.string(),
REDIS_URL: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_SENDER: z.string().optional(),
SMTP_USER: z.string().optional(),
ZOHO_TOKEN: z.string().optional()
});
export const env = envSchema.parse(process.env);
+29
View File
@@ -0,0 +1,29 @@
import { env } from '$src/config/env';
export * from './rate-limiter';
const {
CLOUDFLARE_BUCKET_DOMAIN,
CLOUDFLARE_ACCESS_KEY,
CLOUDFLARE_SECRET_ACCESS_KEY,
CLOUDFLARE_ACCOUNT_ID,
CLOUDFLARE_RENDERING_API_KEY
} = env;
export const CLOUDFLARE = {
CONFIGS: {
BUCKET_DOMAIN: CLOUDFLARE_BUCKET_DOMAIN!,
ACCESS_KEY: CLOUDFLARE_ACCESS_KEY!,
RENDERING_API_KEY: CLOUDFLARE_RENDERING_API_KEY!,
SECRET_ACCESS_KEY: CLOUDFLARE_SECRET_ACCESS_KEY!,
ACCOUNT_ID: CLOUDFLARE_ACCOUNT_ID!
},
R2: {
BUCKET: 'videos',
BUCKET_DOMAIN: CLOUDFLARE_ACCOUNT_ID
? `https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`
: CLOUDFLARE_BUCKET_DOMAIN!,
PRESIGN_EXPIRATION_TIME: 60 * 60, // 1 hour
DOWNLOAD_EXPIRATION_TIME: 60 * 60 // 1 hour
}
} as const;
+32
View File
@@ -0,0 +1,32 @@
// Default time window for rate limiting (1 minute in milliseconds)
export const DEFAULT_WINDOW_MS = 60 * 1000;
export const DEFAULT_MAX_REQUESTS = 100;
export const RATE_LIMIT_KEY_PREFIX = 'rate_limit:';
export const DEFAULT_RATE_LIMITER_OPTIONS = {
windowMs: DEFAULT_WINDOW_MS,
maxRequests: DEFAULT_MAX_REQUESTS,
skipSuccessfulRequests: false,
skipFailedRequests: false,
message: 'Rate limit exceeded. Please try again later.',
standardHeaders: true,
legacyHeaders: false
} as const;
export const HTTP_STATUS = {
TOO_MANY_REQUESTS: 429
} as const;
export const ERROR_MESSAGES = {
RATE_LIMIT_EXCEEDED: 'Too Many Requests',
REDIS_PIPELINE_FAILED: 'Redis pipeline execution failed'
} as const;
export const RATE_LIMIT_HEADERS = {
LIMIT: 'X-RateLimit-Limit',
REMAINING: 'X-RateLimit-Remaining',
RESET: 'X-RateLimit-Reset',
RETRY_AFTER: 'Retry-After'
} as const;
+20
View File
@@ -0,0 +1,20 @@
export const ALLOWED_CONTENT_TYPES = [
'video/mp4',
'video/quicktime',
'video/x-msvideo',
'video/x-matroska'
] as const;
export const ALLOWED_DOCUMENT_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
'application/msword' // .doc
] as const;
export const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
export const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
export const BUCKET_NAME = {
VIDEOS: 'videos',
DOCUMENTS: 'documents'
} as const;
+19 -6
View File
@@ -1,9 +1,22 @@
import { Hono } from 'hono'
import 'dotenv/config';
const app = new Hono()
import { app } from '$src/app';
import { configureOpenAPI } from '$src/utils/openapi';
import { env } from '$src/config/env';
import { serve } from '@hono/node-server';
import { showRoutes } from 'hono/dev';
app.get('/', (c) => {
return c.text('Hello Hono!')
})
// Start server
const port = env.PORT ? parseInt(env.PORT) : 3002;
export default app
function startServer() {
console.log('Starting server...');
serve({ fetch: app.fetch, port });
showRoutes(app, { colorize: true });
}
configureOpenAPI(app);
startServer();
+44
View File
@@ -0,0 +1,44 @@
import { Context, Next } from 'hono';
import { validateUser } from '$src/utils/auth/validate-user';
export const authMiddleware = async (c: Context, next: Next) => {
try {
const authHeader = c.req.header('authorization');
if (!authHeader) {
return c.json(
{
success: false,
message: 'No authorization header provided'
},
401
);
}
// Extract the token from "Bearer <token>"
const token = authHeader.split(' ')[1];
if (!token) {
return c.json(
{
success: false,
message: 'Invalid authorization header format'
},
401
);
}
const user = await validateUser(token);
c.set('user', user); // Attach the user to the context
await next();
} catch (error) {
return c.json(
{
success: false,
message: 'Unauthorized'
},
401
);
}
};
+79
View File
@@ -0,0 +1,79 @@
import {
DEFAULT_RATE_LIMITER_OPTIONS,
ERROR_MESSAGES,
HTTP_STATUS,
RATE_LIMIT_HEADERS
} from '$src/constants/rate-limiter';
import type { MiddlewareHandler } from 'hono';
import { rateLimiter } from '$src/utils/redis/limiter';
import { userKeyGenerator } from '../utils/redis/key-generators';
export interface RateLimiterOptions {
windowMs?: number;
maxRequests?: number;
keyGenerator?: (c: any) => string;
skipSuccessfulRequests?: boolean;
skipFailedRequests?: boolean;
message?: string;
standardHeaders?: boolean;
legacyHeaders?: boolean;
}
const defaultOptions: Required<RateLimiterOptions> = {
...DEFAULT_RATE_LIMITER_OPTIONS,
keyGenerator: userKeyGenerator
};
export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareHandler => {
const opts = { ...defaultOptions, ...options };
return async (c, next) => {
try {
// Generate rate limit key
const key = opts.keyGenerator(c);
// Check rate limit
const result = await rateLimiter.isAllowed(key);
// Set rate limit headers
if (opts.standardHeaders) {
c.header(RATE_LIMIT_HEADERS.LIMIT, opts.maxRequests.toString());
c.header(RATE_LIMIT_HEADERS.REMAINING, result.remaining.toString());
c.header(RATE_LIMIT_HEADERS.RESET, new Date(result.resetTime).toISOString());
}
if (opts.legacyHeaders) {
c.header(RATE_LIMIT_HEADERS.LIMIT, opts.maxRequests.toString());
c.header(RATE_LIMIT_HEADERS.REMAINING, result.remaining.toString());
c.header(RATE_LIMIT_HEADERS.RESET, Math.ceil(result.resetTime / 1000).toString());
}
if (!result.allowed) {
const retryAfter = Math.ceil((result.resetTime - Date.now()) / 1000);
if (opts.standardHeaders) {
c.header(RATE_LIMIT_HEADERS.RETRY_AFTER, retryAfter.toString());
}
return c.json(
{
error: ERROR_MESSAGES.RATE_LIMIT_EXCEEDED,
message: opts.message,
retryAfter
},
HTTP_STATUS.TOO_MANY_REQUESTS
);
}
await next();
} catch (error) {
console.error('Rate limiter error:', error);
// If Redis is down, allow the request but log the error
await next();
}
};
};
// Default rate limiter middleware with standard configuration
export const rateLimiterMiddleware = createRateLimiter();
+50
View File
@@ -0,0 +1,50 @@
import { ZCertificateDownload, ZCourseDownloadContent } from '$src/types/course';
import { Hono } from 'hono';
import { generateCertificate } from '$src/utils/certificate';
import { generateCoursePdf } from '$src/utils/course';
import { katexRouter } from '$src/routes/course/katex';
import { lessonRouter } from '$src/routes/course/lesson';
import { presignRouter } from '$src/routes/course/presign';
import { zValidator } from '@hono/zod-validator';
export const courseRouter = new Hono()
.post('/download/certificate', zValidator('json', ZCertificateDownload), async (c) => {
const validatedData = c.req.valid('json');
const result = await generateCertificate(validatedData);
c.header('Content-Type', 'application/pdf');
c.header(
'Content-Disposition',
`attachment; filename="certificate-${validatedData.courseName}.pdf"`
);
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(result);
controller.close();
}
})
);
})
.post('/download/content', zValidator('json', ZCourseDownloadContent), async (c) => {
const validatedData = c.req.valid('json');
const pdfBuffer = await generateCoursePdf(validatedData);
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
})
.route('/katex', katexRouter)
.route('/lesson', lessonRouter)
.route('/presign', presignRouter);
+39
View File
@@ -0,0 +1,39 @@
import { Context, Hono } from 'hono';
import katex from 'katex';
import { z } from 'zod';
export const katexRouter = new Hono().get('/', async (c: Context) => {
try {
const original = c.req.raw.url;
const latexString = original.split('?')[1].replaceAll('&plus;', '+').replaceAll('&space;', '');
const html = katex.renderToString(latexString, {
output: 'mathml',
throwOnError: false
});
return c.html(html);
} catch (error) {
if (error instanceof z.ZodError) {
return c.json(
{
success: false,
error: 'Validation error',
details: error.message
},
400
);
}
console.error('Error rendering LaTeX:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
},
500
);
}
});
+24
View File
@@ -0,0 +1,24 @@
import { Hono } from 'hono';
import { ZLessonDownloadContent } from '$src/types/course/lesson';
import { generateLessonPdf } from '$src/utils/lesson';
import { zValidator } from '@hono/zod-validator';
export const lessonRouter = new Hono().post(
'/download/pdf',
zValidator('json', ZLessonDownloadContent),
async (c) => {
const validatedData = c.req.valid('json');
const pdfBuffer = await generateLessonPdf(validatedData);
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
}
);
+253
View File
@@ -0,0 +1,253 @@
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import {
ZCourseDocumentPresignUrlUpload,
ZCourseDownloadPresignedUrl,
ZCoursePresignUrlUpload
} from '$src/types/course';
import { describeRoute, validator } from 'hono-openapi';
import { BUCKET_NAME } from '$src/constants/upload';
import { CLOUDFLARE } from '$src/constants';
import type { GetSignedUrlParameters } from '$src/utils/s3';
import { Hono } from 'hono';
import { authMiddleware } from '$src/middlewares/auth';
import { generateFileKey } from '$src/utils/upload';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { s3Client } from '$src/utils/s3';
// Response schemas for OpenAPI documentation
const PresignUploadResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
url: { type: 'string' as const },
fileKey: { type: 'string' as const },
message: { type: 'string' as const }
},
required: ['success', 'url', 'fileKey', 'message']
};
const PresignDownloadResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
urls: {
type: 'object' as const,
additionalProperties: { type: 'string' as const }
},
message: { type: 'string' as const }
},
required: ['success', 'urls', 'message']
};
export const presignRouter = new Hono()
.post(
'/video/upload',
authMiddleware,
describeRoute({
description: 'Generate a pre-signed URL for video upload',
responses: {
200: {
description: 'Pre-signed URL generated successfully',
content: {
'application/json': {
schema: PresignUploadResponse
}
}
},
400: {
description: 'Invalid request body'
},
401: {
description: 'Unauthorized'
}
},
tags: ['Presign']
}),
validator('json', ZCoursePresignUrlUpload),
async (c) => {
const body = c.req.valid('json');
const { fileName, fileType } = body;
const fileKey = generateFileKey(fileName);
const command = new PutObjectCommand({
Bucket: BUCKET_NAME.VIDEOS,
Key: fileKey,
ContentType: fileType
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.PRESIGN_EXPIRATION_TIME
});
return c.json({
success: true,
url: presignedUrl,
fileKey,
message: 'Pre-signed URL generated successfully'
});
}
)
.post(
'/document/upload',
authMiddleware,
describeRoute({
description: 'Generate a pre-signed URL for document upload',
responses: {
200: {
description: 'Document pre-signed URL generated successfully',
content: {
'application/json': {
schema: PresignUploadResponse
}
}
},
400: {
description: 'Invalid request body'
},
401: {
description: 'Unauthorized'
}
},
tags: ['Presign']
}),
validator('json', ZCourseDocumentPresignUrlUpload),
async (c) => {
const body = c.req.valid('json');
const { fileName, fileType } = body;
const fileKey = generateFileKey(fileName);
const command = new PutObjectCommand({
Bucket: BUCKET_NAME.DOCUMENTS,
Key: fileKey,
ContentType: fileType
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.PRESIGN_EXPIRATION_TIME
});
return c.json({
success: true,
url: presignedUrl,
fileKey,
message: 'Document pre-signed URL generated successfully'
});
}
)
.post(
'/video/download',
authMiddleware,
describeRoute({
description: 'Generate pre-signed URLs for video download',
responses: {
200: {
description: 'Video URLs retrieved successfully',
content: {
'application/json': {
schema: PresignDownloadResponse
}
}
},
400: {
description: 'Invalid request body'
},
401: {
description: 'Unauthorized'
}
},
tags: ['Presign']
}),
validator('json', ZCourseDownloadPresignedUrl),
async (c) => {
const body = c.req.valid('json');
const { keys } = body;
const signedUrls: Record<string, string> = {};
const urlPromises = keys.map(async (key) => {
const command = new GetObjectCommand({
Bucket: BUCKET_NAME.VIDEOS,
Key: key
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.DOWNLOAD_EXPIRATION_TIME
});
return { key, presignedUrl };
});
const results = await Promise.all(urlPromises);
results.forEach(({ key, presignedUrl }) => {
signedUrls[key] = presignedUrl;
});
return c.json({
success: true,
urls: signedUrls,
message: 'Video URLs retrieved successfully'
});
}
)
.post(
'/document/download',
authMiddleware,
describeRoute({
description: 'Generate pre-signed URLs for document download',
responses: {
200: {
description: 'Document URLs retrieved successfully',
content: {
'application/json': {
schema: PresignDownloadResponse
}
}
},
400: {
description: 'Invalid request body'
},
401: {
description: 'Unauthorized'
}
},
tags: ['Presign']
}),
validator('json', ZCourseDownloadPresignedUrl),
async (c) => {
const body = c.req.valid('json');
const { keys } = body;
const signedUrls: Record<string, string> = {};
const urlPromises = keys.map(async (key) => {
const command = new GetObjectCommand({
Bucket: BUCKET_NAME.DOCUMENTS,
Key: key
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.DOWNLOAD_EXPIRATION_TIME
});
return { key, presignedUrl };
});
const results = await Promise.all(urlPromises);
results.forEach(({ key, presignedUrl }) => {
signedUrls[key] = presignedUrl;
});
return c.json({
success: true,
urls: signedUrls,
message: 'Document URLs retrieved successfully'
});
}
);
+48
View File
@@ -0,0 +1,48 @@
import { sendWithNodemailer, sendWithZoho } from '$src/services/mail';
import { Hono } from 'hono';
import { ZSendEmailValidation } from '$src/types/mail';
import { env } from '$src/config/env';
import { zValidator } from '@hono/zod-validator';
export const mailRouter = new Hono().post(
'/send',
zValidator('json', ZSendEmailValidation),
async (c) => {
const validatedData = c.req.valid('json');
const results = await Promise.all(
validatedData.map(async (emailData) => {
try {
const res = env.ZOHO_TOKEN
? await sendWithZoho(emailData)
: await sendWithNodemailer(emailData);
console.log('Email status:', res);
return res;
} catch (error) {
console.error('Error sending email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
})
);
const hasErrors = results.some((result) => !result.success);
if (hasErrors) {
return c.json(
{
success: false,
error: 'Some emails failed to send',
details: results
},
500
);
}
return c.json({ success: true, details: results });
}
);
+9
View File
@@ -0,0 +1,9 @@
import { app } from './app';
import { hc } from 'hono/client';
import type { InferRequestType, InferResponseType } from 'hono/client';
export type Client = ReturnType<typeof hc<typeof app>>;
export const hcWithType = (...args: Parameters<typeof hc>): Client => hc<typeof app>(...args);
export type { InferRequestType, InferResponseType };
+97
View File
@@ -0,0 +1,97 @@
import { extractNameAndEmail, withEmailTemplate } from '$src/utils/mail';
import { nodemailerTransporter, zohoClient } from '$src/utils/email';
import { Hono } from 'hono';
import type { TSendEmailValidation } from '$src/types/mail';
import type { Transporter } from 'nodemailer';
import { env } from '$src/config/env';
export const mailRouter = new Hono();
interface EmailResponse {
success: boolean;
error?: string;
details?: unknown;
}
let transporter: Transporter | null;
nodemailerTransporter().then((t: Transporter | null) => {
transporter = t;
});
export async function sendWithNodemailer(
emailData: TSendEmailValidation[0]
): Promise<EmailResponse> {
const { from, to, subject, content, replyTo } = emailData;
if (!transporter) {
return {
success: false,
error: 'Email transporter not initialized'
};
}
try {
const result = await transporter.sendMail({
from: from || env.SMTP_SENDER,
to,
subject,
replyTo,
html: withEmailTemplate(content)
});
return {
success: true,
details: result
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
}
export async function sendWithZoho(emailData: TSendEmailValidation[0]): Promise<EmailResponse> {
const { from, to, subject, content } = emailData;
const fromData = extractNameAndEmail(from || '');
if (!fromData) {
return {
success: false,
error: 'Invalid from data'
};
}
try {
const result = await zohoClient.sendMail({
from: {
address: fromData.email,
name: fromData.name
},
to: [
{
email_address: {
address: to
}
}
],
subject,
htmlbody: content
});
return {
success: true,
details: result
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
}
+50
View File
@@ -0,0 +1,50 @@
import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '$src/constants/upload';
import { z } from 'zod';
export * from './lesson';
export const ZCourseDownloadContent = z.object({
courseTitle: z.string(),
orgName: z.string(),
orgTheme: z.string(),
lessons: z.array(
z.object({
lessonTitle: z.string(),
lessonNumber: z.string(),
lessonNote: z.string(),
slideUrl: z.string().optional(),
video: z.array(z.string()).optional()
})
)
});
export type TCourseDownloadContent = z.infer<typeof ZCourseDownloadContent>;
export const ZCoursePresignUrlUpload = z.object({
fileName: z.string().min(1, 'File name is required'),
fileType: z.enum(ALLOWED_CONTENT_TYPES, {
error: () => ({
message: `Invalid content type. Allowed types: ${ALLOWED_CONTENT_TYPES.join(', ')}`
})
})
});
export type TCoursePresignUrlUpload = z.infer<typeof ZCoursePresignUrlUpload>;
export const ZCourseDocumentPresignUrlUpload = z.object({
fileName: z.string().min(1, 'File name is required'),
fileType: z.enum(ALLOWED_DOCUMENT_TYPES, {
error: () => ({
message: `Invalid content type. Allowed types: ${ALLOWED_DOCUMENT_TYPES.join(', ')}`
})
})
});
export type TCourseDocumentPresignUrlUpload = z.infer<typeof ZCourseDocumentPresignUrlUpload>;
export const ZCourseDownloadPresignedUrl = z.object({
keys: z.array(z.string()).min(1, 'File names array is required')
});
export type TCourseDownloadPresignedUrl = z.infer<typeof ZCourseDownloadPresignedUrl>;
+25
View File
@@ -0,0 +1,25 @@
import { z } from 'zod';
export const ZLessonDownloadContent = z.object({
title: z.string(),
number: z.string(),
orgName: z.string(),
note: z.string(),
slideUrl: z.string().optional(),
video: z.array(z.string()).optional(),
courseTitle: z.string()
});
export type TLessonDownloadContent = z.infer<typeof ZLessonDownloadContent>;
export const ZCertificateDownload = z.object({
theme: z.string().min(1),
studentName: z.string().min(1),
courseName: z.string().min(1),
courseDescription: z.string().min(1),
orgName: z.string().min(1),
orgLogoUrl: z.string().url().optional(),
facilitator: z.string().optional()
});
export type TCertificateDownload = z.infer<typeof ZCertificateDownload>;
+13
View File
@@ -0,0 +1,13 @@
import { z } from 'zod';
export const ZSendEmailValidation = z.array(
z.object({
from: z.string().optional(),
to: z.string().email(),
subject: z.string().min(1),
content: z.string().min(1),
replyTo: z.string().email().optional()
})
);
export type TSendEmailValidation = z.infer<typeof ZSendEmailValidation>;
+21
View File
@@ -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;
}
+124
View File
@@ -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;
}
+28
View File
@@ -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');
}
};
+171
View File
@@ -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);
}
+35
View File
@@ -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;
}
};
+3
View File
@@ -0,0 +1,3 @@
export default function genUniqueId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
+161
View File
@@ -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);
}
+213
View File
@@ -0,0 +1,213 @@
interface FromData {
name: string;
email: string;
}
// format: "ClassroomIO Developers (via ClassroomIO.com)" <[email protected]>
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>
`;
+9
View File
@@ -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' }));
}
}
+105
View File
@@ -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}`;
};
+71
View File
@@ -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);
+4
View File
@@ -0,0 +1,4 @@
import { Redis } from 'ioredis';
import { env } from '$src/config/env';
export const redis = new Redis(env.REDIS_URL);
+56
View File
@@ -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' };
}
}
+36
View File
@@ -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;
}
+24
View File
@@ -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}`;
}
+22 -11
View File
@@ -1,17 +1,28 @@
{
"compilerOptions": {
"target": "ESNext",
"allowSyntheticDefaultImports": true,
"baseUrl": "./",
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"outDir": "./dist",
"paths": {
"$src": ["./src"],
"$src/*": ["./src/*"]
},
"resolveJsonModule": false,
"skipLibCheck": true,
"lib": [
"ESNext"
],
"types": [
"@cloudflare/workers-types/2023-07-01"
],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
"sourceMap": true,
"target": "ESNext",
"typeRoots": ["./node_modules/@types", "./src/types"],
"types": ["node"]
},
}
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"],
"tsc-alias": {
"resolveFullPaths": true,
"verbose": false
}
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/']
}
}
});
-28
View File
@@ -1,28 +0,0 @@
name = "api"
main = "src/index.ts"
compatibility_date = "2025-01-16"
# compatibility_flags = [ "nodejs_compat" ]
# [vars]
# MY_VAR = "my-variable"
# [[kv_namespaces]]
# binding = "MY_KV_NAMESPACE"
# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# [[r2_buckets]]
# binding = "MY_BUCKET"
# bucket_name = "my-bucket"
# [[d1_databases]]
# binding = "DB"
# database_name = "my-database"
# database_id = ""
# [ai]
# binding = "AI"
# [observability]
# enabled = true
# head_sampling_rate = 1