diff --git a/.env.example b/.env.example index dbf85c0..7948d76 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,4 @@ S3_BUCKET= S3_REGION= S3_ACCESS_KEY= S3_SECRET_KEY= +S3_ENDPOINT= diff --git a/PLAN.md b/PLAN.md index 58b9ae2..3368bec 100644 --- a/PLAN.md +++ b/PLAN.md @@ -6,7 +6,7 @@ Voice-first authoring tool for single-user Ghost blog. Capture audio, refine wit ## Architecture Snapshot - **Frontend**: `apps/admin` – React + TypeScript, Vite, Material UI, authenticated single-user dashboard. - **Backend**: `apps/api` – Node.js (Express) providing auth, media upload, OpenAI & Ghost integrations, leveraging shared utilities. -- **Storage**: Configurable AWS S3 (preferred) with local fallback during development. +- **Storage**: **MinIO (S3-compatible)** running on VPS (S3 API at `http://:9000`), with local dev fallback as needed. - **Shared**: `packages/` for shared TypeScript types, client SDK, and utility modules. ## Milestones & Tasks @@ -60,10 +60,26 @@ Voice-first authoring tool for single-user Ghost blog. Capture audio, refine wit - **Package structure**: Maintain `apps/` for runtime targets and `packages/` for shared libraries. ## Immediate Next Actions -- [ ] Create admin layout shell (header/sidebar, container) -- [ ] Persist auth state (cookie/localStorage flag after success) -- [ ] Add simple health route `/api/health` and error handler -- [ ] Begin audio capture UI (mic permission + basic recorder) +- [x] Create admin layout shell (header/sidebar, container) +- [x] Persist auth state (cookie/localStorage flag after success) +- [x] Add simple health route `/api/health` and error handler +- [x] Begin audio capture UI (mic permission + basic recorder) + +## Upcoming Next Actions +- [x] Backend endpoint for audio upload `/api/media/audio` (accept WebM/PCM) — implemented with MinIO via AWS SDK v3 +- [x] S3-compatible adapter using MinIO (`S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`) +- [ ] Add STT trigger in UI: send blob to backend, call OpenAI STT, render transcript + +## MinIO Integration Checklist +- [ ] Deploy MinIO on VPS (console `:9001`, API `:9000`). +- [ ] Create bucket `voxblog` and access keys. +- [ ] Configure `.env` with: + - `S3_ENDPOINT=http://:9000` + - `S3_BUCKET=voxblog` + - `S3_REGION=us-east-1` + - `S3_ACCESS_KEY=...` + - `S3_SECRET_KEY=...` +- [ ] Optional: Set bucket policy to allow public reads for media. ## Scaffolding Plan (Draft) - **Frontend (`apps/admin`)** diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b63eb59..d0ea598 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,6 +4,7 @@ dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); import express from 'express'; import cors from 'cors'; import authRouter from './auth'; +import mediaRouter from './media'; const app = express(); console.log('ENV ADMIN_PASSWORD loaded:', Boolean(process.env.ADMIN_PASSWORD)); @@ -17,10 +18,18 @@ app.use(express.json()); // API routes app.use('/api/auth', authRouter); +app.use('/api/media', mediaRouter); app.get('/api/health', (_req, res) => { res.json({ ok: true }); }); +// Error handler +// eslint-disable-next-line @typescript-eslint/no-unused-vars +app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error('API Error:', err); + res.status(500).json({ error: 'Internal server error' }); +}); + // Start server const PORT = process.env.PORT || 3001; app.listen(PORT, () => { diff --git a/apps/api/src/media.ts b/apps/api/src/media.ts new file mode 100644 index 0000000..1762edb --- /dev/null +++ b/apps/api/src/media.ts @@ -0,0 +1,36 @@ +import express from 'express'; +import multer from 'multer'; +import type { File as MulterFile } from 'multer'; +import crypto from 'crypto'; +import { uploadBuffer } from './storage/s3'; + +const router = express.Router(); +const upload = multer({ storage: multer.memoryStorage() }); + +router.post('/audio', upload.single('audio'), async ( + req: express.Request & { file?: MulterFile }, + res: express.Response +) => { + try { + if (!req.file) return res.status(400).json({ error: 'No audio file' }); + + const bucket = process.env.S3_BUCKET || 'voxblog'; + const mime = req.file.mimetype || 'application/octet-stream'; + const ext = mime === 'audio/webm' ? 'webm' : mime.split('/')[1] || 'bin'; + const key = `audio/${new Date().toISOString().slice(0,10)}/${crypto.randomUUID()}.${ext}`; + + const out = await uploadBuffer({ + bucket, + key, + body: req.file.buffer, + contentType: mime, + }); + + return res.status(200).json({ success: true, ...out }); + } catch (err) { + console.error('Upload failed:', err); + return res.status(500).json({ error: 'Upload failed' }); + } +}); + +export default router; diff --git a/apps/api/src/storage/s3.ts b/apps/api/src/storage/s3.ts new file mode 100644 index 0000000..66a1b46 --- /dev/null +++ b/apps/api/src/storage/s3.ts @@ -0,0 +1,36 @@ +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; + +function getS3Client() { + const endpoint = process.env.S3_ENDPOINT; // e.g. http://:9000 + const region = process.env.S3_REGION || 'us-east-1'; + const accessKeyId = process.env.S3_ACCESS_KEY || ''; + const secretAccessKey = process.env.S3_SECRET_KEY || ''; + + if (!endpoint || !accessKeyId || !secretAccessKey) { + throw new Error('Missing S3 config: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY'); + } + + return new S3Client({ + region, + endpoint, + forcePathStyle: true, + credentials: { accessKeyId, secretAccessKey }, + }); +} + +export async function uploadBuffer(params: { + bucket: string; + key: string; + body: Buffer; + contentType?: string; +}) { + const s3 = getS3Client(); + const cmd = new PutObjectCommand({ + Bucket: params.bucket, + Key: params.key, + Body: params.body, + ContentType: params.contentType || 'application/octet-stream', + }); + await s3.send(cmd); + return { bucket: params.bucket, key: params.key }; +}