feat: end to end api type safety with redis rate limiting (#573)

* feat: support hono rpc

* add wrangler file

* update name of wrangler file

* add wrangler as a dependency

* add compatibility_flags

* trigger ci

* core: add health check to docker file

* expose port 80

* update custom healthcheck

* feat: implement openapi

* install dependencies in root

* debug yml

* update yml

* update port

* update dockerfile with envs

* completely move away from apiClient

* rename backend to api

* refactor: update server descriptions and comment out local spec saving

* update lock file

* use api.classroomio.com

* add support for redis

* fix: add missing line break in WelcomeModal.svelte

* refactor: remove wrangler configuration and update Vite config for API integration
This commit is contained in:
Rotimi Best
2025-09-21 19:35:41 +01:00
committed by GitHub
parent a6880f1b54
commit 6d68351a46
102 changed files with 2938 additions and 1633 deletions
@@ -1,18 +0,0 @@
name: Deploy backend on push
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
concurrency: deploy-group # optional: ensure only one action runs at a time
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: cd apps/backend && flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
+61
View File
@@ -0,0 +1,61 @@
name: Upload OpenAPI Spec to R2
on:
push:
branches: [main]
paths:
- 'apps/api/src/**'
- 'apps/api/scripts/**'
- 'apps/api/package.json'
workflow_dispatch:
env:
NODE_VERSION: '20'
PNPM_VERSION: '8'
jobs:
generate-and-upload-openapi:
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
- name: Get pnpm store directory
shell: bash
run: |
ls -la && echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm i
- name: Upload to Cloudflare R2
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: |
cd apps/api
pnpm run upload:openapi
env:
CLOUDFLARE_ACCESS_KEY: ${{ secrets.CLOUDFLARE_ACCESS_KEY }}
CLOUDFLARE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_SECRET_ACCESS_KEY }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
PUBLIC_SUPABASE_URL: ${{ secrets.PUBLIC_SUPABASE_URL }}
PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.PUBLIC_SUPABASE_ANON_KEY }}
+4 -4
View File
@@ -72,7 +72,7 @@ Here is what you need to be able to run ClassroomIO.com
This repo is a mono repo that consists of 3 projects:
1. `classroomio-com`: The landing page of ClassroomIO hosted [here](https://classroomio.com)
2. `backend`: The backend service that handles PDF, video processing, Emailing and Notifications.
2. `api`: The api service that handles PDF, video processing, Emailing and Notifications.
3. `dashboard`: The web application that runs the learning management system hosted [here](https://app.classroomio.com).
4. `docs`: Official documentation of ClassroomIO hosted [here](https://classroomio.com/docs)
@@ -118,7 +118,7 @@ This repo is a mono repo that consists of 3 projects:
4. Set up your `.env` file
- Go to `apps/classroomio-com`, `apps/dashboard` and `apps/backend`.
- Go to `apps/classroomio-com`, `apps/dashboard` and `apps/api`.
- Duplicate the `.env.example` file and rename it to `.env`
- Populate your .env files with the neccessary variables
@@ -173,14 +173,14 @@ To get the environmental variables for supabase continue to step(5)
8. All projects should start running
- `classroomio-com`: [http://localhost:5174](http://localhost:5174)
- `backend`: [http://localhost:3002](http://localhost:3002)
- `api`: [http://localhost:3002](http://localhost:3002)
- `dashboard`: [http://localhost:5173](http://localhost:5173)
- `docs`: [http://localhost:3000](http://localhost:3000)
9. Running a specific project
- **classroomio-com**: `pnpm dev --filter=classroomio-com`
- **backend**: `pnpm dev --filter=backend`
- **api**: `pnpm dev --filter=api`
- **dashboard**: `pnpm dev --filter=dashboard`
- **docs**: `pnpm dev --filter=docs`
@@ -18,3 +18,5 @@ SMTP_PORT=
# Sentry DNS
SENTRY_DNS=
OPENAPI_URL=
+2 -31
View File
@@ -1,33 +1,4 @@
# prod
dist/
# dev
.yarn/
!.yarn/releases
.vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
.idea/workspace.xml
.idea/usage.statistics.xml
.idea/shelf
# deps
node_modules/
.wrangler
# env
node_modules
.env
.env.production
.dev.vars
# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# misc
.DS_Store
dist
@@ -13,12 +13,18 @@ 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
RUN npm install -g pnpm
# 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,
@@ -58,14 +64,22 @@ ENV NODE_ENV=production \
CLOUDFLARE_SECRET_ACCESS_KEY=$CLOUDFLARE_SECRET_ACCESS_KEY \
CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_ACCOUNT_ID \
CLOUDFLARE_BUCKET_ID=$CLOUDFLARE_BUCKET_ID \
CLOUDFLARE_RENDERING_API_KEY=$CLOUDFLARE_RENDERING_API_KEY \
SMTP_HOST=$SMTP_HOST \
SMTP_USER=$SMTP_USER \
SMTP_PASSWORD=$SMTP_PASSWORD \
SMTP_PORT=$SMTP_PORT \
SMTP_SENDER=$SMTP_SENDER \
ZOHO_TOKEN=$ZOHO_TOKEN \
OPENAPI_URL=$OPENAPI_URL \
PORT=3002
RUN pnpm build
EXPOSE 3002
# Add healthcheck
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3002 || exit 1
CMD ["pnpm", "start"]
+9 -7
View File
@@ -1,8 +1,10 @@
```
npm install
npm run dev
```
# ClassroomIO Backend
```
npm run deploy
```
## Supported Features
1. Certificate download for students.
2. Uploading videos to cloudflare (for storage) and Muse.ai (for transcription)
## Debugging Fly.io
View logs from fly.io in real time
+71 -6
View File
@@ -1,14 +1,79 @@
{
"name": "api",
"name": "@cio/api",
"version": "0.0.1",
"description": "ClassroomIO Backend for long processes",
"main": "dist/index.js",
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy --minify"
"build": "tsc && tsc-alias",
"dev": "pnpm --filter @cio/api --parallel \"/^dev:.*/\"",
"dev:server": "tsx watch src/index.ts",
"dev:tsc": "tsc -p tsconfig.json --watch",
"deploy": "wrangler deploy",
"start": "node dist/index.js",
"test": "vitest NODE_ENV=test",
"test:coverage": "vitest run --coverage",
"lint": "eslint . --ext .ts",
"format": "prettier --write \"src/**/*.ts\"",
"upload:openapi": "tsx scripts/upload-openapi-spec.ts"
},
"repository": {
"type": "git",
"url": "git+https://github.com/classroomio/classroomio.git"
},
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/classroomio/classroomio/issues"
},
"homepage": "https://github.com/classroomio/classroomio#readme",
"exports": {
"./rpc-types": {
"types": "./dist/rpc-types.d.ts",
"default": "./dist/rpc-types.js"
}
},
"dependencies": {
"hono": "^4.6.16"
"@aws-sdk/client-s3": "^3.388.0",
"@aws-sdk/s3-request-presigner": "^3.675.0",
"@hono/node-server": "^1.8.2",
"@hono/standard-validator": "^0.1.5",
"@hono/zod-validator": "^0.7.2",
"@scalar/hono-api-reference": "^0.9.18",
"@sentry/node": "^7.110.1",
"@sentry/profiling-node": "^7.110.1",
"@supabase/supabase-js": "^2.32.0",
"axios": "^1.4.0",
"dotenv": "^16.3.1",
"hono": "^4.9.7",
"hono-openapi": "^1.0.8",
"hono-rate-limiter": "^0.4.2",
"ioredis": "^5.7.0",
"katex": "^0.16.10",
"marked": "^7.0.3",
"nanoid": "^5.1.5",
"nodemailer": "^6.9.5",
"zeptomail": "^6.0.0",
"zod": "^4.1.8",
"zod-openapi": "^5.4.1"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250109.0",
"wrangler": "^3.101.0"
"@types/katex": "^0.16.7",
"@types/node": "^20.11.19",
"@types/nodemailer": "^6.4.17",
"@typescript-eslint/eslint-plugin": "7.0.1",
"@typescript-eslint/parser": "7.0.1",
"@vitest/coverage-v8": "^1.2.2",
"eslint": "^9.31.0",
"prettier": "^3.2.5",
"ts-node": "^10.9.2",
"tsc-alias": "^1.8.16",
"tsx": "^4.7.1",
"typescript": "^5.5.2",
"vitest": "^1.2.2",
"wrangler": "4.21.x"
},
"_moduleAliases": {
"$src": "src"
}
}
+74
View File
@@ -0,0 +1,74 @@
# OpenAPI Spec Generator Script
This script generates the OpenAPI specification from your Hono application and optionally uploads it to a Cloudflare R2 bucket.
## Usage
### Generate and Upload OpenAPI spec
```bash
pnpm run upload:openapi
```
### Direct script usage
```bash
tsx scripts/upload-openapi-spec.ts
```
## Examples
```bash
# Generate spec locally only
pnpm upload:openapi
```
## Environment Variables
For R2 upload functionality, set these environment variables:
```bash
CLOUDFLARE_ACCESS_KEY=your_access_key
CLOUDFLARE_SECRET_ACCESS_KEY=your_secret_key
CLOUDFLARE_ACCOUNT_ID=your_account_id
```
## Output
The script will:
1. **Generate** the OpenAPI specification from your Hono app routes
2. **Save locally** to `dist/openapi/openapi.json`
3. **Upload to R2** (if credentials provided):
- `openapi/openapi-YYYY-MM-DD.json` (dated version)
- `openapi/openapi-latest.json` (latest version)
## GitHub Action Integration
This script is integrated with a GitHub Action that automatically generates and uploads the OpenAPI spec on code changes.
### Automatic Workflow
The GitHub Action runs on:
- **Push to main** - Automatically uploads to R2
- **Pull Requests** - Generates spec for review (no upload)
- **Manual dispatch** - Allows manual triggering with custom parameters
### Setup
1. **Required secrets:**
- `CLOUDFLARE_ACCESS_KEY`
- `CLOUDFLARE_SECRET_ACCESS_KEY`
- `CLOUDFLARE_ACCOUNT_ID`
- `PUBLIC_SUPABASE_URL`
- `PUBLIC_SUPABASE_ANON_KEY`
## Features
- ✅ Automatic OpenAPI spec generation from Hono routes
- ✅ Local file generation for development
- ✅ Cloudflare R2 upload with versioning
- ✅ GitHub Action integration
- ✅ Environment-specific deployments
- ✅ PR previews and artifacts
- ✅ Error handling and logging
- ✅ CLI help with `--help` flag
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env tsx
import 'dotenv/config';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { mkdirSync, writeFileSync } from 'fs';
import { app } from '../src/app';
import { env } from '../src/config/env';
import { generateSpecs } from 'hono-openapi';
import { join } from 'path';
interface UploadOptions {
key: string;
content: string;
contentType?: string;
}
class OpenAPISpecGenerator {
private s3Client: S3Client | null = null;
constructor() {
this.initializeS3Client();
}
private initializeS3Client() {
if (
env.CLOUDFLARE_ACCESS_KEY &&
env.CLOUDFLARE_SECRET_ACCESS_KEY &&
env.CLOUDFLARE_ACCOUNT_ID
) {
this.s3Client = new S3Client({
region: 'auto',
endpoint: `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: env.CLOUDFLARE_ACCESS_KEY,
secretAccessKey: env.CLOUDFLARE_SECRET_ACCESS_KEY
}
});
console.log('✅ S3 client initialized for Cloudflare R2');
} else {
console.warn('⚠️ Cloudflare R2 credentials not found. Will only generate local spec file.');
}
}
async generateSpec(): Promise<string> {
try {
console.log('🔄 Generating OpenAPI specification...');
// Generate the OpenAPI spec from the Hono app
const spec = await generateSpecs(app, {
documentation: {
info: {
title: 'ClassroomIO API',
version: '1.0.0',
description: 'Manage your organization on classroomio via the API',
contact: {
name: 'ClassroomIO',
url: 'https://classroomio.com'
}
},
servers: [
{
url: 'https://api.classroomio.com',
description: 'Production environment'
},
{
url: 'http://localhost:3002',
description: 'Development environment'
}
]
}
});
const specString = JSON.stringify(spec, null, 2);
console.log('✅ OpenAPI specification generated successfully');
return specString;
} catch (error) {
console.error('❌ Error generating OpenAPI spec:', error);
throw error;
}
}
async saveLocalSpec(spec: string): Promise<string> {
try {
const outputDir = join(process.cwd(), 'dist', 'openapi');
mkdirSync(outputDir, { recursive: true });
const filePath = join(outputDir, 'openapi.json');
writeFileSync(filePath, spec, 'utf8');
console.log(`✅ OpenAPI spec saved locally: ${filePath}`);
return filePath;
} catch (error) {
console.error('❌ Error saving local spec:', error);
throw error;
}
}
async uploadToR2(options: UploadOptions): Promise<void> {
if (!this.s3Client) {
throw new Error('S3 client not initialized. Check your Cloudflare R2 credentials.');
}
try {
console.log(`🔄 Uploading to R2 bucket`);
const command = new PutObjectCommand({
Bucket: 'api',
Key: options.key,
Body: options.content,
ContentType: options.contentType || 'application/json',
CacheControl: 'public, max-age=3600' // Cache for 1 hour
});
await this.s3Client.send(command);
console.log(`✅ Successfully uploaded to R2`);
} catch (error) {
console.error('❌ Error uploading to R2:', error);
throw error;
}
}
async generateAndUpload(): Promise<void> {
try {
const spec = await this.generateSpec();
// await this.saveLocalSpec(spec);
if (this.s3Client) {
const uploadKey = `openapi/openapi-${new Date().toISOString().split('T')[0]}.json`;
await this.uploadToR2({
key: uploadKey,
content: spec,
contentType: 'application/json'
});
// Also upload as latest version
await this.uploadToR2({
key: 'openapi/openapi-latest.json',
content: spec,
contentType: 'application/json'
});
}
console.log('🎉 OpenAPI spec generation and upload completed successfully!');
} catch (error) {
console.error('❌ Failed to generate and upload OpenAPI spec:', error);
process.exit(1);
}
}
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`
Usage: tsx scripts/upload-openapi-spec.ts
Examples:
tsx scripts/upload-openapi-spec.ts
Environment Variables Required for R2 Upload:
CLOUDFLARE_ACCESS_KEY
CLOUDFLARE_SECRET_ACCESS_KEY
CLOUDFLARE_ACCOUNT_ID
`);
return;
}
const generator = new OpenAPISpecGenerator();
await generator.generateAndUpload();
}
// Run the script if called directly
if (process.argv[1] && process.argv[1].endsWith('upload-openapi-spec.ts')) {
main()
.then(() => {
console.log('Script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('Script failed:', error);
process.exit(1);
});
}
export { OpenAPISpecGenerator };
+44
View File
@@ -0,0 +1,44 @@
import 'dotenv/config';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { courseRouter } from '$src/routes/course/course';
import { logger } from 'hono/logger';
import { mailRouter } from '$src/routes/mail';
import { prettyJSON } from 'hono/pretty-json';
import { rateLimiterMiddleware } from '$src/middlewares/rate-limiter';
import { secureHeaders } from 'hono/secure-headers';
// Create Hono app with chaining for RPC support
export const app = new Hono()
// Middleware
.use('*', logger())
.use('*', prettyJSON())
.use('*', secureHeaders())
.use(
'*',
cors({
origin: '*',
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
maxAge: 600,
credentials: true
})
)
.use('*', rateLimiterMiddleware)
// Routes
.get('/', (c) =>
c.json({
message: '"Welcome to Classroomio.com API - docs are at https://api.classroomio.com/docs"'
})
)
.route('/course', courseRouter)
.route('/mail', mailRouter)
// Error handling
.onError((err, c) => {
console.error('Error:', err);
return c.json({ error: 'Internal Server Error' }, 500);
});
@@ -1,20 +1,23 @@
import { z } from 'zod';
const envSchema = z.object({
PORT: z.string().optional(),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
CLOUDFLARE_BUCKET_DOMAIN: z.string().optional(),
CLOUDFLARE_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
PUBLIC_SUPABASE_URL: z.string(),
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(),
ZOHO_TOKEN: z.string().optional(),
PUBLIC_SUPABASE_URL: z.string(),
REDIS_URL: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_SENDER: 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);
@@ -1,16 +1,20 @@
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_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!
},
@@ -20,6 +24,6 @@ export const CLOUDFLARE = {
? `https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`
: CLOUDFLARE_BUCKET_DOMAIN!,
PRESIGN_EXPIRATION_TIME: 60 * 60, // 1 hour
DOWNLOAD_EXPIRATION_TIME: 60 * 60 * 3 // 3 hours
DOWNLOAD_EXPIRATION_TIME: 60 * 60 // 1 hour
}
} as const;
+32
View File
@@ -0,0 +1,32 @@
// Default time window for rate limiting (1 minute in milliseconds)
export const DEFAULT_WINDOW_MS = 60 * 1000;
export const DEFAULT_MAX_REQUESTS = 100;
export const RATE_LIMIT_KEY_PREFIX = 'rate_limit:';
export const DEFAULT_RATE_LIMITER_OPTIONS = {
windowMs: DEFAULT_WINDOW_MS,
maxRequests: DEFAULT_MAX_REQUESTS,
skipSuccessfulRequests: false,
skipFailedRequests: false,
message: 'Rate limit exceeded. Please try again later.',
standardHeaders: true,
legacyHeaders: false
} as const;
export const HTTP_STATUS = {
TOO_MANY_REQUESTS: 429
} as const;
export const ERROR_MESSAGES = {
RATE_LIMIT_EXCEEDED: 'Too Many Requests',
REDIS_PIPELINE_FAILED: 'Redis pipeline execution failed'
} as const;
export const RATE_LIMIT_HEADERS = {
LIMIT: 'X-RateLimit-Limit',
REMAINING: 'X-RateLimit-Remaining',
RESET: 'X-RateLimit-Reset',
RETRY_AFTER: 'Retry-After'
} as const;
+19 -6
View File
@@ -1,9 +1,22 @@
import { Hono } from 'hono'
import 'dotenv/config';
const app = new Hono()
import { app } from '$src/app';
import { configureOpenAPI } from '$src/utils/openapi';
import { env } from '$src/config/env';
import { serve } from '@hono/node-server';
import { showRoutes } from 'hono/dev';
app.get('/', (c) => {
return c.text('Hello Hono!')
})
// Start server
const port = env.PORT ? parseInt(env.PORT) : 3002;
export default app
function startServer() {
console.log('Starting server...');
serve({ fetch: app.fetch, port });
showRoutes(app, { colorize: true });
}
configureOpenAPI(app);
startServer();
+79
View File
@@ -0,0 +1,79 @@
import {
DEFAULT_RATE_LIMITER_OPTIONS,
ERROR_MESSAGES,
HTTP_STATUS,
RATE_LIMIT_HEADERS
} from '$src/constants/rate-limiter';
import type { MiddlewareHandler } from 'hono';
import { rateLimiter } from '$src/utils/redis/limiter';
import { userKeyGenerator } from '../utils/redis/key-generators';
export interface RateLimiterOptions {
windowMs?: number;
maxRequests?: number;
keyGenerator?: (c: any) => string;
skipSuccessfulRequests?: boolean;
skipFailedRequests?: boolean;
message?: string;
standardHeaders?: boolean;
legacyHeaders?: boolean;
}
const defaultOptions: Required<RateLimiterOptions> = {
...DEFAULT_RATE_LIMITER_OPTIONS,
keyGenerator: userKeyGenerator
};
export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareHandler => {
const opts = { ...defaultOptions, ...options };
return async (c, next) => {
try {
// Generate rate limit key
const key = opts.keyGenerator(c);
// Check rate limit
const result = await rateLimiter.isAllowed(key);
// Set rate limit headers
if (opts.standardHeaders) {
c.header(RATE_LIMIT_HEADERS.LIMIT, opts.maxRequests.toString());
c.header(RATE_LIMIT_HEADERS.REMAINING, result.remaining.toString());
c.header(RATE_LIMIT_HEADERS.RESET, new Date(result.resetTime).toISOString());
}
if (opts.legacyHeaders) {
c.header(RATE_LIMIT_HEADERS.LIMIT, opts.maxRequests.toString());
c.header(RATE_LIMIT_HEADERS.REMAINING, result.remaining.toString());
c.header(RATE_LIMIT_HEADERS.RESET, Math.ceil(result.resetTime / 1000).toString());
}
if (!result.allowed) {
const retryAfter = Math.ceil((result.resetTime - Date.now()) / 1000);
if (opts.standardHeaders) {
c.header(RATE_LIMIT_HEADERS.RETRY_AFTER, retryAfter.toString());
}
return c.json(
{
error: ERROR_MESSAGES.RATE_LIMIT_EXCEEDED,
message: opts.message,
retryAfter
},
HTTP_STATUS.TOO_MANY_REQUESTS
);
}
await next();
} catch (error) {
console.error('Rate limiter error:', error);
// If Redis is down, allow the request but log the error
await next();
}
};
};
// Default rate limiter middleware with standard configuration
export const rateLimiterMiddleware = createRateLimiter();
+50
View File
@@ -0,0 +1,50 @@
import { ZCertificateDownload, ZCourseDownloadContent } from '$src/types/course';
import { Hono } from 'hono';
import { generateCertificate } from '$src/utils/certificate';
import { generateCoursePdf } from '$src/utils/course';
import { katexRouter } from '$src/routes/course/katex';
import { lessonRouter } from '$src/routes/course/lesson';
import { presignRouter } from '$src/routes/course/presign';
import { zValidator } from '@hono/zod-validator';
export const courseRouter = new Hono()
.post('/download/certificate', zValidator('json', ZCertificateDownload), async (c) => {
const validatedData = c.req.valid('json');
const result = await generateCertificate(validatedData);
c.header('Content-Type', 'application/pdf');
c.header(
'Content-Disposition',
`attachment; filename="certificate-${validatedData.courseName}.pdf"`
);
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(result);
controller.close();
}
})
);
})
.post('/download/content', zValidator('json', ZCourseDownloadContent), async (c) => {
const validatedData = c.req.valid('json');
const pdfBuffer = await generateCoursePdf(validatedData);
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
})
.route('/katex', katexRouter)
.route('/lesson', lessonRouter)
.route('/presign', presignRouter);
@@ -3,9 +3,7 @@ import { Context, Hono } from 'hono';
import katex from 'katex';
import { z } from 'zod';
export const katexRouter = new Hono();
katexRouter.get('/', async (c: Context) => {
export const katexRouter = new Hono().get('/', async (c: Context) => {
try {
const original = c.req.raw.url;
@@ -23,7 +21,7 @@ katexRouter.get('/', async (c: Context) => {
{
success: false,
error: 'Validation error',
details: error.errors
details: error.message
},
400
);
+24
View File
@@ -0,0 +1,24 @@
import { Hono } from 'hono';
import { ZLessonDownloadContent } from '$src/types/course/lesson';
import { generateLessonPdf } from '$src/utils/lesson';
import { zValidator } from '@hono/zod-validator';
export const lessonRouter = new Hono().post(
'/download/pdf',
zValidator('json', ZLessonDownloadContent),
async (c) => {
const validatedData = c.req.valid('json');
const pdfBuffer = await generateLessonPdf(validatedData);
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
}
);
+253
View File
@@ -0,0 +1,253 @@
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import {
ZCourseDocumentPresignUrlUpload,
ZCourseDownloadPresignedUrl,
ZCoursePresignUrlUpload
} from '$src/types/course';
import { describeRoute, validator } from 'hono-openapi';
import { BUCKET_NAME } from '$src/constants/upload';
import { CLOUDFLARE } from '$src/constants';
import type { GetSignedUrlParameters } from '$src/utils/s3';
import { Hono } from 'hono';
import { authMiddleware } from '$src/middlewares/auth';
import { generateFileKey } from '$src/utils/upload';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { s3Client } from '$src/utils/s3';
// Response schemas for OpenAPI documentation
const PresignUploadResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
url: { type: 'string' as const },
fileKey: { type: 'string' as const },
message: { type: 'string' as const }
},
required: ['success', 'url', 'fileKey', 'message']
};
const PresignDownloadResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
urls: {
type: 'object' as const,
additionalProperties: { type: 'string' as const }
},
message: { type: 'string' as const }
},
required: ['success', 'urls', 'message']
};
export const presignRouter = new Hono()
.post(
'/video/upload',
authMiddleware,
describeRoute({
description: 'Generate a pre-signed URL for video upload',
responses: {
200: {
description: 'Pre-signed URL generated successfully',
content: {
'application/json': {
schema: PresignUploadResponse
}
}
},
400: {
description: 'Invalid request body'
},
401: {
description: 'Unauthorized'
}
},
tags: ['Presign']
}),
validator('json', ZCoursePresignUrlUpload),
async (c) => {
const body = c.req.valid('json');
const { fileName, fileType } = body;
const fileKey = generateFileKey(fileName);
const command = new PutObjectCommand({
Bucket: BUCKET_NAME.VIDEOS,
Key: fileKey,
ContentType: fileType
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.PRESIGN_EXPIRATION_TIME
});
return c.json({
success: true,
url: presignedUrl,
fileKey,
message: 'Pre-signed URL generated successfully'
});
}
)
.post(
'/document/upload',
authMiddleware,
describeRoute({
description: 'Generate a pre-signed URL for document upload',
responses: {
200: {
description: 'Document pre-signed URL generated successfully',
content: {
'application/json': {
schema: PresignUploadResponse
}
}
},
400: {
description: 'Invalid request body'
},
401: {
description: 'Unauthorized'
}
},
tags: ['Presign']
}),
validator('json', ZCourseDocumentPresignUrlUpload),
async (c) => {
const body = c.req.valid('json');
const { fileName, fileType } = body;
const fileKey = generateFileKey(fileName);
const command = new PutObjectCommand({
Bucket: BUCKET_NAME.DOCUMENTS,
Key: fileKey,
ContentType: fileType
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.PRESIGN_EXPIRATION_TIME
});
return c.json({
success: true,
url: presignedUrl,
fileKey,
message: 'Document pre-signed URL generated successfully'
});
}
)
.post(
'/video/download',
authMiddleware,
describeRoute({
description: 'Generate pre-signed URLs for video download',
responses: {
200: {
description: 'Video URLs retrieved successfully',
content: {
'application/json': {
schema: PresignDownloadResponse
}
}
},
400: {
description: 'Invalid request body'
},
401: {
description: 'Unauthorized'
}
},
tags: ['Presign']
}),
validator('json', ZCourseDownloadPresignedUrl),
async (c) => {
const body = c.req.valid('json');
const { keys } = body;
const signedUrls: Record<string, string> = {};
const urlPromises = keys.map(async (key) => {
const command = new GetObjectCommand({
Bucket: BUCKET_NAME.VIDEOS,
Key: key
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.DOWNLOAD_EXPIRATION_TIME
});
return { key, presignedUrl };
});
const results = await Promise.all(urlPromises);
results.forEach(({ key, presignedUrl }) => {
signedUrls[key] = presignedUrl;
});
return c.json({
success: true,
urls: signedUrls,
message: 'Video URLs retrieved successfully'
});
}
)
.post(
'/document/download',
authMiddleware,
describeRoute({
description: 'Generate pre-signed URLs for document download',
responses: {
200: {
description: 'Document URLs retrieved successfully',
content: {
'application/json': {
schema: PresignDownloadResponse
}
}
},
400: {
description: 'Invalid request body'
},
401: {
description: 'Unauthorized'
}
},
tags: ['Presign']
}),
validator('json', ZCourseDownloadPresignedUrl),
async (c) => {
const body = c.req.valid('json');
const { keys } = body;
const signedUrls: Record<string, string> = {};
const urlPromises = keys.map(async (key) => {
const command = new GetObjectCommand({
Bucket: BUCKET_NAME.DOCUMENTS,
Key: key
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.DOWNLOAD_EXPIRATION_TIME
});
return { key, presignedUrl };
});
const results = await Promise.all(urlPromises);
results.forEach(({ key, presignedUrl }) => {
signedUrls[key] = presignedUrl;
});
return c.json({
success: true,
urls: signedUrls,
message: 'Document URLs retrieved successfully'
});
}
);
@@ -1,16 +1,15 @@
import { Context, Hono } from 'hono';
import { sendWithNodemailer, sendWithZoho } from '$src/services/mail';
import { Hono } from 'hono';
import { ZSendEmailValidation } from '$src/types/mail';
import { env } from '$src/config/env';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
export const mailRouter = new Hono();
mailRouter.post('/send', async (c: Context) => {
try {
const data = await c.req.json();
const validatedData = ZSendEmailValidation.parse(data);
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) => {
@@ -45,26 +44,5 @@ mailRouter.post('/send', async (c: Context) => {
}
return c.json({ success: true, details: results });
} catch (error) {
if (error instanceof z.ZodError) {
return c.json(
{
success: false,
error: 'Validation error',
details: error.errors
},
400
);
}
console.error('Error sending emails:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
},
500
);
}
});
);
+9
View File
@@ -0,0 +1,9 @@
import { app } from './app';
import { hc } from 'hono/client';
import type { InferRequestType, InferResponseType } from 'hono/client';
export type Client = ReturnType<typeof hc<typeof app>>;
export const hcWithType = (...args: Parameters<typeof hc>): Client => hc<typeof app>(...args);
export type { InferRequestType, InferResponseType };
@@ -1,8 +1,4 @@
import {
ALLOWED_CONTENT_TYPES,
ALLOWED_DOCUMENT_TYPES,
MAX_FILE_SIZE
} from '$src/constants/upload';
import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '$src/constants/upload';
import { z } from 'zod';
@@ -28,7 +24,7 @@ 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, {
errorMap: () => ({
error: () => ({
message: `Invalid content type. Allowed types: ${ALLOWED_CONTENT_TYPES.join(', ')}`
})
})
@@ -39,7 +35,7 @@ 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, {
errorMap: () => ({
error: () => ({
message: `Invalid content type. Allowed types: ${ALLOWED_DOCUMENT_TYPES.join(', ')}`
})
})
@@ -1,5 +1,5 @@
import { User } from '@supabase/supabase-js';
import { getSupabase } from '../supabase.js';
import { getSupabase } from '../supabase';
const supabase = getSupabase();
@@ -9,7 +9,7 @@ export const getCloudflarePdfBuffer = async (html: string, styles?: string) => {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${CLOUDFLARE.CONFIGS.ACCESS_KEY}`
Authorization: `Bearer ${CLOUDFLARE.CONFIGS.RENDERING_API_KEY}`
},
body: JSON.stringify({
html: html,
@@ -19,7 +19,8 @@ export const getCloudflarePdfBuffer = async (html: string, styles?: string) => {
);
console.log('PDF response status:', pdfResponse.status);
return pdfResponse;
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');
@@ -1,4 +1,5 @@
import type { TCourseDownloadContent } from '$src/types/course';
import { getCloudflarePdfBuffer } from '$src/utils/cloudflare';
import { marked } from 'marked';
function getHtmlTemplate(body: string): string {
@@ -159,7 +160,7 @@ async function generateSinglePdfFromHtml(
courseTitle: string,
orgTheme: string
): Promise<Buffer> {
throw 'Download disabled';
return await getCloudflarePdfBuffer(htmlContent);
}
export async function generateCoursePdf(params: TCourseDownloadContent): Promise<Buffer> {
+9
View File
@@ -0,0 +1,9 @@
import { Hono } from 'hono';
import { Scalar } from '@scalar/hono-api-reference';
import { env } from '$src/config/env';
export function configureOpenAPI(app: Hono) {
if (env.OPENAPI_URL) {
app.get('/docs', Scalar({ url: env.OPENAPI_URL, theme: 'none' }));
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Key generation utilities for rate limiting
* These functions help create consistent and secure rate limit keys
*/
import type { Context } from 'hono';
import { supabase } from '$src/utils/supabase';
/**
* Extract user ID from JWT token
* In a real implementation, you'd decode and verify the JWT
*/
export const extractUserIdFromToken = async (token: string): Promise<string | null> => {
try {
const { data } = await supabase.auth.getUser(token);
return data.user?.id || null;
} catch {
return null;
}
};
/**
* Extract real client IP address considering various proxy headers
*/
export const extractClientIp = (c: Context): string => {
// Priority order (most trusted first):
// 1. CF-Connecting-IP (Cloudflare)
// 2. X-Real-IP (nginx)
// 3. X-Forwarded-For (standard, but can be spoofed)
// 4. Connection IP (least reliable)
const cfConnectingIp = c.req.header('cf-connecting-ip');
const realIp = c.req.header('x-real-ip');
const forwardedFor = c.req.header('x-forwarded-for');
if (cfConnectingIp) {
return cfConnectingIp;
}
if (realIp) {
return realIp;
}
if (forwardedFor) {
// x-forwarded-for can contain multiple IPs: "client, proxy1, proxy2"
// The first IP is usually the original client
return forwardedFor.split(',')[0].trim();
}
// Fallback - this might be a proxy IP
return 'unknown';
};
/**
* Generate rate limit key for authenticated users
*/
export const userKeyGenerator = (c: Context): string => {
const authHeader = c.req.header('authorization');
if (authHeader) {
const token = authHeader.split(' ')[1];
if (token && token !== 'null' && token !== 'undefined') {
const userId = extractUserIdFromToken(token);
if (userId) {
return `user:${userId}`;
}
// Fallback to token if user ID extraction fails
return `token:${token}`;
}
}
// No valid auth, fall back to IP
return `ip:${extractClientIp(c)}`;
};
/**
* Generate rate limit key for API keys
*/
export const apiKeyGenerator = (c: Context): string => {
const apiKey = c.req.header('X-API-Key');
if (apiKey) {
return `api:${apiKey}`;
}
// Fallback to IP if no API key
return `ip:${extractClientIp(c)}`;
};
/**
* Generate rate limit key based on IP only
*/
export const ipKeyGenerator = (c: Context): string => {
return `ip:${extractClientIp(c)}`;
};
/**
* Generate rate limit key for specific endpoints
*/
export const endpointKeyGenerator =
(endpoint: string) =>
(c: Context): string => {
const baseKey = userKeyGenerator(c);
return `${baseKey}:${endpoint}`;
};
+71
View File
@@ -0,0 +1,71 @@
import {
DEFAULT_MAX_REQUESTS,
DEFAULT_WINDOW_MS,
ERROR_MESSAGES,
RATE_LIMIT_KEY_PREFIX
} from '$src/constants/rate-limiter';
import { Redis } from 'ioredis';
import { redis } from './redis';
export class RedisRateLimiter {
private redis: Redis;
private windowMs: number;
private maxRequests: number;
constructor(
redis: Redis,
windowMs: number = DEFAULT_WINDOW_MS,
maxRequests: number = DEFAULT_MAX_REQUESTS
) {
this.redis = redis;
this.windowMs = windowMs;
this.maxRequests = maxRequests;
}
async isAllowed(
key: string
): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
const now = Date.now();
const windowStart = now - this.windowMs;
const keyWithPrefix = `${RATE_LIMIT_KEY_PREFIX}${key}`;
// Use Redis pipeline for atomic operations
const pipeline = this.redis.pipeline();
// Remove expired entries
pipeline.zremrangebyscore(keyWithPrefix, '-inf', windowStart);
// Count current requests in window
pipeline.zcard(keyWithPrefix);
// Add current request
pipeline.zadd(keyWithPrefix, now, `${now}-${Math.random()}`);
// Set expiration for the key
pipeline.expire(keyWithPrefix, Math.ceil(this.windowMs / 1000));
const results = await pipeline.exec();
if (!results || results.some((result) => result[0] !== null)) {
throw new Error(ERROR_MESSAGES.REDIS_PIPELINE_FAILED);
}
const currentCount = results[1][1] as number;
const remaining = Math.max(0, this.maxRequests - currentCount - 1);
const resetTime = now + this.windowMs;
return {
allowed: currentCount < this.maxRequests,
remaining,
resetTime
};
}
async reset(key: string): Promise<void> {
const keyWithPrefix = `${RATE_LIMIT_KEY_PREFIX}${key}`;
await this.redis.del(keyWithPrefix);
}
}
export const rateLimiter = new RedisRateLimiter(redis);
+4
View File
@@ -0,0 +1,4 @@
import { Redis } from 'ioredis';
import { env } from '$src/config/env';
export const redis = new Redis(env.REDIS_URL);
@@ -4,7 +4,7 @@ import { env } from '$src/config/env';
const { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } = env;
let supabase: SupabaseClient | null = null;
export let supabase: SupabaseClient | null = null;
interface SupabaseConfig {
supabaseConfig: {
+22 -11
View File
@@ -1,17 +1,28 @@
{
"compilerOptions": {
"target": "ESNext",
"allowSyntheticDefaultImports": true,
"baseUrl": "./",
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"lib": [
"ESNext"
],
"types": [
"@cloudflare/workers-types/2023-07-01"
],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
"outDir": "./dist",
"paths": {
"$src": ["./src"],
"$src/*": ["./src/*"]
},
"resolveJsonModule": false,
"skipLibCheck": true,
"sourceMap": true,
"target": "ESNext",
"typeRoots": ["./node_modules/@types", "./src/types"],
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"],
"tsc-alias": {
"resolveFullPaths": true,
"verbose": false
}
}
-28
View File
@@ -1,28 +0,0 @@
name = "api"
main = "src/index.ts"
compatibility_date = "2025-01-16"
# compatibility_flags = [ "nodejs_compat" ]
# [vars]
# MY_VAR = "my-variable"
# [[kv_namespaces]]
# binding = "MY_KV_NAMESPACE"
# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# [[r2_buckets]]
# binding = "MY_BUCKET"
# bucket_name = "my-bucket"
# [[d1_databases]]
# binding = "DB"
# database_name = "my-database"
# database_id = ""
# [ai]
# binding = "AI"
# [observability]
# enabled = true
# head_sampling_rate = 1
-4
View File
@@ -1,4 +0,0 @@
node_modules
.env
.DS_Store
dist
-10
View File
@@ -1,10 +0,0 @@
# ClassroomIO Backend
## 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
-62
View File
@@ -1,62 +0,0 @@
{
"name": "backend",
"version": "0.0.1",
"description": "ClassroomIO Backend for long processes",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc && tsc-alias",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint . --ext .ts",
"format": "prettier --write \"src/**/*.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",
"dependencies": {
"@aws-sdk/client-s3": "^3.388.0",
"@aws-sdk/s3-request-presigner": "^3.675.0",
"@hono/node-server": "^1.8.2",
"@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.0.5",
"hono-rate-limiter": "^0.4.2",
"katex": "^0.16.10",
"marked": "^7.0.3",
"nanoid": "^5.1.5",
"nodemailer": "^6.9.5",
"zeptomail": "^6.0.0",
"zod": "^3.21.4"
},
"devDependencies": {
"@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"
},
"_moduleAliases": {
"$src": "src"
}
}
-66
View File
@@ -1,66 +0,0 @@
import 'dotenv/config';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { courseRouter } from '$src/routes/course/course';
import { env } from '$src/config/env';
import { logger } from 'hono/logger';
import { mailRouter } from '$src/routes/mail';
import { prettyJSON } from 'hono/pretty-json';
import { rateLimiter } from 'hono-rate-limiter';
import { secureHeaders } from 'hono/secure-headers';
import { serve } from '@hono/node-server';
import { showRoutes } from 'hono/dev';
// Create Hono app
const app = new Hono();
// Middleware
app.use('*', logger());
app.use('*', prettyJSON());
app.use('*', secureHeaders());
app.use(
'*',
cors({
origin: '*',
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
maxAge: 600,
credentials: true
})
);
app.use(
rateLimiter({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes).
standardHeaders: 'draft-6', // draft-6: `RateLimit-*` headers; draft-7: combined `RateLimit` header
keyGenerator: (c) => c.req.header('Authorization')?.split(' ')[1] ?? 'unknown' // Method to generate custom identifiers for clients.
})
);
// Routes
app.get('/', (c) => c.json({ message: 'Welcome to ClassroomIO' }));
app.route('/course', courseRouter);
app.route('/mail', mailRouter);
// Error handling
app.onError((err, c) => {
console.error('Error:', err);
return c.json({ error: 'Internal Server Error' }, 500);
});
// Start server
const port = env.PORT ? parseInt(env.PORT) : 3002;
function startServer() {
console.log('Starting server...');
serve({ fetch: app.fetch, port });
showRoutes(app, { colorize: true });
}
startServer();
-91
View File
@@ -1,91 +0,0 @@
import { Context, Hono } from 'hono';
import { ZCertificateDownload, ZCourseDownloadContent } from '$src/types/course';
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 { z } from 'zod';
export const courseRouter = new Hono();
courseRouter.route('/katex', katexRouter);
courseRouter.route('/lesson', lessonRouter);
courseRouter.route('/presign', presignRouter);
courseRouter.post('/download/certificate', async (c: Context) => {
try {
const data = await c.req.json();
const validatedData = ZCertificateDownload.parse(data);
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();
}
})
);
} catch (error) {
if (error instanceof z.ZodError) {
return c.json(
{
success: false,
error: 'Validation error',
details: error.errors
},
400
);
}
console.error('Error generating certificate:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
},
500
);
}
});
courseRouter.post('/download/content', async (c: Context) => {
try {
const data = await c.req.json();
const validatedData = ZCourseDownloadContent.parse(data);
const pdfBuffer = await generateCoursePdf(validatedData);
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
} catch (error) {
console.error('Error generating course PDF:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
},
400
);
}
});
-35
View File
@@ -1,35 +0,0 @@
import { Context, Hono } from 'hono';
import { ZCertificateDownload, ZLessonDownloadContent } from '$src/types/course/lesson';
import { generateLessonPdf } from '$src/utils/lesson';
export const lessonRouter = new Hono();
lessonRouter.post('/download/pdf', async (c: Context) => {
try {
const data = await c.req.json();
const validatedData = ZLessonDownloadContent.parse(data);
const pdfBuffer = await generateLessonPdf(validatedData);
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
} catch (error) {
console.error('Error generating lesson PDF:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
},
400
);
}
});
-199
View File
@@ -1,199 +0,0 @@
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import {
ZCourseDocumentPresignUrlUpload,
ZCourseDownloadPresignedUrl,
ZCoursePresignUrlUpload
} from '$src/types/course';
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';
export const presignRouter = new Hono();
presignRouter.post('/upload', authMiddleware, async (c) => {
const body = await c.req.json();
const result = ZCoursePresignUrlUpload.parse(body);
const { fileName, fileType } = result;
const fileKey = generateFileKey(fileName);
try {
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'
});
} catch (error) {
console.error('Error generating pre-signed URL:', error);
if (error instanceof Error && error.name === 'SignatureDoesNotMatch') {
return c.json(
{
success: false,
type: 'SIGNATURE_ERROR',
message: 'Invalid signature for the request'
},
403
);
}
return c.json(
{
success: false,
type: 'INTERNAL_ERROR',
message: 'Error generating pre-signed URL'
},
500
);
}
});
presignRouter.post('/document/upload', authMiddleware, async (c) => {
const body = await c.req.json();
const result = ZCourseDocumentPresignUrlUpload.parse(body);
const { fileName, fileType } = result;
const fileKey = generateFileKey(fileName);
try {
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'
});
} catch (error) {
console.error('Error generating document pre-signed URL:', error);
if (error instanceof Error && error.name === 'SignatureDoesNotMatch') {
return c.json(
{
success: false,
type: 'SIGNATURE_ERROR',
message: 'Invalid signature for the request'
},
403
);
}
return c.json(
{
success: false,
type: 'INTERNAL_ERROR',
message: 'Error generating document pre-signed URL'
},
500
);
}
});
presignRouter.post('/download', authMiddleware, async (c) => {
const body = await c.req.json();
const result = ZCourseDownloadPresignedUrl.parse(body);
const { keys } = result;
try {
const signedUrls: Record<string, string> = {};
await Promise.all(
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
});
signedUrls[key] = presignedUrl;
})
);
return c.json({
success: true,
urls: signedUrls,
message: 'Video URLs retrieved successfully'
});
} catch (error) {
console.error('Error Retrieving Video:', error);
return c.json(
{
success: false,
type: 'INTERNAL_ERROR',
message: 'Error Retrieving Video'
},
500
);
}
});
presignRouter.post('/download/document', authMiddleware, async (c) => {
const body = await c.req.json();
const result = ZCourseDownloadPresignedUrl.parse(body);
const { keys } = result;
try {
const signedUrls: Record<string, string> = {};
await Promise.all(
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
});
signedUrls[key] = presignedUrl;
})
);
return c.json({
success: true,
urls: signedUrls,
message: 'Document URLs retrieved successfully'
});
} catch (error) {
console.error('Error Retrieving Document:', error);
return c.json(
{
success: false,
type: 'INTERNAL_ERROR',
message: 'Error Retrieving Document'
},
500
);
}
});
-26
View File
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"baseUrl": "./",
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "Node",
"outDir": "./dist",
"paths": {
"$src": ["./src"],
"$src/*": ["./src/*"]
},
"resolveJsonModule": false,
"skipLibCheck": true,
"sourceMap": true,
"target": "ESNext",
"typeRoots": ["./node_modules/@types", "./src/types"],
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"],
"tsc-alias": {
"resolveFullPaths": true,
"verbose": false
}
}
+1 -1
View File
@@ -25,6 +25,6 @@ Below is a demo showing you step by step how to self host the project
### Next steps
We want to support self-hosting using a Coolify template or just using a Dockerfile. We currently have Dockerfiles for both our backend and dashboard projects however contributions from the open source community would help us in properly testing and improving them.
We want to support self-hosting using a Coolify template or just using a Dockerfile. We currently have Dockerfiles for both our api and dashboard projects however contributions from the open source community would help us in properly testing and improving them.
If you prefer deployment with Docker, do checkout our [docker-compose.yaml](https://github.com/classroomio/classroomio/blob/main/docker-compose.yaml) file to get started and we can kick off a conversation on Github.
@@ -47,7 +47,7 @@ export async function POST({ request }) {
}
);
} catch (error) {
console.log('errors from backend', error);
console.log('errors from api', error);
return new Response(
JSON.stringify({
+1 -1
View File
@@ -14,7 +14,7 @@ UNSPLASH_API_KEY=
OPENAI_API_KEY=
# Sever URL - Optional if you want to use video upload and pdf downloads
# This is the deployment URL of apps/backend
# This is the deployment URL of apps/api
PUBLIC_SERVER_URL=
# For getting metadata for signup users.
-17
View File
@@ -1,17 +0,0 @@
{
"folders": [
{
"name": "project-root",
"path": "./"
},
{
"name": "supabase-functions",
"path": "supabase/functions"
}
],
"settings": {
"files.exclude": {
"supabase/functions/": true
}
}
}
+2
View File
@@ -21,6 +21,7 @@
},
"dependencies": {
"@carbon/charts-svelte": "^1.13.6",
"@cio/api": "workspace:*",
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
"@polar-sh/sveltekit": "^0.2.1",
"@supabase/supabase-js": "^2.31.0",
@@ -51,6 +52,7 @@
"js-yaml": "^4.1.0",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"ky": "^1.10.0",
"lodash": "^4.17.21",
"openai-edge": "^1.2.2",
"papaparse": "^5.4.1",
-13
View File
@@ -1,13 +0,0 @@
const tailwindcss = require('tailwindcss');
const autoprefixer = require('autoprefixer');
const config = {
plugins: [
//Some plugins, like tailwindcss/nesting, need to run before Tailwind,
tailwindcss(),
//But others, like autoprefixer, need to run after,
autoprefixer
]
};
module.exports = config;
@@ -4,7 +4,7 @@
import { env } from '$env/dynamic/public';
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
@@ -13,7 +13,7 @@
import { fetchProfileCourseProgress } from '$lib/utils/services/courses';
import type { ProfileCourseProgress } from '$lib/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
import { apiClient } from '$lib/utils/services/api';
import { classroomio } from '$lib/utils/services/api';
let isLoading = false;
let isCourseComplete = false;
@@ -24,22 +24,20 @@
isLoading = true;
try {
const response = await apiClient.post(
'/course/download/certificate',
{
const response = await classroomio.course.download.certificate.$post({
json: {
theme: `${$course.certificate_theme}`,
studentName: `${$profile.fullname}`,
courseName: `${$course.title}`,
courseDescription: `${$course.description}`,
orgLogoUrl: `${$currentOrg.avatar_url}`,
orgLogoUrl: $currentOrg.avatar_url || `${$currentOrgDomain}/logo-512.png`,
orgName: `${$currentOrg.name}`
},
{
responseType: 'blob'
}
);
});
const file = new Blob([response.data], { type: 'application/pdf' });
const blobResponse = await response.blob();
const file = new Blob([blobResponse], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file);
let a = document.createElement('a');
@@ -1,9 +1,5 @@
<script lang="ts">
import {
uploadCourseDocumentStore,
resetDocumentUploadStore,
cancelDocumentUpload
} from '../../store/lessons';
import { lessonDocUpload, resetDocumentUploadStore } from '../../store/lessons';
import { snackbar } from '$lib/components/Snackbar/store';
import { t } from '$lib/utils/functions/translations';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -11,18 +7,19 @@
import DocumentIcon from 'carbon-icons-svelte/lib/Document.svelte';
import PdfIcon from 'carbon-icons-svelte/lib/Pdf.svelte';
import CloseIcon from 'carbon-icons-svelte/lib/Close.svelte';
import { lesson } from '../../store/lessons';
import { apiClient } from '$lib/utils/services/api';
import axios from 'axios';
import { DocumentUploader } from '$lib/utils/services/courses/presign';
import { onDestroy } from 'svelte';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { isFreePlan } from '$lib/utils/store/org';
import { lesson } from '../../store/lessons';
export let lessonId = '';
let fileInput: HTMLInputElement;
let selectedFile: File | null = null;
let dragOver = false;
let abortController: AbortController | null = null;
let errorTimeout: NodeJS.Timeout | null = null;
let isDisabled = false;
const ALLOWED_TYPES = [
'application/pdf',
@@ -30,9 +27,10 @@
'application/msword'
];
const ALLOWED_EXTENSIONS = ['.pdf', '.docx', '.doc'];
const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
const documentUploader = new DocumentUploader();
function getFileType(file: File): 'pdf' | 'docx' | 'doc' {
if (file.type === 'application/pdf') return 'pdf';
if (file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
@@ -79,10 +77,12 @@
}
selectedFile = file;
$uploadCourseDocumentStore.error = null;
$lessonDocUpload.error = null;
}
function handleDrop(event: DragEvent) {
if (isDisabled) return;
event.preventDefault();
dragOver = false;
@@ -93,76 +93,32 @@
}
function handleDragOver(event: DragEvent) {
if (isDisabled) return;
event.preventDefault();
dragOver = true;
}
function handleDragLeave() {
if (isDisabled) return;
dragOver = false;
}
async function uploadDocument() {
if (!selectedFile) return;
// Create new AbortController for this upload
abortController = new AbortController();
$uploadCourseDocumentStore.isUploading = true;
$uploadCourseDocumentStore.uploadProgress = 0;
$uploadCourseDocumentStore.error = null;
$uploadCourseDocumentStore.isCancelled = false;
$lessonDocUpload.isUploading = true;
try {
// Get presigned URL for upload
const uploadPresignResponse = await apiClient.post('/course/presign/document/upload', {
fileName: selectedFile.name,
fileType: selectedFile.type
const { url: presignedUrl, fileKey } = await documentUploader.getPresignedUrl(selectedFile);
await documentUploader.uploadFile({
url: presignedUrl,
file: selectedFile
});
// Check if upload was cancelled
if ($uploadCourseDocumentStore.isCancelled) {
throw new Error('Upload cancelled');
}
const { urls: presignedUrls } = await documentUploader.getDownloadPresignedUrl([fileKey]);
const presignedUrl = uploadPresignResponse.data.url;
// Upload file to Cloudflare R2 using presigned URL
const uploadResponse = await axios.put(presignedUrl, selectedFile, {
headers: {
'Content-Type': selectedFile.type
},
maxContentLength: Infinity,
maxBodyLength: Infinity,
signal: abortController.signal,
onUploadProgress: (progressEvent) => {
// Check if upload was cancelled during progress
if ($uploadCourseDocumentStore.isCancelled) {
abortController?.abort();
return;
}
const progress = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1));
$uploadCourseDocumentStore.uploadProgress = progress;
}
});
// Check if upload was cancelled
if ($uploadCourseDocumentStore.isCancelled) {
throw new Error('Upload cancelled');
}
// Get download URL for the uploaded file
const downloadPresignedResponse = await apiClient.post('/course/presign/download/document', {
keys: [uploadPresignResponse.data.fileKey]
});
if (!downloadPresignedResponse.data.success) {
throw new Error(downloadPresignedResponse.data.message);
}
const fileKey = uploadPresignResponse.data.fileKey;
const presignedUrls = downloadPresignedResponse.data.urls;
// Create document object
const document = {
type: getFileType(selectedFile),
name: selectedFile.name,
@@ -171,18 +127,18 @@
size: selectedFile.size
};
// Add to lesson materials - ensure materials and documents are properly initialized
if (!$lesson.materials) {
$lesson.materials = { note: '', slide_url: '', videos: [], documents: [] };
lesson.update((state) => ({
...state,
materials: {
...(state.materials || {}),
documents: [...(state.materials.documents || []), document]
}
if (!$lesson.materials.documents) {
$lesson.materials.documents = [];
}
$lesson.materials.documents = [...$lesson.materials.documents, document];
// Update store
$uploadCourseDocumentStore.uploadedDocument = document;
$uploadCourseDocumentStore.uploadProgress = 100;
}));
lessonDocUpload.update((state) => ({
...state,
uploadedDocument: document,
uploadProgress: 100
}));
snackbar.success($t('course.navItem.lessons.materials.tabs.document.upload_success'));
@@ -195,32 +151,29 @@
} catch (error) {
console.error('Upload error:', error);
if ($uploadCourseDocumentStore.isCancelled) {
$uploadCourseDocumentStore.error = $t(
if ($lessonDocUpload.isCancelled) {
$lessonDocUpload.error = $t(
'course.navItem.lessons.materials.tabs.document.upload_cancelled'
);
snackbar.info($t('course.navItem.lessons.materials.tabs.document.upload_cancelled'));
} else {
$uploadCourseDocumentStore.error = error instanceof Error ? error.message : 'Upload failed';
$lessonDocUpload.error = error instanceof Error ? error.message : 'Upload failed';
snackbar.error($t('course.navItem.lessons.materials.tabs.document.upload_error'));
}
} finally {
$uploadCourseDocumentStore.isUploading = false;
abortController = null;
$lessonDocUpload.isUploading = false;
}
}
function removeSelectedFile() {
selectedFile = null;
if (fileInput) fileInput.value = '';
$uploadCourseDocumentStore.error = null;
$lessonDocUpload.error = null;
}
function cancelUpload() {
if (abortController) {
abortController.abort();
}
cancelDocumentUpload();
documentUploader.cancelUpload();
selectedFile = null;
if (fileInput) fileInput.value = '';
}
@@ -235,10 +188,10 @@
}
// Auto-clear error after 5 seconds
$: if ($uploadCourseDocumentStore.error) {
$: if ($lessonDocUpload.error) {
if (errorTimeout) clearTimeout(errorTimeout);
errorTimeout = setTimeout(() => {
$uploadCourseDocumentStore.error = null;
$lessonDocUpload.error = null;
}, 5000);
}
@@ -246,8 +199,14 @@
onDestroy(() => {
if (errorTimeout) clearTimeout(errorTimeout);
});
$: isDisabled = $lessonDocUpload.isUploading || $isFreePlan;
</script>
<UpgradeBanner className="mb-3" onClick={() => ($lessonDocUpload.isModalOpen = false)}>
{$t('course.navItem.lessons.materials.tabs.document.upgrade')}
</UpgradeBanner>
<div class="p-6">
<div class="mb-6">
<h3 class="mb-2 text-lg font-semibold">
@@ -261,7 +220,8 @@
<!-- File Upload Area -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center transition-colors {dragOver
class="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center transition-colors {isDisabled &&
'opacity-50 hover:cursor-not-allowed'} {dragOver
? 'border-blue-500 bg-blue-50'
: 'hover:border-gray-400'}"
on:drop={handleDrop}
@@ -289,6 +249,7 @@
</p>
<input
bind:this={fileInput}
disabled={isDisabled}
type="file"
accept=".pdf,.docx,.doc"
on:change={handleFileSelect}
@@ -298,6 +259,7 @@
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.choose_file')}
variant={VARIANTS.OUTLINED}
{isDisabled}
onClick={() => fileInput?.click()}
/>
</div>
@@ -305,23 +267,23 @@
</div>
<!-- Error Message -->
{#if $uploadCourseDocumentStore.error}
{#if $lessonDocUpload.error}
<div class="mt-4 rounded-md border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-600">{$uploadCourseDocumentStore.error}</p>
<p class="text-sm text-red-600">{$lessonDocUpload.error}</p>
</div>
{/if}
<!-- Upload Progress -->
{#if $uploadCourseDocumentStore.isUploading}
{#if $lessonDocUpload.isUploading}
<div class="mt-4">
<div class="mb-1 flex justify-between text-sm text-gray-600">
<span>{$t('course.navItem.lessons.materials.tabs.document.uploading')}</span>
<span>{$uploadCourseDocumentStore.uploadProgress}%</span>
<span>{$lessonDocUpload.uploadProgress}%</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200">
<div
class="h-2 rounded-full bg-blue-600 transition-all duration-300"
style="width: {$uploadCourseDocumentStore.uploadProgress}%"
style="width: {$lessonDocUpload.uploadProgress}%"
></div>
</div>
<div class="mt-3 flex justify-center">
@@ -335,7 +297,7 @@
{/if}
<!-- Upload Button -->
{#if selectedFile && !$uploadCourseDocumentStore.isUploading}
{#if selectedFile && !$lessonDocUpload.isUploading}
<div class="mt-6 flex justify-end space-x-3">
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.cancel')}
@@ -345,13 +307,13 @@
<PrimaryButton
label={$t('course.navItem.lessons.materials.tabs.document.upload_document')}
onClick={uploadDocument}
isLoading={$uploadCourseDocumentStore.isUploading}
isLoading={$lessonDocUpload.isUploading}
/>
</div>
{/if}
<!-- Success Message -->
{#if $uploadCourseDocumentStore.uploadedDocument}
{#if $lessonDocUpload.uploadedDocument}
<div class="mt-4 rounded-md border border-green-200 bg-green-50 p-3">
<p class="text-sm text-green-600">
{$t('course.navItem.lessons.materials.tabs.document.upload_success')}
@@ -11,22 +11,25 @@ export interface DocumentUploadState {
size: number;
} | null;
error: string | null;
isCancelled: boolean;
}
export const uploadCourseDocumentStore = writable<DocumentUploadState>({
export const lessonDocUpload = writable<DocumentUploadState>({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
uploadedDocument: null,
error: null
error: null,
isCancelled: false
});
export function resetDocumentUploadStore() {
uploadCourseDocumentStore.set({
lessonDocUpload.set({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
uploadedDocument: null,
error: null
error: null,
isCancelled: false
});
}
@@ -1,26 +1,21 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import axios from 'axios';
import {
lesson,
deleteLessonVideo,
uploadCourseVideoStore,
lessonVideoUpload,
cancelVideoUpload
} from '$lib/components/Course/components/Lesson/store/lessons';
import { type ComponentProps } from 'svelte';
import { ProgressBar } from 'carbon-components-svelte';
import { isFreePlan } from '$lib/utils/store/org';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { apiClient } from '$lib/utils/services/api';
import { t } from '$lib/utils/functions/translations';
import { VideoUploader } from '$lib/utils/services/courses/presign';
export let lessonId = '';
let value = 0;
let max = 100;
let status: ComponentProps<ProgressBar>['status'] = 'active';
let fileSize;
let isDisabled = false;
@@ -29,66 +24,32 @@
let fileInput;
let submit;
let fileName = '';
let abortController: AbortController | null = null;
const videoUploader = new VideoUploader();
async function onUpload() {
if (!fileInput) return;
// Create new AbortController for this upload
abortController = new AbortController();
$uploadCourseVideoStore.isUploading = true;
$uploadCourseVideoStore.isCancelled = false;
videoUploader.initUpload();
const videoFile = fileInput.files[0];
fileSize = videoFile?.size / (1024 * 1024);
fileName = videoFile?.name;
try {
const uploadPresignResponse = await apiClient.post('/course/presign/upload', {
fileName: videoFile.name,
fileType: videoFile.type
const { url: presignedUrl, fileKey } = await videoUploader.getPresignedUrl(videoFile);
await videoUploader.uploadFile({
url: presignedUrl,
file: videoFile
});
const presignedUrl = uploadPresignResponse.data.url;
const uploadResponse = await axios.put(presignedUrl, videoFile, {
headers: {
'Content-Type': videoFile.type
},
maxContentLength: Infinity,
maxBodyLength: Infinity,
signal: abortController.signal,
onUploadProgress: (progressEvent) => {
// Check if upload was cancelled during progress
if ($uploadCourseVideoStore.isCancelled) {
abortController?.abort();
return;
}
value = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1) / 2);
}
});
const downloadPresignedResponse = await apiClient.post('/course/presign/download', {
keys: [uploadPresignResponse.data.fileKey]
});
if (!downloadPresignedResponse.data.success) {
formRes = {
type: 'INTERNAL_ERROR',
status: 500,
message: downloadPresignedResponse.data.message
};
return;
}
const fileKey = uploadPresignResponse.data.fileKey;
const presignedUrls = downloadPresignedResponse.data.urls;
const { urls: presignedUrls } = await videoUploader.getDownloadPresignedUrl([fileKey]);
formRes = {
url: presignedUrls[fileKey],
fileKey: uploadPresignResponse.data.fileKey,
status: uploadResponse.status
fileKey: fileKey,
status: 200
};
$lesson.materials.videos = [
@@ -100,12 +61,11 @@
}
];
$uploadCourseVideoStore.isUploading = false;
isLoaded = false;
} catch (err: any) {
console.error('Error uploading video', err, '\n\n', err.response);
if ($uploadCourseVideoStore.isCancelled) {
if ($lessonVideoUpload.isCancelled) {
formRes = {
type: 'CANCELLED',
status: 0,
@@ -114,53 +74,54 @@
} else if (err.response) {
formRes = err.response.data;
} else {
let message = 'An error occurred while uploading the video';
if (err instanceof Error) {
message = err.message;
}
formRes = {
type: 'INTERNAL_ERROR',
status: 500,
message: 'An error occurred while uploading the video'
message
};
}
} finally {
abortController = null;
videoUploader.abortController = null;
}
isLoaded = true;
$lessonVideoUpload.isUploading = false;
}
function tryAgain() {
formRes = null;
isLoaded = false;
$uploadCourseVideoStore.isUploading = false;
value = 0;
$lessonVideoUpload.isUploading = false;
$lessonVideoUpload.uploadProgress = 0;
}
function cancelUpload() {
if (abortController) {
abortController.abort();
}
videoUploader.abortController?.abort();
cancelVideoUpload();
formRes = null;
isLoaded = false;
value = 0;
$lessonVideoUpload.uploadProgress = 0;
}
$: helperText = value + '% of ' + Math.round(fileSize) + 'MB';
$: if (value === max) {
helperText = 'Done';
status = 'finished';
}
$: helperText = $lessonVideoUpload.uploadProgress + '% of ' + Math.round(fileSize) + 'MB';
$: isDisabled = $uploadCourseVideoStore.isUploading || !env.PUBLIC_SERVER_URL || $isFreePlan;
$: isDisabled = $lessonVideoUpload.isUploading || !env.PUBLIC_SERVER_URL || $isFreePlan;
</script>
<UpgradeBanner className="mb-3" onClick={() => ($uploadCourseVideoStore.isModalOpen = false)}>
<UpgradeBanner className="mb-3" onClick={() => ($lessonVideoUpload.isModalOpen = false)}>
{$t('course.navItem.lessons.materials.tabs.video.add_video.upgrade')}
</UpgradeBanner>
{#if !isLoaded}
<button
type="button"
on:click={() => (fileInput && !$uploadCourseVideoStore.isUploading ? fileInput.click() : null)}
on:click={() => (fileInput && !$lessonVideoUpload.isUploading ? fileInput.click() : null)}
class="h-full w-full {isDisabled && 'opacity-50 hover:cursor-not-allowed'}"
disabled={isDisabled}
>
@@ -168,12 +129,17 @@
class="border-primary-300 flex h-full w-full flex-col items-center justify-center rounded-xl border border-dashed"
on:submit|preventDefault={onUpload}
>
{#if $uploadCourseVideoStore.isUploading}
{#if $lessonVideoUpload.isUploading}
<div class="flex w-[60%] max-w-[500px] flex-col justify-center gap-5">
<p class="mt-5 text-center">
{$t('course.navItem.lessons.materials.tabs.video.add_video.uploading')}
</p>
<ProgressBar class="w-full" {value} {max} {status} />
<ProgressBar
class="w-full"
value={$lessonVideoUpload.uploadProgress}
max={100}
status={$lessonVideoUpload.uploadProgress === 100 ? 'finished' : 'active'}
/>
<p class="text-sm">{helperText}</p>
<div class="mt-3">
<PrimaryButton
@@ -242,25 +208,19 @@
/>
</div>
{:else}
<div class="flex h-full w-full flex-col items-start justify-between">
<div class="h-auto w-full rounded-md border px-8 py-3">
<div class="flex items-center justify-between">
<span class="flex items-center gap-2">
<div class="overflow-hidden rounded-sm">
<video class="w-[200px]">
<div class=" w-full rounded-md border px-8 py-3">
<div class="flex flex-col items-center gap-8">
<div class="flex flex-col items-center gap-2">
<video class="border-primary-300 w-[200px] rounded-md border">
<source src={formRes.url} type="video/mp4" />
<track kind="captions" />
<p>{$t('generic.loading')}</p>
</video>
</div>
<p>{fileInput?.files?.[0]?.name || fileName}</p>
</span>
<button on:click={deleteLessonVideo}>
<img src="/delete-video.svg" alt="deletevideo" class="dark:invert" />
</button>
</div>
<PrimaryButton
label={$t('course.navItem.lessons.materials.button_done')}
onClick={() => ($uploadCourseVideoStore.isModalOpen = false)}
onClick={() => ($lessonVideoUpload.isModalOpen = false)}
/>
</div>
</div>
@@ -3,10 +3,7 @@
lesson,
deleteLessonDocument
} from '$lib/components/Course/components/Lesson/store/lessons';
import {
uploadCourseDocumentStore,
presignedDocUrls
} from '$lib/components/Course/components/Lesson/store/lessons';
import { lessonDocUpload } from '$lib/components/Course/components/Lesson/store/lessons';
import MODES from '$lib/utils/constants/mode';
import IconButton from '$lib/components/IconButton/index.svelte';
import CloseIcon from 'carbon-icons-svelte/lib/Close.svelte';
@@ -17,7 +14,6 @@
import { onMount } from 'svelte';
import DocumentList from '../Document/DocumentList.svelte';
import { t } from '$lib/utils/functions/translations';
import { apiClient } from '$lib/utils/services/api';
import type { LessonDocument } from '$lib/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
@@ -56,7 +52,7 @@
});
function openDocumentUploadModal() {
$uploadCourseDocumentStore.isModalOpen = true;
$lessonDocUpload.isModalOpen = true;
}
function deleteDocument(index: number) {
@@ -71,36 +67,6 @@
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async function getPresignedDocs(docs: LessonDocument[]) {
if (!docs.length) return {};
if (docs.every((d) => $presignedDocUrls[d.key || ''])) return;
isLoading = true;
try {
const { data } = await apiClient.post('/course/presign/download/document', {
keys: docs.map((d) => d.key)
});
if (!data.success) {
throw new Error(data.message);
}
for (const key in data.urls) {
presignedDocUrls.update((urls) => ({
...urls,
[key]: data.urls[key]
}));
}
} catch (error) {
isLoading = false;
console.error('Error getting presigned docs:', error);
snackbar.error('Error getting presigned docs');
} finally {
isLoading = false;
}
}
async function downloadDocument(doc: LessonDocument) {
downloadingDocuments.add(doc.name);
downloadingDocuments = downloadingDocuments;
@@ -111,7 +77,7 @@
}
try {
const response = await fetch($presignedDocUrls[doc.key]);
const response = await fetch(doc.link);
if (!response.ok) {
throw new Error('Failed to fetch document');
}
@@ -142,16 +108,6 @@
renderTimeout = null;
}, 150); // 150ms debounce
}
async function viewPDF(document: LessonDocument) {
// Wait for PDF.js to be fully loaded
if (!pdfjsLib) {
// Show loading state while waiting for PDF.js
viewingPDF = document;
pdfViewerOpen = true;
isLoading = true;
error = 'Loading PDF viewer...';
// Wait for PDF.js to load
const checkPDFJS = () => {
return new Promise((resolve) => {
@@ -166,6 +122,15 @@
});
};
async function viewPDF(document: LessonDocument) {
// Wait for PDF.js to be fully loaded
if (!pdfjsLib) {
// Show loading state while waiting for PDF.js
viewingPDF = document;
pdfViewerOpen = true;
isLoading = true;
error = 'Loading PDF viewer...';
await checkPDFJS();
}
@@ -180,7 +145,7 @@
return;
}
const response = await fetch($presignedDocUrls[document.key]);
const response = await fetch(document.link);
if (!response.ok) {
throw new Error('Failed to fetch PDF');
@@ -335,12 +300,9 @@
}
$: displayDocuments = $lesson?.materials?.documents || [];
$: getPresignedDocs(displayDocuments);
</script>
<div class="mx-auto w-full max-w-lg">
<DocumentList
<DocumentList
{isLoading}
{mode}
{displayDocuments}
@@ -350,8 +312,7 @@
{deleteDocument}
{downloadDocument}
on:viewPDF={handleViewPDF}
/>
</div>
/>
<!-- PDF Viewer Modal -->
{#if pdfViewerOpen}
@@ -1,7 +1,7 @@
<script lang="ts">
import Box from '$lib/components/Box/index.svelte';
import VideoUploader from '$lib/components/Course/components/Lesson/Materials/Video/Index.svelte';
import DocumentUploader from '$lib/components/Course/components/Lesson/Materials/Document/Index.svelte';
import AddVideoToLesson from '$lib/components/Course/components/Lesson/Materials/Video/AddVideoToLesson.svelte';
import AddDocumentToLesson from '$lib/components/Course/components/Lesson/Materials/Document/AddDocumentToLesson.svelte';
import {
deleteLessonVideo,
handleUpdateLessonMaterials,
@@ -9,8 +9,8 @@
lesson,
lessonByTranslation,
lessons,
uploadCourseVideoStore,
uploadCourseDocumentStore
lessonVideoUpload,
lessonDocUpload
} from '$lib/components/Course/components/Lesson/store/lessons';
import { course } from '$lib/components/Course/store';
import TextField from '$lib/components/Form/TextField.svelte';
@@ -47,6 +47,7 @@
import * as CONSTANTS from './constants';
import { orderedTabs } from './constants';
import Loader from './Loader.svelte';
export let mode = MODES.view;
export let prevMode = '';
export let lessonId = '';
@@ -57,7 +58,6 @@
let localeExists: Record<string, boolean> = {};
let lessonTitle = '';
let prevContent = '';
let initAutoSave = false;
let timeoutId: NodeJS.Timeout;
let tabs = CONSTANTS.tabs;
let currentTab = tabs[0].value;
@@ -182,7 +182,7 @@
}
const openAddVideoModal = () => {
$uploadCourseVideoStore.isModalOpen = true;
$lessonVideoUpload.isModalOpen = true;
};
const { input, handleSubmit, completion, isLoading } = useCompletion({
@@ -244,11 +244,6 @@
if (timeoutId) clearTimeout(timeoutId);
if (!initAutoSave) {
initAutoSave = true;
return;
}
isSaving = true;
timeoutId = setTimeout(async () => {
const { error } = await saveLesson(updatedMaterials);
@@ -262,7 +257,6 @@
}
async function onLessonIdChange(_lid: string) {
initAutoSave = false;
isSaving = false;
tabs = orderedTabs(tabs, $course.metadata?.lessonTabsOrder);
@@ -271,18 +265,18 @@
}
const onClose = () => {
if ($uploadCourseVideoStore.isUploading) return;
if ($lessonVideoUpload.isUploading) return;
$uploadCourseVideoStore.isModalOpen = false;
$lessonVideoUpload.isModalOpen = false;
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
};
const onDocumentClose = () => {
if ($uploadCourseDocumentStore.isUploading) return;
if ($lessonDocUpload.isUploading) return;
$uploadCourseDocumentStore.isModalOpen = false;
$lessonDocUpload.isModalOpen = false;
// Clear any error messages when modal closes
$uploadCourseDocumentStore.error = null;
$lessonDocUpload.error = null;
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
};
@@ -327,20 +321,20 @@
<Modal
{onClose}
bind:open={$uploadCourseVideoStore.isModalOpen}
bind:open={$lessonVideoUpload.isModalOpen}
width="w-4/5 w-[90%] h-[80%] md:h-[566px]"
modalHeading={$t('course.navItem.lessons.materials.tabs.video.add_video.title')}
>
<VideoUploader {lessonId} />
<AddVideoToLesson {lessonId} />
</Modal>
<Modal
onClose={onDocumentClose}
bind:open={$uploadCourseDocumentStore.isModalOpen}
bind:open={$lessonDocUpload.isModalOpen}
width="w-4/5 w-[90%] h-[80%] md:h-[566px]"
modalHeading={$t('course.navItem.lessons.materials.tabs.document.upload_title')}
>
<DocumentUploader {lessonId} />
<AddDocumentToLesson {lessonId} />
</Modal>
<HtmlRender className="m-auto text-center">
@@ -493,7 +487,13 @@
/>
</div>
{:else}
<video bind:this={player} class="plyr-video-trigger" playsinline controls>
<video
bind:this={player}
class="plyr-video-trigger"
style="max-height: 569px;"
playsinline
controls
>
<source src={video.link} type="video/mp4" />
<track kind="captions" />
</video>
@@ -15,22 +15,22 @@ import { lessonValidation } from '$lib/utils/functions/validator';
import { snackbar } from '$lib/components/Snackbar/store';
import { writable } from 'svelte/store';
export const uploadCourseVideoStore = writable({
isUploading: false,
isModalOpen: false,
isCancelled: false
});
export const uploadCourseDocumentStore = writable({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
uploadedDocument: null as any,
export const lessonVideoUpload = writable({
error: null as string | null,
isCancelled: false
isCancelled: false,
isModalOpen: false,
isUploading: false,
uploadProgress: 0
});
export const presignedDocUrls = writable<Record<string, string>>({});
export const lessonDocUpload = writable({
error: null as string | null,
isCancelled: false,
isModalOpen: false,
isUploading: false,
uploadedDocument: null as any,
uploadProgress: 0
});
export const lessons: Writable<Lesson[]> = writable([]);
@@ -217,7 +217,7 @@ export const deleteLessonDocument = (index: any) => {
};
export function resetDocumentUploadStore() {
uploadCourseDocumentStore.set({
lessonDocUpload.set({
isUploading: false,
isModalOpen: false,
uploadProgress: 0,
@@ -228,7 +228,7 @@ export function resetDocumentUploadStore() {
}
export function cancelDocumentUpload() {
uploadCourseDocumentStore.update((store) => ({
lessonDocUpload.update((store) => ({
...store,
isCancelled: true,
isUploading: false,
@@ -237,7 +237,7 @@ export function cancelDocumentUpload() {
}
export function cancelVideoUpload() {
uploadCourseVideoStore.update((store) => ({
lessonVideoUpload.update((store) => ({
...store,
isCancelled: true,
isUploading: false
@@ -18,6 +18,7 @@
to: $profile.email,
name: $profile.fullname
});
goto($currentOrgPath + '/courses?create=true');
};
</script>
@@ -29,7 +30,7 @@
maxWidth="w-[800px]"
modalHeading="Welcome"
>
<p class="text-black dark:text-white text-sm md:text-base lg:text-lg">
<p class="text-sm text-black md:text-base lg:text-lg dark:text-white">
{$t('welcome_modal.we_at')}
<a href="https://app.classroomio.com/" class="text-primary-700 no-underline hover:no-underline"
>ClassroomIO</a
@@ -38,5 +39,5 @@
<span class="text-primary-700">{$t('welcome_modal.thank_you')};</span>
{$t('welcome_modal.deeply_appreciate')}
</p>
<img src="/images/welcome-img.svg" alt="A welcome banner" class="w-full my-6" />
<img src="/images/welcome-img.svg" alt="A welcome banner" class="my-6 w-full" />
</Modal>
@@ -80,7 +80,7 @@ export const addMathPlugin = (tinymce: any) => {
onSubmit: async function (w) {
let content = output.exportAs('html');
// The generated content always points to https://latex.codecogs.com/svg.image, this means we have to always rely on their server to serve formulas. We have a dedicated endpoint for generating formulas on our backend service: GET `/katex`. We will need to manually update the URL in the generate HTML to point to our server:
// The generated content always points to https://latex.codecogs.com/svg.image, this means we have to always rely on their server to serve formulas. We have a dedicated endpoint for generating formulas on our api service: GET `/katex`. We will need to manually update the URL in the generate HTML to point to our server:
// https://latex.codecogs.com/svg.image
// 1. Get only the URL in the img tag generated by codecogs library
@@ -1,19 +0,0 @@
import axios from 'axios';
import { env } from '$env/dynamic/public';
import { getAccessToken } from '$lib/utils/functions/supabase';
export const apiClient = axios.create({
baseURL: `${env.PUBLIC_SERVER_URL}`,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
});
apiClient.interceptors.request.use(async (config) => {
const token = await getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
@@ -0,0 +1,80 @@
# API Client
## Basic Usage
```typescript
import { apiClient, safeRequest, classroomio } from '$lib/utils/services/api';
// GET request
const result = await safeRequest(async () => {
return apiClient.request('/api/users', { method: 'GET' });
});
// POST request
const result = await safeRequest(async () => {
return apiClient.request('/api/users', {
method: 'POST',
body: { name: 'John', email: '[email protected]' }
});
});
// Handle response
if (result.success) {
console.log('Data:', result.data);
} else {
console.error('Error:', result.error.message);
}
```
## Error Handling
```typescript
if (result.success) {
// Success
console.log(result.data);
} else {
// Check error type
if (result.error.error_code === 'auth') {
window.location.href = '/login';
} else if (result.error.error_code === 'client') {
showError('Please check your input');
} else if (result.error.error_code === 'server') {
showError('Server error, please try again');
}
}
```
## Custom Fetch
```typescript
import { createApiClient } from '$lib/utils/services/api';
const customClient = createApiClient({
customFetch: (input, init) => {
// Your custom fetch logic
return fetch(input, init);
}
});
const result = await safeRequest(async () => {
return customClient.request('/api/data', { method: 'GET' });
});
```
## RPC Usage
```typescript
import { classroomio } from '$lib/utils/services/api';
// Use with Hono RPC
const data = await classroomio.users.$get();
const user = await classroomio.users.$post({ json: { name: 'John' } });
```
## Error Codes
- `'auth'` - 401 Unauthorized
- `'client'` - 400-499 Client errors
- `'server'` - 500-599 Server errors
- `'timeout'` - 408 Request timeout
- `'network'` - 0 Network issues
@@ -0,0 +1,24 @@
import type { ApiClientConfig } from './types';
import { env } from '$env/dynamic/public';
// Default configuration
export const DEFAULT_CONFIG: Required<ApiClientConfig> = {
baseURL: env.PUBLIC_SERVER_URL || '',
timeout: 30000, // 30 seconds
retries: 1,
retryDelay: 1000, // 1 second
customFetch: fetch,
onAuthError: async () => {
// Default: redirect to login or refresh token
console.warn('Authentication error occurred. Consider redirecting to login.');
},
onNetworkError: async (error: Error) => {
console.error('Network error:', error);
},
onResponse: async (response: Response) => {
// Default: log response for debugging
if (process.env.NODE_ENV === 'development') {
console.log(`API Response: ${response.status} ${response.statusText}`);
}
}
};
@@ -0,0 +1,212 @@
import { type ApiClientConfig, ApiError, type RequestConfig } from './types';
import { DEFAULT_CONFIG } from './constants';
import { delay } from './utils';
import { env } from '$env/dynamic/public';
import { getAccessToken } from '$lib/utils/functions/supabase';
import { hcWithType } from '@cio/api/rpc-types';
class ApiClient {
private config: Required<ApiClientConfig>;
constructor(config: ApiClientConfig = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
private async makeRequest(
input: RequestInfo | URL,
requestConfig: RequestConfig = {}
): Promise<Response> {
console.log('makeRequest', input, requestConfig);
const {
timeout = this.config.timeout,
retries = this.config.retries,
skipAuth = false,
...fetchConfig
} = requestConfig;
const url = typeof input === 'string' ? input : input.toString();
const fullUrl = url.startsWith('http') ? url : `${this.config.baseURL}${url}`;
// Prepare headers
const headers = new Headers(fetchConfig.headers);
// Add default headers
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json');
}
// Handle body serialization and content-type
let requestBody = fetchConfig.body;
if (
fetchConfig.body &&
typeof fetchConfig.body === 'object' &&
!(fetchConfig.body instanceof FormData)
) {
// Auto-stringify objects (except FormData)
requestBody = JSON.stringify(fetchConfig.body);
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
} else if (fetchConfig.body && !headers.has('Content-Type')) {
// Set content-type for other body types if not already set
if (typeof fetchConfig.body === 'string') {
headers.set('Content-Type', 'text/plain');
}
}
// Add authentication if not skipped
if (!skipAuth) {
try {
const token = await getAccessToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
} catch (error) {
console.warn('Failed to get access token:', error);
// Continue without auth if token retrieval fails
}
}
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const requestInit: RequestInit = {
...fetchConfig,
headers,
body: requestBody,
signal: controller.signal
};
let lastError: Error | null = null;
// Retry logic with exponential backoff
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await this.config.customFetch(fullUrl, requestInit);
clearTimeout(timeoutId);
await this.config.onResponse(response);
if (response.ok) {
return response;
}
if (response.status === 401) {
await this.config.onAuthError();
throw new ApiError(
'Authentication failed',
response.status,
response.statusText,
response
);
}
if (response.status >= 400 && response.status < 500) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new ApiError(
`Client error: ${errorText}`,
response.status,
response.statusText,
response
);
}
if (response.status >= 500) {
const error = new ApiError(
`Server error: ${response.statusText}`,
response.status,
response.statusText,
response
);
if (attempt === retries) {
throw error;
}
lastError = error;
await delay(this.config.retryDelay * Math.pow(2, attempt));
continue;
}
// Other status codes
throw new ApiError(
`Unexpected status: ${response.statusText}`,
response.status,
response.statusText,
response
);
} catch (error) {
clearTimeout(timeoutId);
// Handle abort (timeout)
if (error instanceof Error && error.name === 'AbortError') {
throw new ApiError('Request timeout', 408, 'Request Timeout');
}
// Handle network errors - retryable
if (error instanceof TypeError && error.message.includes('fetch')) {
const networkError = new ApiError('Network error', 0, 'Network Error');
if (attempt === retries) {
await this.config.onNetworkError(networkError);
throw networkError;
}
lastError = networkError;
await delay(this.config.retryDelay * Math.pow(2, attempt));
continue;
}
// Re-throw non-retryable errors
throw error;
}
}
// If we get here, all retries failed
throw lastError || new ApiError('Request failed after all retries');
}
// Generic request method - handles all HTTP methods
async request(input: RequestInfo | URL, config: RequestConfig = {}): Promise<Response> {
return this.makeRequest(input, config);
}
// Update configuration
updateConfig(newConfig: Partial<ApiClientConfig>): void {
this.config = { ...this.config, ...newConfig };
}
}
// Create default instance
export const apiClient = new ApiClient();
// RPC client using the new fetch wrapper
export const classroomio = hcWithType(env.PUBLIC_SERVER_URL, {
fetch: async (input: RequestInfo | URL, requestInit?: RequestInit) => {
return apiClient.request(input, requestInit);
}
});
// Utility functions for common use cases
export const createApiClient = (config: ApiClientConfig) => new ApiClient(config);
// Re-export utility functions for convenience
export {
handleJsonResponse,
handleTextResponse,
isApiError,
isNetworkError,
isAuthError,
isClientError,
isServerError,
isTimeoutError,
safeRequest,
safeRequestText,
safeRequestRaw,
type Result
} from './utils';
export type { InferResponseType, InferRequestType } from '@cio/api/rpc-types';
@@ -0,0 +1,46 @@
// Types for better type safety
export interface ApiClientConfig {
baseURL?: string;
timeout?: number;
retries?: number;
retryDelay?: number;
customFetch?: typeof fetch;
onAuthError?: () => Promise<void> | void;
onNetworkError?: (error: Error) => Promise<void> | void;
onResponse?: (response: Response) => Promise<void> | void;
}
export interface RequestConfig extends RequestInit {
timeout?: number;
retries?: number;
skipAuth?: boolean;
}
export class ApiError extends Error {
public readonly error_code: string;
constructor(
message: string,
public status?: number,
public statusText?: string,
public response?: Response
) {
super(message);
this.name = 'ApiError';
// Determine error code based on status
if (status === 401) {
this.error_code = 'auth';
} else if (status && status >= 400 && status < 500) {
this.error_code = 'client';
} else if (status && status >= 500) {
this.error_code = 'server';
} else if (status === 408) {
this.error_code = 'timeout';
} else if (status === 0 || !status) {
this.error_code = 'network';
} else {
this.error_code = 'unknown';
}
}
}
@@ -0,0 +1,125 @@
import { ApiError } from './types';
// Utility function for exponential backoff
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Helper function to handle JSON responses
export const handleJsonResponse = async <T = any>(response: Response): Promise<T> => {
if (!response.ok) {
throw new ApiError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
response.statusText,
response
);
}
try {
return await response.json();
} catch (error) {
throw new ApiError(
'Failed to parse JSON response',
response.status,
response.statusText,
response
);
}
};
// Helper function to handle text responses
export const handleTextResponse = async (response: Response): Promise<string> => {
if (!response.ok) {
throw new ApiError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
response.statusText,
response
);
}
return response.text();
};
// Error handling utilities
export const isApiError = (error: unknown): error is ApiError => {
return error instanceof ApiError;
};
export const isNetworkError = (error: unknown): boolean => {
return isApiError(error) && error.status === 0;
};
export const isAuthError = (error: unknown): boolean => {
return isApiError(error) && error.status === 401;
};
export const isClientError = (error: unknown): boolean => {
return isApiError(error) && (error.status ?? 0) >= 400 && (error.status ?? 0) < 500;
};
export const isServerError = (error: unknown): boolean => {
return isApiError(error) && (error.status ?? 0) >= 500;
};
export const isTimeoutError = (error: unknown): boolean => {
return isApiError(error) && error.status === 408;
};
// Safe request wrapper that returns Result pattern
export type Result<T, E = ApiError> = { success: true; data: T } | { success: false; error: E };
// Generic safe request wrapper
export const safeRequest = async <T = any>(
requestFn: () => Promise<Response>
): Promise<Result<T>> => {
try {
const response = await requestFn();
const data = await handleJsonResponse<T>(response);
return { success: true, data };
} catch (error) {
if (isApiError(error)) {
return { success: false, error };
}
return {
success: false,
error: new ApiError('Unexpected error', 0, 'Unknown Error')
};
}
};
// Safe request wrapper for text responses
export const safeRequestText = async (
requestFn: () => Promise<Response>
): Promise<Result<string>> => {
try {
const response = await requestFn();
const data = await handleTextResponse(response);
return { success: true, data };
} catch (error) {
if (isApiError(error)) {
return { success: false, error };
}
return {
success: false,
error: new ApiError('Unexpected error', 0, 'Unknown Error')
};
}
};
// Safe request wrapper that returns the raw response
export const safeRequestRaw = async (
requestFn: () => Promise<Response>
): Promise<Result<Response>> => {
try {
const response = await requestFn();
return { success: true, data: response };
} catch (error) {
if (isApiError(error)) {
return { success: false, error };
}
return {
success: false,
error: new ApiError('Unexpected error', 0, 'Unknown Error')
};
}
};
@@ -11,9 +11,9 @@ import type {
} from '$lib/utils/types';
import type { PostgrestError, PostgrestSingleResponse } from '@supabase/supabase-js';
import { GenericUploader } from './presign';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { STATUS } from '$lib/utils/constants/course';
import { apiClient } from '$lib/utils/services/api';
import { get } from 'svelte/store';
import { isOrgAdmin } from '$lib/utils/store/org';
import { isUUID } from '$lib/utils/functions/isUUID';
@@ -53,12 +53,10 @@ export async function fetchProfileCourseProgress(
data: ProfileCourseProgress[] | null;
error: PostgrestError | null;
}> {
const { data, error } = await supabase
.rpc('get_course_progress', {
const { data, error } = await supabase.rpc('get_course_progress', {
course_id_arg: courseId,
profile_id_arg: profileId
})
.returns<ProfileCourseProgress[]>();
});
return { data, error };
}
@@ -286,23 +284,30 @@ export async function fetchLesson(lessonId: Lesson['id']) {
.single();
if (data) {
const keys = data.videos.filter((video) => video.type === 'upload').map((video) => video.key);
const videoKeys =
data.videos?.filter((video) => video.type === 'upload')?.map((video) => video.key) || [];
if (!keys.length) {
return { data, error };
}
const docKeys = data.documents?.map((doc) => doc.key) || [];
try {
const response = await apiClient.post('/course/presign/download', { keys });
// Get presigned URLs for videos and documents
const genericUploader = new GenericUploader('generic');
const urls = await genericUploader.getAllDownloadPresignedUrl(videoKeys, docKeys);
data.videos = data.videos.map((video) => {
if (video.type === 'upload') {
video.link = response.data.urls[video.key];
if (urls.videos[video.key]) {
video.link = urls.videos[video.key];
}
return video;
});
data.documents = data.documents.map((doc) => {
doc.link = urls.documents[doc.key];
return doc;
});
} catch (error) {
console.error('Error retrieving videos:', error);
console.error('Error retrieving presigned assets (videos and documents):', error);
}
}
@@ -0,0 +1,134 @@
import { type Writable, get } from 'svelte/store';
import {
lessonDocUpload,
lessonVideoUpload
} from '$lib/components/Course/components/Lesson/store/lessons';
import axios from 'axios';
import { classroomio } from '$lib/utils/services/api';
export type UploadType = 'document' | 'video' | 'generic';
export class GenericUploader {
public abortController: AbortController | null = null;
private uploadType: UploadType;
private uploadStore: Writable<any>;
constructor(uploadType: UploadType) {
this.uploadType = uploadType;
this.uploadStore = uploadType === 'document' ? lessonDocUpload : lessonVideoUpload;
this.abortController = new AbortController();
}
async getDownloadPresignedUrl(keys: string[], type = this.uploadType) {
const endpoint =
type === 'document'
? classroomio.course.presign.document.download
: classroomio.course.presign.video.download;
const response = await endpoint.$post({
json: {
keys
}
});
return response.json();
}
async getAllDownloadPresignedUrl(videoKeys: string[], docKeys: string[]) {
const urls = {
videos: {},
documents: {}
};
try {
if (videoKeys.length) {
const videoResponse = await this.getDownloadPresignedUrl(videoKeys, 'video');
urls.videos = videoResponse?.urls || {};
}
if (docKeys.length) {
const docResponse = await this.getDownloadPresignedUrl(docKeys, 'document');
urls.documents = docResponse?.urls || {};
}
} catch (error) {
console.error('Error getting download presigned url:', error);
}
return urls;
}
async getPresignedUrl(file: File) {
const endpoint =
this.uploadType === 'document'
? classroomio.course.presign.document.upload
: classroomio.course.presign.video.upload;
const response = await endpoint.$post({
json: {
fileName: file?.name,
fileType: file?.type
}
});
return response.json();
}
async uploadFile(params: { url: string; file: File }) {
await axios.put(params.url, params.file, {
headers: {
'Content-Type': params.file.type
},
maxContentLength: Infinity,
maxBodyLength: Infinity,
signal: this.abortController?.signal,
onUploadProgress: (progressEvent) => {
if (get(this.uploadStore).isCancelled) {
this.abortController?.abort();
return;
}
const progress = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1));
this.uploadStore.update((state) => ({
...state,
uploadProgress: progress
}));
}
});
}
initUpload() {
this.uploadStore.update((state) => ({
...state,
isUploading: true,
uploadProgress: 0,
error: null,
isCancelled: false
}));
this.abortController = new AbortController();
}
cancelUpload() {
this.uploadStore.update((store) => ({
...store,
isCancelled: true,
isUploading: false
}));
this.abortController?.abort();
this.abortController = null;
}
}
export class DocumentUploader extends GenericUploader {
constructor() {
super('document');
}
}
export class VideoUploader extends GenericUploader {
constructor() {
super('video');
}
}
@@ -789,7 +789,8 @@
"loading_pdf": "PDF laden ...",
"failed_to_load_pdf": "Das PDF -Dokument konnte nicht geladen werden",
"failed_to_render_pdf": "Die PDF -Seite versäumte es, PDF -Seite zu rendern",
"use_arrow_keys": "Verwenden Sie Pfeiltasten, um zu navigieren, +/-, um zu zoomen, oder klicken Sie auf die obigen Steuerelemente. Mit der rechten Maustaste ist deaktiviert."
"use_arrow_keys": "Verwenden Sie Pfeiltasten, um zu navigieren, +/-, um zu zoomen, oder klicken Sie auf die obigen Steuerelemente. Mit der rechten Maustaste ist deaktiviert.",
"upgrade": "Aktualisieren Sie Ihren Plan, um Dokumente hochzuladen"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "Gesperrt",
"unlocked": "Entsperrt"
"unlocked": "Entsperrt",
"loading": "Laden"
},
"specialization": {
"course_tag": "Spezialisierung"
@@ -693,6 +693,7 @@
"upload_error_message": "Unsupported file format or upload failed",
"upload_error_try_again": "Please try again with a valid document file",
"try_again": "Try Again",
"upgrade": "Upgrade your plan to upload documents",
"cancel_upload": "Cancel Upload",
"upload_cancelled": "Upload cancelled by user",
@@ -1521,7 +1522,8 @@
"generic": {
"locked": "Locked",
"unlocked": "Unlocked"
"unlocked": "Unlocked",
"loading": "Loading"
},
"validations": {
@@ -789,7 +789,8 @@
"loading_pdf": "Cargando PDF ...",
"failed_to_load_pdf": "No se pudo cargar el documento PDF",
"failed_to_render_pdf": "No se pudo renderizar la página de PDF",
"use_arrow_keys": "Use las teclas de flecha para navegar, +/- para hacer zoom o hacer clic en los controles anteriores. El clic derecho está deshabilitado."
"use_arrow_keys": "Use las teclas de flecha para navegar, +/- para hacer zoom o hacer clic en los controles anteriores. El clic derecho está deshabilitado.",
"upgrade": "Actualice su plan para cargar documentos"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "bloqueado",
"unlocked": "desbloqueado"
"unlocked": "desbloqueado",
"loading": "Cargando"
},
"specialization": {
"course_tag": "Especialización"
@@ -789,7 +789,8 @@
"loading_pdf": "Chargement du PDF ...",
"failed_to_load_pdf": "Échec du chargement du document PDF",
"failed_to_render_pdf": "Échec de la page PDF",
"use_arrow_keys": "Utilisez des touches flèches pour naviguer, +/- pour zoomer ou cliquez sur les commandes ci-dessus. Le clic droit est désactivé."
"use_arrow_keys": "Utilisez des touches flèches pour naviguer, +/- pour zoomer ou cliquez sur les commandes ci-dessus. Le clic droit est désactivé.",
"upgrade": "Mettez à niveau votre plan pour télécharger des documents"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "Fermé à clé",
"unlocked": "Débloqué"
"unlocked": "Débloqué",
"loading": "Chargement"
},
"specialization": {
"course_tag": "Spécialisation"
@@ -789,7 +789,8 @@
"loading_pdf": "लोडिंग पीडीएफ ...",
"failed_to_load_pdf": "पीडीएफ दस्तावेज़ लोड करने में विफल",
"failed_to_render_pdf": "पीडीएफ पेज को प्रस्तुत करने में विफल",
"use_arrow_keys": "ज़ूम करने के लिए नेविगेट करने के लिए तीर कुंजियों का उपयोग करें, या ऊपर दिए गए नियंत्रणों पर क्लिक करें। राइट-क्लिक अक्षम है।"
"use_arrow_keys": "ज़ूम करने के लिए नेविगेट करने के लिए तीर कुंजियों का उपयोग करें, या ऊपर दिए गए नियंत्रणों पर क्लिक करें। राइट-क्लिक अक्षम है।",
"upgrade": "दस्तावेज़ अपलोड करने के लिए अपनी योजना को अपग्रेड करें"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "बंद",
"unlocked": "अनलॉक किया"
"unlocked": "अनलॉक किया",
"loading": "लोड करना"
},
"specialization": {
"course_tag": "विशेषज्ञता"
@@ -99,7 +99,8 @@
},
"generic": {
"locked": "Zamknięty",
"unlocked": "Odblokowany"
"unlocked": "Odblokowany",
"loading": "Załadunek"
},
"content": {
"heading": "Internacjonalizacja w SvelTekit",
@@ -957,7 +958,8 @@
"loading_pdf": "Ładowanie PDF ...",
"failed_to_load_pdf": "Nie udało się załadować dokumentu PDF",
"failed_to_render_pdf": "Nie udało się renderować strony PDF",
"use_arrow_keys": "Użyj klawiszy strzałek do nawigacji, +/-, aby powiększyć lub kliknąć powyższe elementy sterujące. Kliknięcie prawym przyciskiem myszy jest wyłączone."
"use_arrow_keys": "Użyj klawiszy strzałek do nawigacji, +/-, aby powiększyć lub kliknąć powyższe elementy sterujące. Kliknięcie prawym przyciskiem myszy jest wyłączone.",
"upgrade": "Uaktualnij swój plan przesyłania dokumentów"
}
}
},
@@ -789,7 +789,8 @@
"loading_pdf": "Carregando PDF ...",
"failed_to_load_pdf": "Falha ao carregar o documento PDF",
"failed_to_render_pdf": "Falhou em renderizar a página em PDF",
"use_arrow_keys": "Use as teclas de seta para navegar, +/- zoom ou clique nos controles acima. Clique com o botão direito do mouse está desativado."
"use_arrow_keys": "Use as teclas de seta para navegar, +/- zoom ou clique nos controles acima. Clique com o botão direito do mouse está desativado.",
"upgrade": "Atualize seu plano para fazer upload de documentos"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "Bloqueado",
"unlocked": "Desbloqueado"
"unlocked": "Desbloqueado",
"loading": "Carregando"
},
"specialization": {
"course_tag": "Especialização"
@@ -789,7 +789,8 @@
"loading_pdf": "Загрузка PDF ...",
"failed_to_load_pdf": "Не удалось загрузить документ PDF",
"failed_to_render_pdf": "Не удалось отобразить страницу PDF",
"use_arrow_keys": "Используйте клавиши со стрелками, чтобы перемещаться, +/- до масштабирования или нажмите кнопку элементов управления выше. Щелкнуть правой кнопкой мыши отключено."
"use_arrow_keys": "Используйте клавиши со стрелками, чтобы перемещаться, +/- до масштабирования или нажмите кнопку элементов управления выше. Щелкнуть правой кнопкой мыши отключено.",
"upgrade": "Обновите свой план для загрузки документов"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "Заблокировано",
"unlocked": "Разблокировано"
"unlocked": "Разблокировано",
"loading": "Загрузка"
},
"specialization": {
"course_tag": "Специализация"
@@ -789,7 +789,8 @@
"loading_pdf": "Tải PDF ...",
"failed_to_load_pdf": "Không tải tài liệu PDF",
"failed_to_render_pdf": "Không thể hiển thị trang PDF",
"use_arrow_keys": "Sử dụng các phím mũi tên để điều hướng, +/- đến phóng to hoặc nhấp vào các điều khiển ở trên. Nhấp chuột phải bị vô hiệu hóa."
"use_arrow_keys": "Sử dụng các phím mũi tên để điều hướng, +/- đến phóng to hoặc nhấp vào các điều khiển ở trên. Nhấp chuột phải bị vô hiệu hóa.",
"upgrade": "Nâng cấp kế hoạch của bạn để tải lên tài liệu"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "Đã khóa",
"unlocked": "Đã mở khóa"
"unlocked": "Đã mở khóa",
"loading": "Đang tải"
},
"specialization": {
"course_tag": "Chuyên môn"
+1 -1
View File
@@ -295,7 +295,7 @@ export type LessonDocument = {
name: string;
link: string;
size?: number;
key?: string;
key: string;
};
export interface LessonPage {
+5 -8
View File
@@ -1,4 +1,4 @@
import { env } from '$env/dynamic/public';
import { classroomio } from '$lib/utils/services/api';
const sendEmail = (sFetch: typeof fetch) => {
return async (
@@ -12,13 +12,10 @@ const sendEmail = (sFetch: typeof fetch) => {
}[]
) => {
try {
const response = await sFetch(`${env.PUBLIC_SERVER_URL}/mail/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(emailDataArray)
});
const response = await classroomio.mail.send.$post(
{ json: emailDataArray },
{ fetch: sFetch }
);
if (!response.ok) {
console.log('Failed to send emails');
@@ -41,7 +41,7 @@
import { ChevronLeft, ChevronRight, Edit, Save } from 'carbon-icons-svelte';
import OverflowMenuVertical from 'carbon-icons-svelte/lib/OverflowMenuVertical.svelte';
import ResultOld from 'carbon-icons-svelte/lib/ResultOld.svelte';
import { apiClient } from '$lib/utils/services/api';
import { classroomio } from '$lib/utils/services/api';
export let data;
@@ -160,9 +160,8 @@
const lessonNumber = getLessonOrder(currentLesson.id);
const slideUrl = $lesson.materials.slide_url || '';
const response = await apiClient.post(
'/course/lesson/download/pdf',
{
const response = await classroomio.course.lesson.download.pdf.$post({
json: {
title: currentLesson.title,
number: lessonNumber,
orgName: $currentOrg.name,
@@ -170,13 +169,11 @@
slideUrl: slideUrl,
video: lessonVideo,
courseTitle: $course.title
},
{
responseType: 'blob'
}
);
});
const blob = await response.blob();
const file = new Blob([response.data], { type: 'application/pdf' });
const file = new Blob([blob], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file);
let a = document.createElement('a');
-15
View File
@@ -1,15 +0,0 @@
/** @type {import('tailwindcss').Config}*/
const config = {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {}
},
plugins: []
};
module.exports = config;
// generic name for component
// add theme-name dynamically on body
// have globaly style for theme-name .bg-primary-100
+1 -1
View File
@@ -11,7 +11,7 @@
"strict": true,
"target": "ESNext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */,
"module": "ESNext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"noImplicitAny": false,
"paths": {
"$lib": ["./src/lib"],
+10 -4
View File
@@ -1,7 +1,9 @@
import { sveltekit } from '@sveltejs/kit/vite';
import fs from 'fs';
import { defineConfig, loadEnv } from 'vite';
import fs from 'fs';
import path from 'path';
import { sveltekit } from '@sveltejs/kit/vite';
export default ({ mode }) => {
process.env = { ...process.env, ...loadEnv(mode, process.cwd()) };
@@ -19,10 +21,14 @@ export default ({ mode }) => {
sourcemap: false
},
optimizeDeps: {
entries: ['src/routes/**/+*.{js,ts,svelte}']
entries: ['src/routes/**/+*.{js,ts,svelte}'],
include: ['@cio/api/rpc-types']
},
resolve: {
mainFields: ['browser']
mainFields: ['browser'],
alias: {
'@cio/api': path.resolve(__dirname, '../api/dist')
}
}
});
};
@@ -4,4 +4,4 @@ import { Callout } from 'nextra/components';
# Cloudflare Setup
In this guide, you will learn how to get your cloudflare environment variables. We use Cloudflare R2 for video uploads and the aim of this guide is to help you get the right variables to properly setup our backend.
In this guide, you will learn how to get your cloudflare environment variables. We use Cloudflare R2 for video uploads and the aim of this guide is to help you get the right variables to properly setup our api.
+10 -10
View File
@@ -10,19 +10,19 @@ This guide will help you quickly selfhost ClassroomIO
ClassroomIO is made up of the following applications
- [apps/backend](https://github.com/rotimi-best/classroomio/tree/main/apps/backend) - an Express server
- [apps/api](https://github.com/rotimi-best/classroomio/tree/main/apps/api) - an Express server
- [apps/dashboard](https://github.com/rotimi-best/classroomio/tree/main/apps/dashboard) - the admin dashboard + LMS
- [supabase](https://github.com/rotimi-best/classroomio/tree/main/supabase) - the database + REST API.
The quickest and easiest way to deploy both the backend and dashboard is by using our [Railway Template](https://railway.app/template/dl9RG7?referralCode=8OyIj5). However there are a couple of things that you'd need first before heading over to the template, which are:
The quickest and easiest way to deploy both the api and dashboard is by using our [Railway Template](https://railway.app/template/dl9RG7?referralCode=8OyIj5). However there are a couple of things that you'd need first before heading over to the template, which are:
1. SMTP values - For sending emails on the backend
1. SMTP values - For sending emails on the api
2. Supabase - For the database used on the dashboard
3. Cloudflare R2 (optional) - Video uploads on the backend
3. Cloudflare R2 (optional) - Video uploads on the api
## SMTP Setup
Both for the supabase and backend setup, you'd need a custom SMTP for email sending. Most importantly you need these values:
Both for the supabase and api setup, you'd need a custom SMTP for email sending. Most importantly you need these values:
```bash
SMTP_HOST=
@@ -63,7 +63,7 @@ We use Cloudflare R2 for video uploading within the course builder. It is not re
## Deploy on Railway
Now you have everything you need and you are ready to deploy the backend and dashboard.
Now you have everything you need and you are ready to deploy the api and dashboard.
Go ahead and click this button to get started.
@@ -71,7 +71,7 @@ Go ahead and click this button to get started.
### Environment Variables.
#### cio-backend
#### cio-api
```bash
# Required
@@ -94,7 +94,7 @@ SENTRY_DNS=""
<Callout type="info" emoji="ℹ️">
Learn more about the environment variables
[here](https://github.com/rotimi-best/classroomio/blob/main/apps/backend/.env.example)
[here](https://github.com/rotimi-best/classroomio/blob/main/apps/api/.env.example)
</Callout>
#### cio-dashboard
@@ -121,7 +121,7 @@ PRIVATE_APP_SUBDOMAINS=""
## Using Docker
We provide a docker-compose configuration to deploy both the backend and dashboard services. Here's how to use it:
We provide a docker-compose configuration to deploy both the api and dashboard services. Here's how to use it:
1. Clone the repository:
@@ -151,7 +151,7 @@ The docker-compose file sets up two services:
- Runs on port 3002
- Uses Node.js slim image
- Mounts the backend code directory
- Mounts the api code directory
- Requires environment variables for:
- Supabase configuration
- Cloudflare R2 for video uploads

Some files were not shown because too many files have changed in this diff Show More