feat(storage): integrate MinIO via S3-compatible adapter; add /api/media/audio upload route; update env and PLAN.md

This commit is contained in:
Ender 2025-10-23 21:17:12 +02:00
parent 7e0bb3dc53
commit c94461b460
5 changed files with 103 additions and 5 deletions

View File

@ -5,3 +5,4 @@ S3_BUCKET=
S3_REGION=
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_ENDPOINT=

26
PLAN.md
View File

@ -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://<vps>: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://<vps>: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`)**

View File

@ -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, () => {

36
apps/api/src/media.ts Normal file
View File

@ -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;

View File

@ -0,0 +1,36 @@
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
function getS3Client() {
const endpoint = process.env.S3_ENDPOINT; // e.g. http://<VPS_IP>: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 };
}