feat: integrate MinIO for video storage, add video routes, and update README with API documentation

This commit is contained in:
2025-12-07 17:10:00 +00:00
parent 93b5e289f3
commit bd3d17c192
6 changed files with 256 additions and 0 deletions

View File

@@ -2,3 +2,11 @@ DATABASE_URL=postgres://username:password@localhost:5432/database_name
JWT_SECRET=replace_with_a_long_random_secret
JWT_EXPIRES_IN=7d
PORT=3000
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=videos
MINIO_REGION=us-east-1
MINIO_PRESIGNED_EXPIRY_SECONDS=600

View File

@@ -21,6 +21,14 @@ DATABASE_URL=postgres://username:password@localhost:5432/database_name
JWT_SECRET=replace_with_a_long_random_secret
JWT_EXPIRES_IN=7d
PORT=3000
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=videos
MINIO_REGION=us-east-1
MINIO_PRESIGNED_EXPIRY_SECONDS=600
```
## Run app
@@ -78,3 +86,41 @@ Get current user:
GET /auth/me
Authorization: Bearer <token>
```
## Video API (Dummy MinIO S3 Integration)
All routes require a JWT Bearer token.
Create a presigned upload URL:
```bash
POST /videos/upload-url
Authorization: Bearer <token>
{
"fileName": "sample.mp4",
"prefix": "raw"
}
```
Get a presigned download URL:
```bash
GET /videos/download-url?objectKey=raw/<user-id>/<timestamp>-sample.mp4
Authorization: Bearer <token>
```
List video objects:
```bash
GET /videos?prefix=raw&limit=20
Authorization: Bearer <token>
```
Delete a video object:
```bash
DELETE /videos?objectKey=raw/<user-id>/<timestamp>-sample.mp4
Authorization: Bearer <token>
```
**Self-hosted MinIO note** Make sure the backend can reach your MinIO endpoint (network, TLS, credentials) and mirror any bucket changes you make outside of the app in `MINIO_BUCKET`, otherwise uploads/downloads fail.

View File

