feat(storage): integrate MinIO via S3-compatible adapter; add /api/media/audio upload route; update env and PLAN.md
This commit is contained in:
parent
7e0bb3dc53
commit
c94461b460
@ -5,3 +5,4 @@ S3_BUCKET=
|
||||
S3_REGION=
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_ENDPOINT=
|
||||
|
||||
26
PLAN.md
26
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://<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`)**
|
||||
|
||||
@ -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
36
apps/api/src/media.ts
Normal 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;
|
||||
36
apps/api/src/storage/s3.ts
Normal file
36
apps/api/src/storage/s3.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user