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,2 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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 "The Ultimate Guide to relocating to the UK via
|
||||
Study Path." 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' 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&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 "The Ultimate Guide to relocating to the UK via Study
|
||||
Path" 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>
|
||||
@@ -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"]
|
||||
@@ -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 "The Ultimate Guide to relocating to the UK via
|
||||
Study Path." 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' 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&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 "The Ultimate Guide to relocating to the UK via Study
|
||||
Path" 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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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('+', '+').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
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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)" <[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>
|
||||
`;
|
||||
@@ -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}`;
|
||||
}
|
||||
+22
-11
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/']
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user