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
+44
View File
@@ -0,0 +1,44 @@
import 'dotenv/config';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { courseRouter } from '$src/routes/course/course';
import { logger } from 'hono/logger';
import { mailRouter } from '$src/routes/mail';
import { prettyJSON } from 'hono/pretty-json';
import { rateLimiterMiddleware } from '$src/middlewares/rate-limiter';
import { secureHeaders } from 'hono/secure-headers';
// Create Hono app with chaining for RPC support
export const app = new Hono()
// Middleware
.use('*', logger())
.use('*', prettyJSON())
.use('*', secureHeaders())
.use(
'*',
cors({
origin: '*',
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
maxAge: 600,
credentials: true
})
)
.use('*', rateLimiterMiddleware)
// Routes
.get('/', (c) =>
c.json({
message: '"Welcome to Classroomio.com API - docs are at https://api.classroomio.com/docs"'
})
)
.route('/course', courseRouter)
.route('/mail', mailRouter)
// Error handling
.onError((err, c) => {
console.error('Error:', err);
return c.json({ error: 'Internal Server Error' }, 500);
});
+23
View File
@@ -0,0 +1,23 @@
import { z } from 'zod';
const envSchema = z.object({
CLOUDFLARE_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
CLOUDFLARE_BUCKET_DOMAIN: z.string().optional(),
CLOUDFLARE_RENDERING_API_KEY: z.string().optional(),
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
OPENAPI_URL: z.string().optional(),
PORT: z.string().optional(),
PUBLIC_SUPABASE_ANON_KEY: z.string(),
PUBLIC_SUPABASE_URL: z.string(),
REDIS_URL: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_SENDER: z.string().optional(),
SMTP_USER: z.string().optional(),
ZOHO_TOKEN: z.string().optional()
});
export const env = envSchema.parse(process.env);
+29
View File
@@ -0,0 +1,29 @@
import { env } from '$src/config/env';
export * from './rate-limiter';
const {
CLOUDFLARE_BUCKET_DOMAIN,
CLOUDFLARE_ACCESS_KEY,
CLOUDFLARE_SECRET_ACCESS_KEY,
CLOUDFLARE_ACCOUNT_ID,
CLOUDFLARE_RENDERING_API_KEY
} = env;
export const CLOUDFLARE = {
CONFIGS: {
BUCKET_DOMAIN: CLOUDFLARE_BUCKET_DOMAIN!,
ACCESS_KEY: CLOUDFLARE_ACCESS_KEY!,
RENDERING_API_KEY: CLOUDFLARE_RENDERING_API_KEY!,
SECRET_ACCESS_KEY: CLOUDFLARE_SECRET_ACCESS_KEY!,
ACCOUNT_ID: CLOUDFLARE_ACCOUNT_ID!
},
R2: {
BUCKET: 'videos',
BUCKET_DOMAIN: CLOUDFLARE_ACCOUNT_ID
? `https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`
: CLOUDFLARE_BUCKET_DOMAIN!,
PRESIGN_EXPIRATION_TIME: 60 * 60, // 1 hour
DOWNLOAD_EXPIRATION_TIME: 60 * 60 // 1 hour
}
} as const;
+32
View File
@@ -0,0 +1,32 @@
// Default time window for rate limiting (1 minute in milliseconds)
export const DEFAULT_WINDOW_MS = 60 * 1000;
export const DEFAULT_MAX_REQUESTS = 100;
export const RATE_LIMIT_KEY_PREFIX = 'rate_limit:';
export const DEFAULT_RATE_LIMITER_OPTIONS = {
windowMs: DEFAULT_WINDOW_MS,
maxRequests: DEFAULT_MAX_REQUESTS,
skipSuccessfulRequests: false,
skipFailedRequests: false,
message: 'Rate limit exceeded. Please try again later.',
standardHeaders: true,
legacyHeaders: false
} as const;
export const HTTP_STATUS = {
TOO_MANY_REQUESTS: 429
} as const;
export const ERROR_MESSAGES = {
RATE_LIMIT_EXCEEDED: 'Too Many Requests',
REDIS_PIPELINE_FAILED: 'Redis pipeline execution failed'
} as const;
export const RATE_LIMIT_HEADERS = {
LIMIT: 'X-RateLimit-Limit',
REMAINING: 'X-RateLimit-Remaining',
RESET: 'X-RateLimit-Reset',
RETRY_AFTER: 'Retry-After'
} as const;
+20
View File
@@ -0,0 +1,20 @@
export const ALLOWED_CONTENT_TYPES = [
'video/mp4',
'video/quicktime',
'video/x-msvideo',
'video/x-matroska'
] as const;
export const ALLOWED_DOCUMENT_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
'application/msword' // .doc
] as const;
export const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
export const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
export const BUCKET_NAME = {
VIDEOS: 'videos',
DOCUMENTS: 'documents'
} as const;
+19 -6
View File
@@ -1,9 +1,22 @@
import { Hono } from 'hono'
import 'dotenv/config';
const app = new Hono()
import { app } from '$src/app';
import { configureOpenAPI } from '$src/utils/openapi';
import { env } from '$src/config/env';
import { serve } from '@hono/node-server';
import { showRoutes } from 'hono/dev';
app.get('/', (c) => {
return c.text('Hello Hono!')
})
// Start server
const port = env.PORT ? parseInt(env.PORT) : 3002;
export default app
function startServer() {
console.log('Starting server...');
serve({ fetch: app.fetch, port });
showRoutes(app, { colorize: true });
}
configureOpenAPI(app);
startServer();
+44
View File
@@ -0,0 +1,44 @@
import { Context, Next } from 'hono';
import { validateUser } from '$src/utils/auth/validate-user';
export const authMiddleware = async (c: Context, next: Next) => {
try {
const authHeader = c.req.header('authorization');
if (!authHeader) {
return c.json(
{
success: false,
message: 'No authorization header provided'
},
401
);
}
// Extract the token from "Bearer <token>"
const token = authHeader.split(' ')[1];
if (!token) {
return c.json(
{
success: false,
message: 'Invalid authorization header format'
},
401
);
}
const user = await validateUser(token);
c.set('user', user); // Attach the user to the context
await next();
} catch (error) {
return c.json(
{
success: false,
message: 'Unauthorized'
},
401
);
}
};
+79
View File
@@ -0,0 +1,79 @@
import {
DEFAULT_RATE_LIMITER_OPTIONS,
ERROR_MESSAGES,
HTTP_STATUS,
RATE_LIMIT_HEADERS
} from '$src/constants/rate-limiter';
import type { MiddlewareHandler } from 'hono';
import { rateLimiter } from '$src/utils/redis/limiter';
import { userKeyGenerator } from '../utils/redis/key-generators';
export interface RateLimiterOptions {
windowMs?: number;
maxRequests?: number;
keyGenerator?: (c: any) => string;
skipSuccessfulRequests?: boolean;
skipFailedRequests?: boolean;
message?: string;
standardHeaders?: boolean;
legacyHeaders?: boolean;
}
const defaultOptions: Required<RateLimiterOptions> = {
...DEFAULT_RATE_LIMITER_OPTIONS,
keyGenerator: userKeyGenerator
};
export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareHandler => {
const opts = { ...defaultOptions, ...options };
return async (c, next) => {
try {
// Generate rate limit key
const key = opts.keyGenerator(c);
// Check rate limit
const result = await rateLimiter.isAllowed(key);
// Set rate limit headers
if (opts.standardHeaders) {
c.header(RATE_LIMIT_HEADERS.LIMIT, opts.maxRequests.toString());
c.header(RATE_LIMIT_HEADERS.REMAINING, result.remaining.toString());
c.header(RATE_LIMIT_HEADERS.RESET, new Date(result.resetTime).toISOString());
}
if (opts.legacyHeaders) {
c.header(RATE_LIMIT_HEADERS.LIMIT, opts.maxRequests.toString());
c.header(RATE_LIMIT_HEADERS.REMAINING, result.remaining.toString());
c.header(RATE_LIMIT_HEADERS.RESET, Math.ceil(result.resetTime / 1000).toString());
}
if (!result.allowed) {
const retryAfter = Math.ceil((result.resetTime - Date.now()) / 1000);
if (opts.standardHeaders) {
c.header(RATE_LIMIT_HEADERS.RETRY_AFTER, retryAfter.toString());
}
return c.json(
{
error: ERROR_MESSAGES.RATE_LIMIT_EXCEEDED,
message: opts.message,
retryAfter
},
HTTP_STATUS.TOO_MANY_REQUESTS
);
}
await next();
} catch (error) {
console.error('Rate limiter error:', error);
// If Redis is down, allow the request but log the error
await next();
}
};
};
// Default rate limiter middleware with standard configuration
export const rateLimiterMiddleware = createRateLimiter();
+50
View File
@@ -0,0 +1,50 @@
import { ZCertificateDownload, ZCourseDownloadContent } from '$src/types/course';
import { Hono } from 'hono';
import { generateCertificate } from '$src/utils/certificate';
import { generateCoursePdf } from '$src/utils/course';
import { katexRouter } from '$src/routes/course/katex';
import { lessonRouter } from '$src/routes/course/lesson';
import { presignRouter } from '$src/routes/course/presign';
import { zValidator } from '@hono/zod-validator';
export const courseRouter = new Hono()
.post('/download/certificate', zValidator('json', ZCertificateDownload), async (c) => {
const validatedData = c.req.valid('json');
const result = await generateCertificate(validatedData);
c.header('Content-Type', 'application/pdf');
c.header(
'Content-Disposition',
`attachment; filename="certificate-${validatedData.courseName}.pdf"`
);
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(result);
controller.close();
}
})
);
})
.post('/download/content', zValidator('json', ZCourseDownloadContent), async (c) => {
const validatedData = c.req.valid('json');
const pdfBuffer = await generateCoursePdf(validatedData);
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
})
.route('/katex', katexRouter)
.route('/lesson', lessonRouter)
.route('/presign', presignRouter);
+39
View File
@@ -0,0 +1,39 @@
import { Context, Hono } from 'hono';
import katex from 'katex';
import { z } from 'zod';
export const katexRouter = new Hono().get('/', async (c: Context) => {
try {
const original = c.req.raw.url;
const latexString = original.split('?')[1].replaceAll('&plus;', '+').replaceAll('&space;', '');
const html = katex.renderToString(latexString, {
output: 'mathml',
throwOnError: false
});
return c.html(html);
} catch (error) {
if (error instanceof z.ZodError) {
return c.json(
{
success: false,
error: 'Validation error',
details: error.message
},
400
);
}
console.error('Error rendering LaTeX:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
},
500
);
}
});
+24
View File
@@ -0,0 +1,24 @@
import { Hono } from 'hono';
import { ZLessonDownloadContent } from '$src/types/course/lesson';
import { generateLessonPdf } from '$src/utils/lesson';
import { zValidator } from '@hono/zod-validator';
export const lessonRouter = new Hono().post(
'/download/pdf',
zValidator('json', ZLessonDownloadContent),
async (c) => {
const validatedData = c.req.valid('json');
const pdfBuffer = await generateLessonPdf(validatedData);
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
}
);
+253
View File
@@ -0,0 +1,253 @@
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import {
ZCourseDocumentPresignUrlUpload,
ZCourseDownloadPresignedUrl,
ZCoursePresignUrlUpload
} from '$src/types/course';
import { describeRoute, validator } from 'hono-openapi';
import { BUCKET_NAME } from '$src/constants/upload';
import { CLOUDFLARE } from '$src/constants';
import type { GetSignedUrlParameters } from '$src/utils/s3';
import { Hono } from 'hono';
import { authMiddleware } from '$src/middlewares/auth';
import { generateFileKey } from '$src/utils/upload';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { s3Client } from '$src/utils/s3';
// Response schemas for OpenAPI documentation
const PresignUploadResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
url: { type: 'string' as const },
fileKey: { type: 'string' as const },
message: { type: 'string' as const }
},
required: ['success', 'url', 'fileKey', 'message']
};
const PresignDownloadResponse = {
type: 'object' as const,
properties: {
success: { type: 'boolean' as const },
urls: {
type: 'object' as const,
additionalProperties: { type: 'string' as const }
},
message: { type: 'string' as const }
},
required: ['success', 'urls', 'message']
};
export const presignRouter = new Hono()
.post(
'/video/upload',
authMiddleware,
describeRoute({
description: 'Generate a pre-signed URL for video upload',
responses: {
200: {
description: 'Pre-signed URL generated successfully',
content: {
'application/json': {
schema: PresignUploadResponse
}
}
},
400: {
description: 'Invalid request body'
},
401: {
description: 'Unauthorized'
}
},
tags: ['Presign']
}),
validator('json', ZCoursePresignUrlUpload),
async (c) => {
const body = c.req.valid('json');
const { fileName, fileType } = body;
const fileKey = generateFileKey(fileName);
const command = new PutObjectCommand({
Bucket: BUCKET_NAME.VIDEOS,
Key: fileKey,
ContentType: fileType
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.PRESIGN_EXPIRATION_TIME
});
return c.json({
success: true,
url: presignedUrl,
fileKey,
message: 'Pre-signed URL generated successfully'
});
}
)
.post(
'/document/upload',
authMiddleware,
describeRoute({
description: 'Generate a pre-signed URL for document upload',
responses: {
200: {
description: 'Document pre-signed URL generated successfully',
content: {
'application/json': {
schema: PresignUploadResponse
}
}
},
400: {
description: 'Invalid request body'
},
401: {
description: 'Unauthorized'
}
},
tags: ['Presign']
}),
validator('json', ZCourseDocumentPresignUrlUpload),
async (c) => {
const body = c.req.valid('json');
const { fileName, fileType } = body;
const fileKey = generateFileKey(fileName);
const command = new PutObjectCommand({
Bucket: BUCKET_NAME.DOCUMENTS,
Key: fileKey,
ContentType: fileType
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.PRESIGN_EXPIRATION_TIME
});
return c.json({
success: true,
url: presignedUrl,
fileKey,
message: 'Document pre-signed URL generated successfully'
});
}
)
.post(
'/video/download',
authMiddleware,
describeRoute({
description: 'Generate pre-signed URLs for video download',
responses: {
200: {
description: 'Video URLs retrieved successfully',
content: {
'application/json': {
schema: PresignDownloadResponse
}
}
},
400: {
description: 'Invalid request body'
},
401: {
description: 'Unauthorized'
}
},
tags: ['Presign']
}),
validator('json', ZCourseDownloadPresignedUrl),
async (c) => {
const body = c.req.valid('json');
const { keys } = body;
const signedUrls: Record<string, string> = {};
const urlPromises = keys.map(async (key) => {
const command = new GetObjectCommand({
Bucket: BUCKET_NAME.VIDEOS,
Key: key
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.DOWNLOAD_EXPIRATION_TIME
});
return { key, presignedUrl };
});
const results = await Promise.all(urlPromises);
results.forEach(({ key, presignedUrl }) => {
signedUrls[key] = presignedUrl;
});
return c.json({
success: true,
urls: signedUrls,
message: 'Video URLs retrieved successfully'
});
}
)
.post(
'/document/download',
authMiddleware,
describeRoute({
description: 'Generate pre-signed URLs for document download',
responses: {
200: {
description: 'Document URLs retrieved successfully',
content: {
'application/json': {
schema: PresignDownloadResponse
}
}
},
400: {
description: 'Invalid request body'
},
401: {
description: 'Unauthorized'
}
},
tags: ['Presign']
}),
validator('json', ZCourseDownloadPresignedUrl),
async (c) => {
const body = c.req.valid('json');
const { keys } = body;
const signedUrls: Record<string, string> = {};
const urlPromises = keys.map(async (key) => {
const command = new GetObjectCommand({
Bucket: BUCKET_NAME.DOCUMENTS,
Key: key
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.DOWNLOAD_EXPIRATION_TIME
});
return { key, presignedUrl };
});
const results = await Promise.all(urlPromises);
results.forEach(({ key, presignedUrl }) => {
signedUrls[key] = presignedUrl;
});
return c.json({
success: true,
urls: signedUrls,
message: 'Document URLs retrieved successfully'
});
}
);
+48
View File
@@ -0,0 +1,48 @@
import { sendWithNodemailer, sendWithZoho } from '$src/services/mail';
import { Hono } from 'hono';
import { ZSendEmailValidation } from '$src/types/mail';
import { env } from '$src/config/env';
import { zValidator } from '@hono/zod-validator';
export const mailRouter = new Hono().post(
'/send',
zValidator('json', ZSendEmailValidation),
async (c) => {
const validatedData = c.req.valid('json');
const results = await Promise.all(
validatedData.map(async (emailData) => {
try {
const res = env.ZOHO_TOKEN
? await sendWithZoho(emailData)
: await sendWithNodemailer(emailData);
console.log('Email status:', res);
return res;
} catch (error) {
console.error('Error sending email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
})
);
const hasErrors = results.some((result) => !result.success);
if (hasErrors) {
return c.json(
{
success: false,
error: 'Some emails failed to send',
details: results
},
500
);
}
return c.json({ success: true, details: results });
}
);
+9
View File
@@ -0,0 +1,9 @@
import { app } from './app';
import { hc } from 'hono/client';
import type { InferRequestType, InferResponseType } from 'hono/client';
export type Client = ReturnType<typeof hc<typeof app>>;
export const hcWithType = (...args: Parameters<typeof hc>): Client => hc<typeof app>(...args);
export type { InferRequestType, InferResponseType };
+97
View File
@@ -0,0 +1,97 @@
import { extractNameAndEmail, withEmailTemplate } from '$src/utils/mail';
import { nodemailerTransporter, zohoClient } from '$src/utils/email';
import { Hono } from 'hono';
import type { TSendEmailValidation } from '$src/types/mail';
import type { Transporter } from 'nodemailer';
import { env } from '$src/config/env';
export const mailRouter = new Hono();
interface EmailResponse {
success: boolean;
error?: string;
details?: unknown;
}
let transporter: Transporter | null;
nodemailerTransporter().then((t: Transporter | null) => {
transporter = t;
});
export async function sendWithNodemailer(
emailData: TSendEmailValidation[0]
): Promise<EmailResponse> {
const { from, to, subject, content, replyTo } = emailData;
if (!transporter) {
return {
success: false,
error: 'Email transporter not initialized'
};
}
try {
const result = await transporter.sendMail({
from: from || env.SMTP_SENDER,
to,
subject,
replyTo,
html: withEmailTemplate(content)
});
return {
success: true,
details: result
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
}
export async function sendWithZoho(emailData: TSendEmailValidation[0]): Promise<EmailResponse> {
const { from, to, subject, content } = emailData;
const fromData = extractNameAndEmail(from || '');
if (!fromData) {
return {
success: false,
error: 'Invalid from data'
};
}
try {
const result = await zohoClient.sendMail({
from: {
address: fromData.email,
name: fromData.name
},
to: [
{
email_address: {
address: to
}
}
],
subject,
htmlbody: content
});
return {
success: true,
details: result
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
}
+50
View File
@@ -0,0 +1,50 @@
import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '$src/constants/upload';
import { z } from 'zod';
export * from './lesson';
export const ZCourseDownloadContent = z.object({
courseTitle: z.string(),
orgName: z.string(),
orgTheme: z.string(),
lessons: z.array(
z.object({
lessonTitle: z.string(),
lessonNumber: z.string(),
lessonNote: z.string(),
slideUrl: z.string().optional(),
video: z.array(z.string()).optional()
})
)
});
export type TCourseDownloadContent = z.infer<typeof ZCourseDownloadContent>;
export const ZCoursePresignUrlUpload = z.object({
fileName: z.string().min(1, 'File name is required'),
fileType: z.enum(ALLOWED_CONTENT_TYPES, {
error: () => ({
message: `Invalid content type. Allowed types: ${ALLOWED_CONTENT_TYPES.join(', ')}`
})
})
});
export type TCoursePresignUrlUpload = z.infer<typeof ZCoursePresignUrlUpload>;
export const ZCourseDocumentPresignUrlUpload = z.object({
fileName: z.string().min(1, 'File name is required'),
fileType: z.enum(ALLOWED_DOCUMENT_TYPES, {
error: () => ({
message: `Invalid content type. Allowed types: ${ALLOWED_DOCUMENT_TYPES.join(', ')}`
})
})
});
export type TCourseDocumentPresignUrlUpload = z.infer<typeof ZCourseDocumentPresignUrlUpload>;
export const ZCourseDownloadPresignedUrl = z.object({
keys: z.array(z.string()).min(1, 'File names array is required')
});
export type TCourseDownloadPresignedUrl = z.infer<typeof ZCourseDownloadPresignedUrl>;
+25
View File
@@ -0,0 +1,25 @@
import { z } from 'zod';
export const ZLessonDownloadContent = z.object({
title: z.string(),
number: z.string(),
orgName: z.string(),
note: z.string(),
slideUrl: z.string().optional(),
video: z.array(z.string()).optional(),
courseTitle: z.string()
});
export type TLessonDownloadContent = z.infer<typeof ZLessonDownloadContent>;
export const ZCertificateDownload = z.object({
theme: z.string().min(1),
studentName: z.string().min(1),
courseName: z.string().min(1),
courseDescription: z.string().min(1),
orgName: z.string().min(1),
orgLogoUrl: z.string().url().optional(),
facilitator: z.string().optional()
});
export type TCertificateDownload = z.infer<typeof ZCertificateDownload>;
+13
View File
@@ -0,0 +1,13 @@
import { z } from 'zod';
export const ZSendEmailValidation = z.array(
z.object({
from: z.string().optional(),
to: z.string().email(),
subject: z.string().min(1),
content: z.string().min(1),
replyTo: z.string().email().optional()
})
);
export type TSendEmailValidation = z.infer<typeof ZSendEmailValidation>;
+21
View File
@@ -0,0 +1,21 @@
import { User } from '@supabase/supabase-js';
import { getSupabase } from '../supabase';
const supabase = getSupabase();
export async function validateUser(accessToken: string): Promise<User> {
let user: User | null = null;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
throw new Error('Unauthenticated user');
}
return user;
}
+124
View File
@@ -0,0 +1,124 @@
import type { TCertificateDownload } from '$src/types/course/lesson';
import { getCloudflarePdfBuffer } from '$src/utils/cloudflare';
export interface CertificateData {
theme: string;
studentName: string;
courseName: string;
courseDescription: string;
orgLogoUrl: string;
orgName: string;
facilitator?: string;
}
export interface CertificateGenerationResult {
success: boolean;
buffer?: Buffer;
error?: string;
}
export const generateCertificate = async (data: TCertificateDownload) => {
const { html, styles } = generateCertificateHtml(data);
return getCloudflarePdfBuffer(html, styles);
};
function generateCertificateHtml(data: TCertificateDownload): { html: string; styles: string } {
const { studentName, courseName, courseDescription, orgLogoUrl, orgName, facilitator } = data;
const html = `
<html>
<head>
<meta charset="utf-8">
<title>Certificate of Completion</title>
</head>
<body>
<div class="certificate">
<div class="header">
<img src="${orgLogoUrl}" alt="${orgName} Logo" class="logo">
<h1 class="title">Certificate of Completion</h1>
</div>
<div class="content">
<h2 class="student-name">${studentName}</h2>
<p class="course-name">${courseName}</p>
<p class="description">${courseDescription}</p>
</div>
<div class="footer">
<div class="signature">
<p>${facilitator || 'Course Facilitator'}</p>
<p>${orgName}</p>
</div>
<p class="date">${new Date().toLocaleDateString()}</p>
</div>
</div>
</body>
</html>
`;
const styles = `
body {
font-family: 'Arial', sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.certificate {
width: 1123px;
height: 794px;
margin: 0 auto;
background-color: white;
position: relative;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
.header {
text-align: center;
padding: 20px;
}
.logo {
max-width: 200px;
margin-bottom: 20px;
}
.title {
font-size: 36px;
color: #333;
margin-bottom: 10px;
}
.content {
text-align: center;
padding: 40px;
}
.student-name {
font-size: 48px;
color: #2c3e50;
margin-bottom: 20px;
}
.course-name {
font-size: 24px;
color: #34495e;
margin-bottom: 10px;
}
.description {
font-size: 18px;
color: #7f8c8d;
margin-bottom: 30px;
}
.footer {
position: absolute;
bottom: 40px;
width: 100%;
text-align: center;
}
.signature {
margin-top: 20px;
}
.date {
margin-top: 10px;
color: #95a5a6;
}
`;
const pdf = {
html: html,
styles: styles
};
return pdf;
}
+28
View File
@@ -0,0 +1,28 @@
import { CLOUDFLARE } from '$src/constants';
export const getCloudflarePdfBuffer = async (html: string, styles?: string) => {
console.log('Generating PDF with Cloudflare API...');
try {
const pdfResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE.CONFIGS.ACCOUNT_ID}/browser-rendering/pdf`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${CLOUDFLARE.CONFIGS.RENDERING_API_KEY}`
},
body: JSON.stringify({
html: html,
addStyleTag: [{ content: `${styles}` }]
})
}
);
console.log('PDF response status:', pdfResponse.status);
const arrayBuffer = await pdfResponse.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
console.error('Error generating PDF:', error);
throw new Error(error instanceof Error ? error.message : 'Failed to generate PDF');
}
};
+171
View File
@@ -0,0 +1,171 @@
import type { TCourseDownloadContent } from '$src/types/course';
import { getCloudflarePdfBuffer } from '$src/utils/cloudflare';
import { marked } from 'marked';
function getHtmlTemplate(body: string): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<title>Lesson Note</title>
<style>
html {
-webkit-print-color-adjust: exact;
}
body {
font-family: 'Roboto', sans-serif;
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 1.5rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
.roboto-mono {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full break-after-page">
${body}
</body>
</html>
`;
}
type TLessonBody = TCourseDownloadContent['lessons'][0];
function getLessonBody(
{ lessonTitle, lessonNumber, lessonNote, slideUrl, video }: TLessonBody,
orgName: string,
orgTheme: string
): string {
const noteHtml = marked.parse(lessonNote);
const showExtraResources = slideUrl || (video && video.length > 0);
return `
<div
class="bg-[${orgTheme ? orgTheme : '#0030FF'}] w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-[${orgTheme ? orgTheme : '#0030FF'}] bg-white text-md font-bold tracking-tighter">
${lessonNumber}
</h3>
<h1 class="text-white m-0">${lessonTitle}</h1>
<h4 class="roboto-mono text-md font-normal text-white m-0">By ${orgName}</h4>
</div>
<main class="break-after-page">
<section>
${noteHtml}
</section>
<section>
${
showExtraResources
? `
<section>
<h1 class='text-xl font-bold my-1'>Extra Resources</h1>
${
slideUrl
? `<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=${slideUrl} style="color: blue" class="underline">Open Here</a>
</div>`
: ''
}
${
video && video.length > 0
? `
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
${video
.map(
(videoData, index) => `
<li key=${
index + 1
}><a href=${videoData} style="color: blue" class="underline">${videoData}</a></li>
`
)
.join('')}
</ol>
</div>
`
: ''
}
</section>
`
: ''
}
</section>
</main>
`;
}
function getCourseBody({
courseTitle,
orgName,
orgTheme,
lessons
}: TCourseDownloadContent): string {
return `
<header class="flex justify-center items-center flex-col border-[${orgTheme ? orgTheme : '#0030FF'}] border-[40px] mx-3 h-[100vh] m-0">
<h1 class="font-bold text-8xl text-black m-0 mx-96">${courseTitle}</h1>
<h4 class="text-md text-2xl font-light text-black mt-5 ">${orgName}</h4>
</header>
<div class="root mx-auto mt-5 prose break-after-page">
${lessons.map((lesson) => getLessonBody(lesson, orgName, orgTheme)).join('')}
</div>
`;
}
async function generateSinglePdfFromHtml(
htmlContent: string,
courseTitle: string,
orgTheme: string
): Promise<Buffer> {
return await getCloudflarePdfBuffer(htmlContent);
}
export async function generateCoursePdf(params: TCourseDownloadContent): Promise<Buffer> {
const body = getCourseBody(params);
const html = getHtmlTemplate(body);
console.log(html);
return await generateSinglePdfFromHtml(html, params.courseTitle, params.orgTheme);
}
+35
View File
@@ -0,0 +1,35 @@
import { SendMailClient } from 'zeptomail';
import type { Transporter } from 'nodemailer';
import { env } from '$src/config/env';
import nodemailer from 'nodemailer';
export const zohoClient = new SendMailClient({
url: 'api.zeptomail.eu/',
token: env.ZOHO_TOKEN!
});
export const nodemailerTransporter = async (): Promise<Transporter | null> => {
if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASSWORD) {
console.error('SMTP configuration missing');
return null;
}
try {
const transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: parseInt(env.SMTP_PORT || '465', 10),
secure: true,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD
}
});
await transporter.verify();
return transporter;
} catch (error) {
console.error('Transporter error:', error);
return null;
}
};
+3
View File
@@ -0,0 +1,3 @@
export default function genUniqueId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
+161
View File
@@ -0,0 +1,161 @@
import type { TLessonDownloadContent } from '$src/types/course/lesson';
import { marked } from 'marked';
function getHtmlTemplate(body: string): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<title>Lesson Note</title>
<style>
html {
-webkit-print-color-adjust: exact;
}
header {
background-color: rgb(0, 48, 255);
}
body {
font-family: 'Roboto', sans-serif;
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 1.5rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
.roboto-mono {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full">
${body}
</body>
</html>
`;
}
function getLessonBody({
title,
number,
orgName,
note,
slideUrl,
video,
courseTitle
}: TLessonDownloadContent): string {
const noteHtml = marked.parse(note);
const showExtraResources = slideUrl || (video && video.length > 0);
return `
<div class="root mx-auto mt-5 prose">
<header
class="w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-blue-700 bg-white text-md font-bold tracking-tighter">
${number}
</h3>
<h1 class="text-white m-0">${title}</h1>
<h4 class="roboto-mono text-md font-normal text-white m-0">By ${orgName}</h4>
</header>
<main>
<section>
${noteHtml}
</section>
<section>
${
showExtraResources
? `
<section>
<h1 class='text-xl font-bold my-1'>Extra Resources</h1>
${
slideUrl
? `<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=${slideUrl} style="color: blue" class="underline">Open Here</a>
</div>`
: ''
}
${
video && video.length > 0
? `
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
${video
.map(
(videoData, index) => `
<li key=${
index + 1
}><a href=${videoData} style="color: blue" class="underline">${videoData}</a></li>
`
)
.join('')}
</ol>
</div>
`
: ''
}
</section>
`
: ''
}
</section>
</main>
</div>
`;
}
async function generateSinglePdfFromHtml(
htmlContent: string,
courseTitle: string
): Promise<Buffer> {
throw 'Download disabled';
}
export async function generateLessonPdf(params: TLessonDownloadContent): Promise<Buffer> {
const body = getLessonBody(params);
const html = getHtmlTemplate(body);
console.log(html);
return await generateSinglePdfFromHtml(html, params.courseTitle);
}
+213
View File
@@ -0,0 +1,213 @@
interface FromData {
name: string;
email: string;
}
// format: "ClassroomIO Developers (via ClassroomIO.com)" <notify@mail.classroomio.com>
export function extractNameAndEmail(str: string): FromData | undefined {
// Use regular expressions to match the name and email
const regex = /"(.*?)"\s+<\s*(.*?)@(.*?)\s*>/;
const match = str.match(regex);
if (match) {
// Extract the name and email from the match groups
const name = match[1];
const email = match[2] + '@' + match[3];
return { name, email };
} else {
// Return undefined if the format doesn't match
return { name: str, email: str };
}
}
export const withEmailTemplate = (content: string): string =>
`<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<base target="_blank" />
<style>
body {
background-color: #F6F9FF;
font-family: "Poppins", "Helvetica Neue", "Segoe UI", Helvetica,
sans-serif;
font-size: 15px;
font-weight: 400;
line-height: 26px;
margin: 0;
}
pre {
background: #f4f4f4;
padding: 2px;
}
hr {
border-top: 1px dashed #94a3b8;
margin-top: 1rem;
margin-bottom: 1rem;
}
table {
width: 100%;
}
table td {
padding: 5px 10px 0px;
vertical-align: middle;
}
.socialicons {
background-color: #fff;
border-radius: 5px;
padding: 5px;
width: initial;
margin: 0 auto;
max-width: 200px;
}
.brandcolor {
color: #00c4b8;
}
.tooltip {
background-color: #f1f5f9;
padding: 1rem;
border-radius: 1rem;
color: #475569;
margin-top: 15px;
margin-bottom: 15px;
}
.tooltip a {
color: #1e293b;
}
.button {
margin-top: 12px;
background: #0233BD;
border-radius: 8px;
text-decoration: none !important;
color: #fff !important;
font-weight: 500;
padding: 10px 30px;
display: inline-block;
font-size: 0.9em;
}
.button:hover {
background: #0233BD;
}
.footer {
text-align: center;
font-size: 12px;
color: #cbd5e1;
}
.footer a {
color: #cbd5e1;
}
.gutter {
padding: 5px 30px;
text-align: center;
background-color: #fff;
border-bottom: 1px solid rgb(221, 208, 208);
max-width: 525px;
margin: 0 auto;
}
img {
max-width: 100%;
height: auto;
}
.gutter img {
max-width: 280px;
}
h1,
h2,
h3,
h4 {
font-weight: 600;
}
@media screen and (max-width: 600px) {
.wrap {
max-width: auto;
}
.gutter {
padding: 10px;
}
}
</style>
</head>
<body style="
background-color: #F6F9FF;
font-family: 'Poppins', 'Helvetica Neue', 'Segoe UI', Helvetica,
sans-serif;
font-size: 15px;
line-height: 26px;
padding: 20px 0;
margin: 0;
color: #1e293b;
">
<div class="gutter">
<a href="https://classroomio.com" target="_blank">
<img src="https://brand.cdn.clsrio.com/cio-bg-transparent.png" alt="ClassroomIO Logo" /></a>
</div>
<div class="wrap" style="
background-color: #fff;
padding: 40px 30px;
max-width: 525px;
margin: 0 auto;
">
${content}
</div>
<div class="footer" style="
text-align: center;
font-size: 12px;
color: #fff;
padding: 30px;
background-color: #0542CC;
max-width: 525px;
margin: 0 auto;">
<p>Find us on</p>
<table class="socialicons">
<tr>
<td>
<a target="_blank" href="https://twitter.com/classroomio"><img title="X"
src="https://brand.cdn.clsrio.com/socials/twitter.png" alt="X" width="19" /></a>
</td>
<td>
<a target="_blank" href="https://www.youtube.com/@ClassroomIO"><img title="Youtube"
src="https://brand.cdn.clsrio.com/socials/youtube.png" alt="Youtube" width="24" /></a>
</td>
<td>
<a target="_blank" href="https://www.facebook.com/classroomiohq"><img title="Facebook"
src="https://brand.cdn.clsrio.com/socials/facebook.png" alt="Facebook" width="24" /></a>
</td>
</tr>
</table>
<p style="padding-top: 8px; line-height: initial">
ClassroomIO ${new Date().getFullYear()}. All rights reserved.<br />
<a style="text-decoration: none" href="https://app.enzuzo.com/policies/tos/958fc978-5477-11ee-a03b-7b111830c594"
target="_blank">Terms</a>
|
<a style="text-decoration: none"
href="https://app.enzuzo.com/policies/privacy/958fc978-5477-11ee-a03b-7b111830c594" target="_blank">Privacy
Policy</a>
</p>
</div>
</body>
</html>
`;
+9
View File
@@ -0,0 +1,9 @@
import { Hono } from 'hono';
import { Scalar } from '@scalar/hono-api-reference';
import { env } from '$src/config/env';
export function configureOpenAPI(app: Hono) {
if (env.OPENAPI_URL) {
app.get('/docs', Scalar({ url: env.OPENAPI_URL, theme: 'none' }));
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Key generation utilities for rate limiting
* These functions help create consistent and secure rate limit keys
*/
import type { Context } from 'hono';
import { supabase } from '$src/utils/supabase';
/**
* Extract user ID from JWT token
* In a real implementation, you'd decode and verify the JWT
*/
export const extractUserIdFromToken = async (token: string): Promise<string | null> => {
try {
const { data } = await supabase.auth.getUser(token);
return data.user?.id || null;
} catch {
return null;
}
};
/**
* Extract real client IP address considering various proxy headers
*/
export const extractClientIp = (c: Context): string => {
// Priority order (most trusted first):
// 1. CF-Connecting-IP (Cloudflare)
// 2. X-Real-IP (nginx)
// 3. X-Forwarded-For (standard, but can be spoofed)
// 4. Connection IP (least reliable)
const cfConnectingIp = c.req.header('cf-connecting-ip');
const realIp = c.req.header('x-real-ip');
const forwardedFor = c.req.header('x-forwarded-for');
if (cfConnectingIp) {
return cfConnectingIp;
}
if (realIp) {
return realIp;
}
if (forwardedFor) {
// x-forwarded-for can contain multiple IPs: "client, proxy1, proxy2"
// The first IP is usually the original client
return forwardedFor.split(',')[0].trim();
}
// Fallback - this might be a proxy IP
return 'unknown';
};
/**
* Generate rate limit key for authenticated users
*/
export const userKeyGenerator = (c: Context): string => {
const authHeader = c.req.header('authorization');
if (authHeader) {
const token = authHeader.split(' ')[1];
if (token && token !== 'null' && token !== 'undefined') {
const userId = extractUserIdFromToken(token);
if (userId) {
return `user:${userId}`;
}
// Fallback to token if user ID extraction fails
return `token:${token}`;
}
}
// No valid auth, fall back to IP
return `ip:${extractClientIp(c)}`;
};
/**
* Generate rate limit key for API keys
*/
export const apiKeyGenerator = (c: Context): string => {
const apiKey = c.req.header('X-API-Key');
if (apiKey) {
return `api:${apiKey}`;
}
// Fallback to IP if no API key
return `ip:${extractClientIp(c)}`;
};
/**
* Generate rate limit key based on IP only
*/
export const ipKeyGenerator = (c: Context): string => {
return `ip:${extractClientIp(c)}`;
};
/**
* Generate rate limit key for specific endpoints
*/
export const endpointKeyGenerator =
(endpoint: string) =>
(c: Context): string => {
const baseKey = userKeyGenerator(c);
return `${baseKey}:${endpoint}`;
};
+71
View File
@@ -0,0 +1,71 @@
import {
DEFAULT_MAX_REQUESTS,
DEFAULT_WINDOW_MS,
ERROR_MESSAGES,
RATE_LIMIT_KEY_PREFIX
} from '$src/constants/rate-limiter';
import { Redis } from 'ioredis';
import { redis } from './redis';
export class RedisRateLimiter {
private redis: Redis;
private windowMs: number;
private maxRequests: number;
constructor(
redis: Redis,
windowMs: number = DEFAULT_WINDOW_MS,
maxRequests: number = DEFAULT_MAX_REQUESTS
) {
this.redis = redis;
this.windowMs = windowMs;
this.maxRequests = maxRequests;
}
async isAllowed(
key: string
): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
const now = Date.now();
const windowStart = now - this.windowMs;
const keyWithPrefix = `${RATE_LIMIT_KEY_PREFIX}${key}`;
// Use Redis pipeline for atomic operations
const pipeline = this.redis.pipeline();
// Remove expired entries
pipeline.zremrangebyscore(keyWithPrefix, '-inf', windowStart);
// Count current requests in window
pipeline.zcard(keyWithPrefix);
// Add current request
pipeline.zadd(keyWithPrefix, now, `${now}-${Math.random()}`);
// Set expiration for the key
pipeline.expire(keyWithPrefix, Math.ceil(this.windowMs / 1000));
const results = await pipeline.exec();
if (!results || results.some((result) => result[0] !== null)) {
throw new Error(ERROR_MESSAGES.REDIS_PIPELINE_FAILED);
}
const currentCount = results[1][1] as number;
const remaining = Math.max(0, this.maxRequests - currentCount - 1);
const resetTime = now + this.windowMs;
return {
allowed: currentCount < this.maxRequests,
remaining,
resetTime
};
}
async reset(key: string): Promise<void> {
const keyWithPrefix = `${RATE_LIMIT_KEY_PREFIX}${key}`;
await this.redis.del(keyWithPrefix);
}
}
export const rateLimiter = new RedisRateLimiter(redis);
+4
View File
@@ -0,0 +1,4 @@
import { Redis } from 'ioredis';
import { env } from '$src/config/env';
export const redis = new Redis(env.REDIS_URL);
+56
View File
@@ -0,0 +1,56 @@
import {
DeleteObjectCommand,
DeleteObjectCommandInput,
GetObjectCommand,
GetObjectCommandInput,
PutObjectCommand,
PutObjectCommandInput,
S3Client
} from '@aws-sdk/client-s3';
import { CLOUDFLARE } from '$src/constants';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export type GetSignedUrlParameters = Parameters<typeof getSignedUrl>;
export const s3Client = new S3Client({
region: 'auto',
endpoint: `https://${CLOUDFLARE.CONFIGS.ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: CLOUDFLARE.CONFIGS.ACCESS_KEY,
secretAccessKey: CLOUDFLARE.CONFIGS.SECRET_ACCESS_KEY
}
});
export async function uploadToS3(
params: PutObjectCommandInput
): Promise<{ success: boolean; error?: string }> {
try {
await s3Client.send(new PutObjectCommand(params));
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
export async function getFromS3(
params: GetObjectCommandInput
): Promise<{ success: boolean; data?: any; error?: string }> {
try {
const data = await s3Client.send(new GetObjectCommand(params));
return { success: true, data };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
export async function deleteFromS3(
params: DeleteObjectCommandInput
): Promise<{ success: boolean; error?: string }> {
try {
await s3Client.send(new DeleteObjectCommand(params));
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
+36
View File
@@ -0,0 +1,36 @@
import { SupabaseClient, createClient } from '@supabase/supabase-js';
import { env } from '$src/config/env';
const { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } = env;
export let supabase: SupabaseClient | null = null;
interface SupabaseConfig {
supabaseConfig: {
url: string;
anonKey: string;
};
}
export function getSupabase(config?: SupabaseConfig): SupabaseClient {
if (supabase) return supabase;
const finalConfig = config ?? {
supabaseConfig: {
url: PUBLIC_SUPABASE_URL,
anonKey: PUBLIC_SUPABASE_ANON_KEY
}
};
try {
supabase = createClient(finalConfig.supabaseConfig.url, finalConfig.supabaseConfig.anonKey, {
auth: { persistSession: false }
});
} catch (error) {
console.error('Error starting supabase', error);
throw error;
}
return supabase;
}
+24
View File
@@ -0,0 +1,24 @@
import { nanoid } from 'nanoid';
import path from 'path';
/**
* Safely extracts the file extension from a filename.
* Returns an empty string if no extension is found.
*/
export function getExtension(filename: string): string {
if (!filename || typeof filename !== 'string') return '';
const ext = path.extname(filename).split('.');
return ext.length > 1 ? ext[ext.length - 1] : '';
}
/**
* Generates a unique file key for storage, preserving the file extension.
* Throws an error if the filename is invalid or has no extension.
*/
export function generateFileKey(fileName: string): string {
const ext = getExtension(fileName);
if (!ext) {
throw new Error('Invalid file name or missing file extension');
}
return `${nanoid()}.${ext}`;
}