feat: integrate MinIO for video storage, add video routes, and update README with API documentation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
154
Backend/routes/videos.ts
Normal 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
45
Backend/utils/minio.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user