From aa93c54391affd9493e1f32377e45854952abe97 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 2 Apr 2026 21:12:15 +0200 Subject: [PATCH] feat(api): create unified API server with first 3 modules New consolidated Hono/Bun API server at apps/api/ that replaces individual app servers. One process, one port, one auth middleware, one container. Modules ported: - calendar: RRULE expansion, ICS import, Google Calendar (stub) - contacts: avatar upload (S3), vCard import/parsing - mukke: audio upload/download presigned URLs, batch cover art Architecture: each module registers routes under /api/v1/{module}/* using the shared-hono middleware stack (auth, rate limit, error handler). Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/package.json | 22 +++++ apps/api/src/index.ts | 44 ++++++++++ apps/api/src/modules/calendar/routes.ts | 110 ++++++++++++++++++++++++ apps/api/src/modules/contacts/routes.ts | 87 +++++++++++++++++++ apps/api/src/modules/mukke/routes.ts | 91 ++++++++++++++++++++ apps/api/tsconfig.json | 14 +++ 6 files changed, 368 insertions(+) create mode 100644 apps/api/package.json create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/modules/calendar/routes.ts create mode 100644 apps/api/src/modules/contacts/routes.ts create mode 100644 apps/api/src/modules/mukke/routes.ts create mode 100644 apps/api/tsconfig.json diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 000000000..68db80757 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,22 @@ +{ + "name": "@manacore/api", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --hot run src/index.ts", + "build": "bun build src/index.ts --outdir dist --target bun", + "start": "bun run dist/index.js", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@manacore/shared-hono": "workspace:*", + "@manacore/shared-storage": "workspace:*", + "hono": "^4.7.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.8.0" + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 000000000..c5982b34e --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,44 @@ +/** + * ManaCore Unified API Server + * + * Consolidates all app compute servers into one Hono/Bun process. + * Each module registers its routes under /api/v1/{module}/*. + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { + authMiddleware, + healthRoute, + errorHandler, + notFoundHandler, + rateLimitMiddleware, +} from '@manacore/shared-hono'; + +// Module routes +import { calendarRoutes } from './modules/calendar/routes'; +import { contactsRoutes } from './modules/contacts/routes'; +import { mukkeRoutes } from './modules/mukke/routes'; + +const PORT = parseInt(process.env.PORT || '3050', 10); +const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','); + +const app = new Hono(); + +// ─── Global Middleware ────────────────────────────────────── +app.onError(errorHandler); +app.notFound(notFoundHandler); +app.use('*', cors({ origin: CORS_ORIGINS, credentials: true })); +app.route('/health', healthRoute('mana-api')); +app.use('/api/*', rateLimitMiddleware({ max: 200, windowMs: 60_000 })); +app.use('/api/*', authMiddleware()); + +// ─── Module Routes ────────────────────────────────────────── +app.route('/api/v1/calendar', calendarRoutes); +app.route('/api/v1/contacts', contactsRoutes); +app.route('/api/v1/mukke', mukkeRoutes); + +// ─── Server Info ──────────────────────────────────────────── +console.log(`mana-api starting on port ${PORT}...`); + +export default { port: PORT, fetch: app.fetch }; diff --git a/apps/api/src/modules/calendar/routes.ts b/apps/api/src/modules/calendar/routes.ts new file mode 100644 index 000000000..584d5b35e --- /dev/null +++ b/apps/api/src/modules/calendar/routes.ts @@ -0,0 +1,110 @@ +/** + * Calendar module — RRULE expansion + ICS import + * Ported from apps/calendar/apps/server + */ + +import { Hono } from 'hono'; +import { z } from 'zod'; + +const routes = new Hono(); + +// ─── RRULE Expansion (server-only: DoS protection) ────────── + +const ExpandSchema = z.object({ + rrule: z.string().min(1).max(500), + dtstart: z.string().min(1), + until: z.string().optional(), + maxOccurrences: z.number().int().min(1).max(5000).optional(), +}); + +routes.post('/events/expand', async (c) => { + const parsed = ExpandSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }, 400); + } + + const { rrule, dtstart, until, maxOccurrences } = parsed.data; + const max = Math.min(maxOccurrences || 365, 5000); + + try { + const start = new Date(dtstart); + const end = until ? new Date(until) : new Date(start.getTime() + 365 * 24 * 60 * 60 * 1000); + const occurrences: string[] = []; + + const parts = rrule.replace('RRULE:', '').split(';'); + const freq = parts.find((p: string) => p.startsWith('FREQ='))?.split('=')[1]; + const interval = parseInt( + parts.find((p: string) => p.startsWith('INTERVAL='))?.split('=')[1] || '1', + 10 + ); + + let current = new Date(start); + + while (current <= end && occurrences.length < max) { + occurrences.push(current.toISOString()); + switch (freq) { + case 'DAILY': + current = new Date(current.getTime() + interval * 24 * 60 * 60 * 1000); + break; + case 'WEEKLY': + current = new Date(current.getTime() + interval * 7 * 24 * 60 * 60 * 1000); + break; + case 'MONTHLY': + current = new Date(current.setMonth(current.getMonth() + interval)); + break; + case 'YEARLY': + current = new Date(current.setFullYear(current.getFullYear() + interval)); + break; + default: + current = end; + } + } + + return c.json({ occurrences, count: occurrences.length }); + } catch { + return c.json({ error: 'RRULE expansion failed' }, 500); + } +}); + +// ─── Google Calendar Import ───────────────────────────────── + +routes.post('/sync/google', async (c) => { + return c.json({ error: 'Google Calendar sync not yet implemented' }, 501); +}); + +// ─── ICS Import ───────────────────────────────────────────── + +routes.post('/import/ics', async (c) => { + const formData = await c.req.formData(); + const file = formData.get('file') as File | null; + if (!file) return c.json({ error: 'No file' }, 400); + + const text = await file.text(); + const events = parseICS(text); + return c.json({ events, count: events.length }); +}); + +function parseICS(text: string): Array> { + const events: Array> = []; + const blocks = text.split('BEGIN:VEVENT').filter((b) => b.includes('END:VEVENT')); + + for (const block of blocks) { + const event: Record = {}; + const lines = block.split(/\r?\n/); + + for (const line of lines) { + if (line.startsWith('SUMMARY:')) event.title = line.slice(8); + if (line.startsWith('DTSTART')) event.start = line.split(':').pop() || ''; + if (line.startsWith('DTEND')) event.end = line.split(':').pop() || ''; + if (line.startsWith('DESCRIPTION:')) event.description = line.slice(12); + if (line.startsWith('LOCATION:')) event.location = line.slice(9); + if (line.startsWith('RRULE:')) event.rrule = line.slice(6); + } + + if (event.title || event.start) events.push(event); + } + + return events; +} + +export { routes as calendarRoutes }; diff --git a/apps/api/src/modules/contacts/routes.ts b/apps/api/src/modules/contacts/routes.ts new file mode 100644 index 000000000..41733cf1d --- /dev/null +++ b/apps/api/src/modules/contacts/routes.ts @@ -0,0 +1,87 @@ +/** + * Contacts module — Avatar upload + vCard import + * Ported from apps/contacts/apps/server + */ + +import { Hono } from 'hono'; + +const ALLOWED_AVATAR_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml', +]); + +const routes = new Hono(); + +// ─── Avatar Upload (S3) ───────────────────────────────────── + +routes.post('/:id/avatar', async (c) => { + const userId = c.get('userId'); + const formData = await c.req.formData(); + const file = formData.get('file') as File | null; + + if (!file) return c.json({ error: 'No file' }, 400); + if (file.size > 5 * 1024 * 1024) return c.json({ error: 'Max 5MB' }, 400); + if (!ALLOWED_AVATAR_TYPES.has(file.type)) { + return c.json({ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, SVG' }, 400); + } + + try { + const { createContactsStorage, generateUserFileKey, getContentType } = await import( + '@manacore/shared-storage' + ); + const storage = createContactsStorage(); + const key = generateUserFileKey( + userId, + `avatar-${c.req.param('id')}.${file.name.split('.').pop()}` + ); + const buffer = Buffer.from(await file.arrayBuffer()); + + const result = await storage.upload(key, buffer, { + contentType: getContentType(file.name), + public: true, + }); + + return c.json({ avatarUrl: result.url }, 201); + } catch { + return c.json({ error: 'Upload failed' }, 500); + } +}); + +// ─── vCard Import ─────────────────────────────────────────── + +routes.post('/import/vcard', async (c) => { + const formData = await c.req.formData(); + const file = formData.get('file') as File | null; + if (!file) return c.json({ error: 'No file' }, 400); + + const text = await file.text(); + const contacts = parseVCard(text); + return c.json({ contacts, count: contacts.length }); +}); + +function parseVCard(text: string): Array> { + const contacts: Array> = []; + const cards = text.split('BEGIN:VCARD').filter((c) => c.includes('END:VCARD')); + + for (const card of cards) { + const contact: Record = {}; + const lines = card.split(/\r?\n/); + + for (const line of lines) { + if (line.startsWith('FN:')) contact.name = line.slice(3); + if (line.startsWith('EMAIL')) contact.email = line.split(':').pop() || ''; + if (line.startsWith('TEL')) contact.phone = line.split(':').pop() || ''; + if (line.startsWith('ORG:')) contact.company = line.slice(4); + if (line.startsWith('TITLE:')) contact.title = line.slice(6); + } + + if (contact.name || contact.email) contacts.push(contact); + } + + return contacts; +} + +export { routes as contactsRoutes }; diff --git a/apps/api/src/modules/mukke/routes.ts b/apps/api/src/modules/mukke/routes.ts new file mode 100644 index 000000000..50b57bfdf --- /dev/null +++ b/apps/api/src/modules/mukke/routes.ts @@ -0,0 +1,91 @@ +/** + * Mukke module — Audio upload, presigned URLs, cover art + * Ported from apps/mukke/apps/server + */ + +import { Hono } from 'hono'; + +const routes = new Hono(); + +// ─── Song Upload (presigned URL) ──────────────────────────── + +routes.post('/songs/upload', async (c) => { + const userId = c.get('userId'); + const { filename } = await c.req.json(); + if (!filename) return c.json({ error: 'filename required' }, 400); + + const songId = crypto.randomUUID(); + const key = `users/${userId}/songs/${songId}/${filename}`; + + try { + const { createMukkeStorage } = await import('@manacore/shared-storage'); + const storage = createMukkeStorage(); + const uploadUrl = await storage.getUploadUrl(key, { expiresIn: 3600 }); + + return c.json({ + song: { id: songId, title: filename.replace(/\.[^/.]+$/, ''), storagePath: key }, + uploadUrl, + }); + } catch { + return c.json({ error: 'Failed to generate upload URL' }, 500); + } +}); + +// ─── Download URL ─────────────────────────────────────────── + +routes.get('/songs/:id/download-url', async (c) => { + const { storagePath } = c.req.query(); + if (!storagePath) return c.json({ error: 'storagePath required' }, 400); + + try { + const { createMukkeStorage } = await import('@manacore/shared-storage'); + const storage = createMukkeStorage(); + const url = await storage.getDownloadUrl(storagePath, { expiresIn: 3600 }); + return c.json({ url }); + } catch { + return c.json({ error: 'Failed to generate download URL' }, 500); + } +}); + +// ─── Cover Art URL ────────────────────────────────────────── + +routes.get('/songs/:id/cover-url', async (c) => { + const { coverArtPath } = c.req.query(); + if (!coverArtPath) return c.json({ url: null }); + + try { + const { createMukkeStorage } = await import('@manacore/shared-storage'); + const storage = createMukkeStorage(); + const url = await storage.getDownloadUrl(coverArtPath, { expiresIn: 3600 }); + return c.json({ url }); + } catch { + return c.json({ url: null }); + } +}); + +// ─── Batch Cover URLs ─────────────────────────────────────── + +routes.post('/library/cover-urls', async (c) => { + const { paths } = await c.req.json(); + if (!paths?.length) return c.json({ urls: {} }); + + try { + const { createMukkeStorage } = await import('@manacore/shared-storage'); + const storage = createMukkeStorage(); + const urls: Record = {}; + + for (const path of paths.slice(0, 50)) { + try { + urls[path] = await storage.getDownloadUrl(path, { expiresIn: 3600 }); + } catch { + // Skip failed URLs + } + } + + return c.json({ urls }); + } catch { + return c.json({ urls: {} }); + } +}); + +export { routes as mukkeRoutes }; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 000000000..6f7d69bc9 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["bun-types"] + }, + "include": ["src/**/*.ts"] +}