From 0194f8fe3b915523fbf9022f10e95f41878646e7 Mon Sep 17 00:00:00 2001 From: vonuyvicoo Date: Wed, 15 Oct 2025 15:28:52 +0800 Subject: [PATCH] feat: centralize middleware and improve routing from regex-based using TinyRouter --- .env.example | 15 -- functions/_shared/types.ts | 9 + functions/_shared/validation.ts | 69 ++++++ functions/helpers/context.ts | 18 ++ functions/helpers/wrappers.ts | 57 +++++ functions/index.ts | 291 +++++++----------------- functions/middleware/auth-middleware.ts | 65 ++++++ functions/middleware/with-middleware.ts | 44 ++++ package-lock.json | 32 +++ package.json | 3 + 10 files changed, 379 insertions(+), 224 deletions(-) delete mode 100644 .env.example create mode 100644 functions/_shared/validation.ts create mode 100644 functions/helpers/context.ts create mode 100644 functions/helpers/wrappers.ts create mode 100644 functions/middleware/auth-middleware.ts create mode 100644 functions/middleware/with-middleware.ts diff --git a/.env.example b/.env.example deleted file mode 100644 index d770447..0000000 --- a/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Auth.js Configuration -AUTH_SECRET=your-secret-here-min-32-chars-long - -# Google OAuth Configuration -GOOGLE_CLIENT_ID=your-google-client-id -GOOGLE_CLIENT_SECRET=your-google-client-secret - -# Facebook OAuth Configuration -FACEBOOK_CLIENT_ID=your-facebook-app-id -FACEBOOK_CLIENT_SECRET=your-facebook-app-secret - -# Email Encryption Configuration (Optional) -# Generate a strong 32+ character key for encrypting email addresses in the database -# If not provided, emails will be stored in plaintext (not recommended for production) -EMAIL_ENCRYPTION_KEY=your-email-encryption-key-min-32-chars-long \ No newline at end of file diff --git a/functions/_shared/types.ts b/functions/_shared/types.ts index 798cb5f..e865b40 100644 --- a/functions/_shared/types.ts +++ b/functions/_shared/types.ts @@ -1,5 +1,7 @@ /// +import type { AuthenticatedUser } from "./utils" + export interface Env { DB: D1Database CACHE: KVNamespace @@ -22,3 +24,10 @@ export interface EventContext { next(input?: Request | string, init?: RequestInit): Promise data: Record } +export type AuthResult = { + success: true + user: AuthenticatedUser + } | { + success: false + response: Response + } \ No newline at end of file diff --git a/functions/_shared/validation.ts b/functions/_shared/validation.ts new file mode 100644 index 0000000..5a3038a --- /dev/null +++ b/functions/_shared/validation.ts @@ -0,0 +1,69 @@ +// Centralized validation schemas +import { z } from 'zod' + +// Base schemas +export const IdParamSchema = z.string().regex(/^\d+$/, 'Invalid ID format').transform(Number) +export const SlugSchema = z.string().min(1, 'Slug is required').max(100, 'Slug too long') +export const PaginationSchema = z.object({ + limit: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().min(1).max(100)), + offset: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().min(0)) +}) + +// Petition schemas +export const CreatePetitionSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200, 'Title too long'), + description: z.string().min(10, 'Description must be at least 10 characters').max(5000, 'Description too long'), + type: z.enum(['local', 'national'], { errorMap: () => ({ message: 'Type must be "local" or "national"' }) }), + image_url: z.string().url().optional(), + target_count: z.number().min(1, 'Target count must be at least 1').max(1000000, 'Target count too high').optional(), + location: z.string().max(100, 'Location too long').optional(), + due_date: z.string().datetime().optional(), + category_ids: z.array(z.number()).max(5, 'Maximum 5 categories allowed').optional() +}) + +export const UpdatePetitionSchema = CreatePetitionSchema.partial() + +// Signature schemas +export const CreateSignatureSchema = z.object({ + petition_id: z.number().positive('Invalid petition ID'), + comment: z.string().max(1000, 'Comment too long').optional(), + anonymous: z.boolean().optional() +}) + +// Report schemas +export const CreateReportSchema = z.object({ + reported_item_type: z.enum(['petition', 'signature'], { + errorMap: () => ({ message: 'Reported item type must be "petition" or "signature"' }) + }), + reported_item_id: z.number().positive('Invalid reported item ID'), + report_reason: z.enum([ + 'spam', 'inappropriate_content', 'harassment', 'misinformation', + 'hate_speech', 'violence', 'copyright_violation', 'other' + ], { errorMap: () => ({ message: 'Invalid report reason' }) }), + report_description: z.string().max(1000, 'Description too long').optional() +}) + +export const UpdateReportSchema = z.object({ + status: z.enum(['pending', 'reviewed', 'resolved', 'dismissed']).optional(), + admin_notes: z.string().max(1000, 'Admin notes too long').optional() +}) + +// Category schemas +export const CreateCategorySchema = z.object({ + name: z.string().min(1, 'Category name is required').max(50, 'Category name too long'), + description: z.string().max(200, 'Description too long').optional() +}) + +// Query parameter schemas +export const PetitionQuerySchema = z.object({ + type: z.enum(['local', 'national']).optional(), + limit: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().min(1).max(100)).optional(), + offset: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().min(0)).optional(), + userId: z.string().optional() +}) + +export const ReportQuerySchema = z.object({ + status: z.enum(['pending', 'reviewed', 'resolved', 'dismissed']).optional(), + limit: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().min(1).max(100)).optional(), + offset: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().min(0)).optional() +}) \ No newline at end of file diff --git a/functions/helpers/context.ts b/functions/helpers/context.ts new file mode 100644 index 0000000..ff16815 --- /dev/null +++ b/functions/helpers/context.ts @@ -0,0 +1,18 @@ +import type { Env, EventContext } from '../_shared/types' + +export function createContext( + request: Request, + env: Env, + params: Record = {} +): EventContext { + return { + request, + env, + params, + waitUntil: () => {}, + next: async () => new Response('Not implemented', { status: 501 }), + data: {}, + } +} + + diff --git a/functions/helpers/wrappers.ts b/functions/helpers/wrappers.ts new file mode 100644 index 0000000..14c41b5 --- /dev/null +++ b/functions/helpers/wrappers.ts @@ -0,0 +1,57 @@ +import type { Env, EventContext } from '../_shared/types' +import { createContext } from './context' +import { requireAuth, requirePetitionOwnership } from '../middleware/auth-middleware' + +export function wrapHandler(handler: (context: EventContext) => Promise) { + return async (request: Request, env: Env) => { + const context = createContext(request, env, request.params || {}) + return await handler(context) + } +} + +export function wrapHandlerWithAuth(handler: (context: EventContext) => Promise) { + return async (request: Request, env: Env) => { + const context = createContext(request, env, request.params || {}) + const authResult = await requireAuth(context) + if (!authResult.success) { + return authResult.response + } + context.data.user = authResult.user + return await handler(context) + } +} + +export function wrapHandlerWithConditionalAuth(handler: (context: EventContext) => Promise) { + return async (request: Request, env: Env) => { + const url = new URL(request.url) + const userId = url.searchParams.get('userId') + + const context = createContext(request, env, request.params || {}) + + if (userId) { + const authResult = await requireAuth(context) + if (!authResult.success) { + return authResult.response + } + context.data.user = authResult.user + return await handler(context) + } + + return await handler(context) + } +} + +export function wrapHandlerWithOwnership(handler: (context: EventContext) => Promise) { + return async (request: Request, env: Env) => { + const petitionId = parseInt(request.params?.id || '0') + const context = createContext(request, env, request.params || {}) + const authResult = await requirePetitionOwnership(context, petitionId) + if (!authResult.success) { + return authResult.response + } + context.data.user = authResult.user + return await handler(context) + } +} + + diff --git a/functions/index.ts b/functions/index.ts index 62566c2..142cbb5 100644 --- a/functions/index.ts +++ b/functions/index.ts @@ -1,5 +1,21 @@ -// Main Cloudflare Worker entry point - routes to individual functions -import type { Env, EventContext } from './_shared/types' +// Main Cloudflare Worker entry point with itty-router +import { AutoRouter } from 'itty-router' +import { withParams } from 'itty-router-extras' +import type { Env } from './_shared/types' +import { createContext } from './helpers/context' +import { + wrapHandler, + wrapHandlerWithAuth, + wrapHandlerWithConditionalAuth, + wrapHandlerWithOwnership, +} from './helpers/wrappers' + +// Extend Request type to include params +declare global { + interface Request { + params?: Record + } +} // Import individual function handlers import { onRequest as uptimeHandler } from './api/uptime' @@ -17,49 +33,75 @@ import { onRequest as categoriesHandler } from './api/categories' import { onRequest as reportsHandler } from './api/reports' import { onRequest as adminReportsHandler } from './api/admin/reports' import { onRequest as authHandler } from './auth/[...auth]' + import { getCorsHeaders, handleCORS, - requireAuthentication, - requirePetitionOwnership, } from './_shared/utils' - -function createContext( - request: Request, - env: Env, - params: Record = {} -): EventContext { - return { - request, - env, - params, - waitUntil: () => {}, // Simplified for now - next: async () => new Response('Not implemented', { status: 501 }), - data: {}, - } -} - -function parsePathParams(path: string, pattern: string): Record { - const pathParts = path.split('/').filter(p => p) - const patternParts = pattern.split('/').filter(p => p) - const params: Record = {} - - for (let i = 0; i < patternParts.length; i++) { - const part = patternParts[i] - if (part.startsWith('[') && part.endsWith(']')) { - const paramName = part.slice(1, -1) - params[paramName] = pathParts[i] || '' - } - } - - return params -} +// auth middleware used inside helpers/wrappers + +// moved to ./helpers/context + +// Create router with params middleware +const router = AutoRouter() + +// Apply withParams middleware to all routes +router.all('*', withParams) + +// wrappers moved to ./helpers/wrappers + +// Auth routes - handle all /auth/* paths +router.all('/auth/*', async (request, env: Env) => { + const response = await authHandler(createContext(request, env)) + // Add CORS headers to auth responses + const corsHeaders = getCorsHeaders(request, env) + Object.entries(corsHeaders).forEach(([key, value]) => { + response.headers.set(key, value) + }) + return response +}) + +// Public routes (no authentication required) +router + .get('/api/uptime', wrapHandler(uptimeHandler)) + .get('/api/petitions', wrapHandlerWithConditionalAuth(petitionsHandler)) + .get('/api/petition/:slug', wrapHandler(petitionBySlugHandler)) + .get('/api/petitions/:id', wrapHandler(petitionByIdHandler)) + .get('/api/petitions/:id/signatures', wrapHandler(petitionSignaturesHandler)) + .get('/api/categories', wrapHandler(categoriesHandler)) + +// Protected routes (authentication required) +router + .post('/api/petitions', wrapHandlerWithAuth(petitionsHandler)) + .post('/api/signatures', wrapHandlerWithAuth(signaturesHandler)) + .post('/api/reports', wrapHandlerWithAuth(reportsHandler)) + .get('/api/users/:id', wrapHandlerWithAuth(userByIdHandler)) + .get('/api/users/:id/signatures', wrapHandlerWithAuth(userSignaturesHandler)) + .get('/api/users/:id/petitions', wrapHandlerWithAuth(userPetitionsHandler)) + +// Petition ownership routes (require petition ownership) +router + .put('/api/petitions/:id', wrapHandlerWithOwnership(petitionByIdHandler)) + .delete('/api/petitions/:id', wrapHandlerWithOwnership(petitionByIdHandler)) + .post('/api/petition/:id/publish', wrapHandlerWithOwnership(petitionPublishHandler)) + .post('/api/petition/:id/unpublish', wrapHandlerWithOwnership(petitionUnpublishHandler)) + +// Admin routes +router + .get('/api/admin/reports', wrapHandlerWithAuth(adminReportsHandler)) + .put('/api/admin/reports/:id', wrapHandlerWithAuth(adminReportsHandler)) + +// 404 handler +router.all('*', (request, env: Env) => { + const corsHeaders = getCorsHeaders(request, env) + return new Response('Not Found', { + status: 404, + headers: corsHeaders, + }) +}) export default { async fetch(request: Request, env: Env): Promise { - const url = new URL(request.url) - const path = url.pathname - // Handle preflight OPTIONS requests const corsResponse = handleCORS(request, env) if (corsResponse) { @@ -67,176 +109,7 @@ export default { } try { - // Route to appropriate function handler - - // Auth routes - handle all /auth/* paths - if (path.startsWith('/auth/')) { - const response = await authHandler(createContext(request, env)) - // Add CORS headers to auth responses - const corsHeaders = getCorsHeaders(request, env) - Object.entries(corsHeaders).forEach(([key, value]) => { - response.headers.set(key, value) - }) - return response - } - - if (path === '/api/uptime') { - return await uptimeHandler(createContext(request, env)) - } - - if (path.match(/^\/api\/users\/[^/]+\/signatures$/)) { - const params = parsePathParams(path, '/api/users/[id]/signatures') - // Require authentication for user signatures - const authResult = await requireAuthentication(request, env) - if (authResult instanceof Response) { - return authResult - } - const context = createContext(request, env, params) - context.data.user = authResult.user - return await userSignaturesHandler(context) - } - - if (path.match(/^\/api\/users\/[^/]+\/petitions$/)) { - const params = parsePathParams(path, '/api/users/[id]/petitions') - // Require authentication for user petitions - const authResult = await requireAuthentication(request, env) - if (authResult instanceof Response) { - return authResult - } - const context = createContext(request, env, params) - context.data.user = authResult.user - return await userPetitionsHandler(context) - } - - if (path.match(/^\/api\/users\/[^/]+$/)) { - const params = parsePathParams(path, '/api/users/[id]') - // Require authentication for user profiles - const authResult = await requireAuthentication(request, env) - if (authResult instanceof Response) { - return authResult - } - const context = createContext(request, env, params) - context.data.user = authResult.user - return await userByIdHandler(context) - } - - if (path === '/api/petitions') { - const url = new URL(request.url) - const userId = url.searchParams.get('userId') - - // Require authentication for POST (creating petitions) or GET with userId parameter - if (request.method === 'POST' || userId) { - const authResult = await requireAuthentication(request, env) - if (authResult instanceof Response) { - return authResult - } - // Add user to context for the handler - const context = createContext(request, env) - context.data.user = authResult.user - return await petitionsHandler(context) - } - return await petitionsHandler(createContext(request, env)) - } - - if (path.match(/^\/api\/petition\/[^/]+$/)) { - const params = parsePathParams(path, '/api/petition/[slug]') - return await petitionBySlugHandler(createContext(request, env, params)) - } - - if (path.match(/^\/api\/petition\/\d+\/publish$/)) { - const params = parsePathParams(path, '/api/petition/[id]/publish') - // Require petition ownership for publishing - const petitionId = parseInt(params.id) - const authResult = await requirePetitionOwnership(request, env, petitionId) - if (authResult instanceof Response) { - return authResult - } - const context = createContext(request, env, params) - context.data.user = authResult.user - return await petitionPublishHandler(context) - } - - if (path.match(/^\/api\/petition\/\d+\/unpublish$/)) { - const params = parsePathParams(path, '/api/petition/[id]/unpublish') - // Require petition ownership for unpublishing - const petitionId = parseInt(params.id) - const authResult = await requirePetitionOwnership(request, env, petitionId) - if (authResult instanceof Response) { - return authResult - } - const context = createContext(request, env, params) - context.data.user = authResult.user - return await petitionUnpublishHandler(context) - } - - if (path.match(/^\/api\/petitions\/\d+\/signatures$/)) { - const params = parsePathParams(path, '/api/petitions/[id]/signatures') - return await petitionSignaturesHandler(createContext(request, env, params)) - } - - if (path.match(/^\/api\/petitions\/\d+$/)) { - const params = parsePathParams(path, '/api/petitions/[id]') - return await petitionByIdHandler(createContext(request, env, params)) - } - - if (path === '/api/signatures') { - // Require authentication for POST (creating signatures) - if (request.method === 'POST') { - const authResult = await requireAuthentication(request, env) - if (authResult instanceof Response) { - return authResult - } - const context = createContext(request, env) - context.data.user = authResult.user - return await signaturesHandler(context) - } - return await signaturesHandler(createContext(request, env)) - } - - if (path === '/api/categories') { - return await categoriesHandler(createContext(request, env)) - } - - if (path === '/api/reports') { - // Require authentication for reporting - const authResult = await requireAuthentication(request, env) - if (authResult instanceof Response) { - return authResult - } - const context = createContext(request, env) - context.data.user = authResult.user - return await reportsHandler(context) - } - - if (path === '/api/admin/reports') { - // Require authentication for admin reports - const authResult = await requireAuthentication(request, env) - if (authResult instanceof Response) { - return authResult - } - const context = createContext(request, env) - context.data.user = authResult.user - return await adminReportsHandler(context) - } - - if (path.match(/^\/api\/admin\/reports\/\d+$/)) { - const params = parsePathParams(path, '/api/admin/reports/[id]') - // Require authentication for admin report updates - const authResult = await requireAuthentication(request, env) - if (authResult instanceof Response) { - return authResult - } - const context = createContext(request, env, params) - context.data.user = authResult.user - return await adminReportsHandler(context) - } - - // 404 for unmatched routes - const corsHeaders = getCorsHeaders(request, env) - return new Response('Not Found', { - status: 404, - headers: corsHeaders, - }) + return await router.fetch(request, env) } catch (error: unknown) { console.error('Router Error:', error) const message = error instanceof Error ? error.message : 'Unknown error' @@ -250,4 +123,4 @@ export default { }) } }, -} +} \ No newline at end of file diff --git a/functions/middleware/auth-middleware.ts b/functions/middleware/auth-middleware.ts new file mode 100644 index 0000000..69a31b6 --- /dev/null +++ b/functions/middleware/auth-middleware.ts @@ -0,0 +1,65 @@ +// functions/_shared/auth-middleware.ts +import { getAuthenticatedUser, createErrorResponseWithCors, getDbService } from '../_shared/utils' +import type { EventContext, Env } from '../_shared/types' +import type { AuthResult } from '../_shared/types' + + + +export async function requireAuth( + context: EventContext +): Promise { + const user = await getAuthenticatedUser(context.request, context.env) + + if (!user) { + return { + success: false, + response: createErrorResponseWithCors( + 'Authentication required', + context.request, + context.env, + 401 + ) + } + } + + return { success: true, user } +} + +export async function requirePetitionOwnership( + context: EventContext, + petitionId: number +): Promise { + const authResult = await requireAuth(context) + if (!authResult.success) { + return authResult + } + + const db = getDbService(context) + const petition = await db.getPetitionById(petitionId) + + if (!petition) { + return { + success: false, + response: createErrorResponseWithCors( + 'Petition not found', + context.request, + context.env, + 404 + ) + } + } + + if (petition.created_by !== authResult.user.id) { + return { + success: false, + response: createErrorResponseWithCors( + 'You can only modify petitions you created', + context.request, + context.env, + 403 + ) + } + } + + return authResult +} \ No newline at end of file diff --git a/functions/middleware/with-middleware.ts b/functions/middleware/with-middleware.ts new file mode 100644 index 0000000..7b12e77 --- /dev/null +++ b/functions/middleware/with-middleware.ts @@ -0,0 +1,44 @@ +import { requireAuth, requirePetitionOwnership } from './auth-middleware' +import type { EventContext, Env } from '../_shared/types' +import type { AuthenticatedUser } from '../_shared/utils' + +// Higher-order function to enforce authentication +export function withAuth>( + handler: (context: T & { data: { user: AuthenticatedUser } }) => Promise +) { + return async (context: T): Promise => { + const authResult = await requireAuth(context) + if (!authResult.success) return authResult.response + + return handler({ + ...context, + data: { ...context.data, user: authResult.user } + }) + } +} + +// Higher-order function to enforce petition ownership +export function withPetitionOwnership>( + petitionIdExtractor: (context: T) => number, + handler: (context: T & { data: { user: AuthenticatedUser } }) => Promise +) { + return async (context: T): Promise => { + const petitionId = petitionIdExtractor(context) + const authResult = await requirePetitionOwnership(context, petitionId) + if (!authResult.success) return authResult.response + + return handler({ + ...context, + data: { ...context.data, user: authResult.user } + }) + } +} + +// Higher-order function for public endpoints (no auth required) +export function withPublic>( + handler: (context: T) => Promise +) { + return async (context: T): Promise => { + return handler(context) + } +} diff --git a/package-lock.json b/package-lock.json index f0e3b90..73e179c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,8 @@ "i18next": "^25.5.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", + "itty-router": "^5.0.22", + "itty-router-extras": "^0.4.6", "lucide-react": "^0.544.0", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -34,6 +36,7 @@ "@cloudflare/workers-types": "^4.20250924.0", "@eslint/js": "^9.36.0", "@playwright/test": "^1.55.1", + "@types/itty-router-extras": "^0.4.3", "@types/node": "^24.5.2", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", @@ -2428,6 +2431,23 @@ "@types/unist": "*" } }, + "node_modules/@types/itty-router-extras": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@types/itty-router-extras/-/itty-router-extras-0.4.3.tgz", + "integrity": "sha512-mBLJnci8IQRqWCURrBW45XVtXtwuTig0DWKBZQlLB36yOZoxkNDf8pSsHW/gOFqyqtO21zcSfgTjrA6/bKJwEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "itty-router": "^2.3.10" + } + }, + "node_modules/@types/itty-router-extras/node_modules/itty-router": { + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/itty-router/-/itty-router-2.6.6.tgz", + "integrity": "sha512-hIPHtXGymCX7Lzb2I4G6JgZFE4QEEQwst9GORK7sMYUpJvLfy4yZJr95r04e8DzoAnj6HcxM2m4TbK+juu+18g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -6208,6 +6228,18 @@ "node": ">= 0.4" } }, + "node_modules/itty-router": { + "version": "5.0.22", + "resolved": "https://registry.npmjs.org/itty-router/-/itty-router-5.0.22.tgz", + "integrity": "sha512-9hmdGErWdYDOurGYxSbqLhy4EFReIwk71hMZTJ5b+zfa2zjMNV1ftFno2b8VjAQvX615gNB8Qxbl9JMRqHnIVA==", + "license": "MIT" + }, + "node_modules/itty-router-extras": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/itty-router-extras/-/itty-router-extras-0.4.6.tgz", + "integrity": "sha512-6r7HQBkFMPSJfcKksrKC7avEQnPCSSEvoz6PAAZMNhz8hthYu1pzedXDrvTFDWXJosfuaittzoNciWHO/TxMaw==", + "license": "MIT" + }, "node_modules/jiti": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", diff --git a/package.json b/package.json index ed88281..9169630 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "i18next": "^25.5.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", + "itty-router": "^5.0.22", + "itty-router-extras": "^0.4.6", "lucide-react": "^0.544.0", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -51,6 +53,7 @@ "@cloudflare/workers-types": "^4.20250924.0", "@eslint/js": "^9.36.0", "@playwright/test": "^1.55.1", + "@types/itty-router-extras": "^0.4.3", "@types/node": "^24.5.2", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9",