@@ -10,6 +10,7 @@ export const users = pgTable('users', {
export const events = pgTable('events', {
id: uuid('id').defaultRandom().primaryKey(),
creatorId: uuid('creator_id').references(() => users.id),
title: varchar('title', { length: 255 }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
videoUrl: varchar('video_url', { length: 255 }).notNull().unique(),

View File

@@ -2,6 +2,7 @@ import 'dotenv/config';
import express from 'express';
import authRoutes from './routes/auth';
import videosRoutes from './routes/videos';
const app = express();
@@ -12,6 +13,7 @@ app.get('/', (_req, res) => {
});
app.use('/auth', authRoutes);
app.use('/videos', videosRoutes);
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);

154
Backend/routes/videos.ts Normal file
View File

@@ -0,0 +1,154 @@
import { Router } from 'express';
import { z } from 'zod';
import { requireAuth } from '../middleware/auth';
import {
ensureMinioBucket,
minioBucket,
minioClient,
minioPresignedExpirySeconds,
} from '../utils/minio';
const router = Router();
const uploadUrlSchema = z.object({
fileName: z.string().trim().min(1).max(255),
prefix: z.string().trim().optional(),
});
const downloadUrlSchema = z.object({
objectKey: z.string().trim().min(1),
});
const listSchema = z.object({
prefix: z.string().trim().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
const sanitizeSegment = (value: string): string => value.replace(/[^a-zA-Z0-9._/-]/g, '_');
const buildObjectKey = (userId: string, fileName: string, prefix?: string): string => {
const safePrefix = prefix ? sanitizeSegment(prefix).replace(/^\/+|\/+$/g, '') : 'uploads';
const safeFileName = sanitizeSegment(fileName);
return `${safePrefix}/${userId}/${Date.now()}-${safeFileName}`;
};
router.use(requireAuth);
router.post('/upload-url', async (req, res) => {
const parsed = uploadUrlSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() });
return;
}
const user = req.user;
if (!user) {
res.status(401).json({ message: 'Unauthorized' });
return;
}
await ensureMinioBucket();
const objectKey = buildObjectKey(user.userId, parsed.data.fileName, parsed.data.prefix);
const uploadUrl = await minioClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds);
res.status(201).json({
message: 'Dummy upload URL generated',
bucket: minioBucket,
objectKey,
uploadUrl,
expiresInSeconds: minioPresignedExpirySeconds,
});
});
router.get('/download-url', async (req, res) => {
const parsed = downloadUrlSchema.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({ message: 'Invalid query params', errors: parsed.error.flatten() });
return;
}
await ensureMinioBucket();
const downloadUrl = await minioClient.presignedGetObject(
minioBucket,
parsed.data.objectKey,
minioPresignedExpirySeconds,
);
res.json({
message: 'Dummy download URL generated',
bucket: minioBucket,
objectKey: parsed.data.objectKey,
downloadUrl,
expiresInSeconds: minioPresignedExpirySeconds,
});
});
router.get('/', async (req, res) => {
const parsed = listSchema.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({ message: 'Invalid query params', errors: parsed.error.flatten() });
return;
}
await ensureMinioBucket();
const objects = await new Promise<
{ objectKey: string | undefined; size: number; etag: string | undefined; lastModified: Date | undefined }[]
>((resolve, reject) => {
const results: {
objectKey: string | undefined;
size: number;
etag: string | undefined;
lastModified: Date | undefined;
}[] = [];
const stream = minioClient.listObjectsV2(minioBucket, parsed.data.prefix, true);
stream.on('data', (item) => {
if (results.length >= parsed.data.limit) {
stream.destroy();
return;
}
results.push({
objectKey: item.name,
size: item.size,
etag: item.etag,
lastModified: item.lastModified,
});
});
stream.on('error', (error) => reject(error));
stream.on('end', () => resolve(results));
stream.on('close', () => resolve(results));
});
res.json({
bucket: minioBucket,
count: objects.length,
objects,
});
});
router.delete('/', async (req, res) => {
const parsed = downloadUrlSchema.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({ message: 'Invalid query params', errors: parsed.error.flatten() });
return;
}
await ensureMinioBucket();
await minioClient.removeObject(minioBucket, parsed.data.objectKey);
res.json({ message: 'Object deleted', bucket: minioBucket, objectKey: parsed.data.objectKey });
});
export default router;

45
Backend/utils/minio.ts Normal file
View File

@@ -0,0 +1,45 @@
import { Client } from 'minio';
const endpoint = process.env.MINIO_ENDPOINT ?? 'localhost';
const port = Number(process.env.MINIO_PORT ?? 9000);
const useSSL = (process.env.MINIO_USE_SSL ?? 'false').toLowerCase() === 'true';
const accessKey = process.env.MINIO_ACCESS_KEY;
const secretKey = process.env.MINIO_SECRET_KEY;
if (!accessKey || !secretKey) {
throw new Error('MINIO_ACCESS_KEY and MINIO_SECRET_KEY must be set');
}
export const minioBucket = process.env.MINIO_BUCKET ?? 'videos';
export const minioPresignedExpirySeconds = Number(process.env.MINIO_PRESIGNED_EXPIRY_SECONDS ?? 60 * 10);
export const minioClient = new Client({
endPoint: endpoint,
port,
useSSL,
accessKey,
secretKey,
});
let ensureBucketPromise: Promise<void> | null = null;
export const ensureMinioBucket = async (): Promise<void> => {
if (ensureBucketPromise) {
return ensureBucketPromise;
}
ensureBucketPromise = (async () => {
const exists = await minioClient.bucketExists(minioBucket);
if (!exists) {
await minioClient.makeBucket(minioBucket, process.env.MINIO_REGION ?? 'us-east-1');
}
})();
try {
await ensureBucketPromise;
} catch (error) {
ensureBucketPromise = null;
throw error;
}
};