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_SECRET=replace_with_a_long_random_secret
|
||||||
JWT_EXPIRES_IN=7d
|
JWT_EXPIRES_IN=7d
|
||||||
PORT=3000
|
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_SECRET=replace_with_a_long_random_secret
|
||||||
JWT_EXPIRES_IN=7d
|
JWT_EXPIRES_IN=7d
|
||||||
PORT=3000
|
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
|
## Run app
|
||||||
@@ -78,3 +86,41 @@ Get current user:
|
|||||||
GET /auth/me
|
GET /auth/me
|
||||||
Authorization: Bearer <token>
|
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', {
|
export const events = pgTable('events', {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
creatorId: uuid('creator_id').references(() => users.id),
|
||||||
title: varchar('title', { length: 255 }).notNull(),
|
title: varchar('title', { length: 255 }).notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
videoUrl: varchar('video_url', { length: 255 }).notNull().unique(),
|
videoUrl: varchar('video_url', { length: 255 }).notNull().unique(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dotenv/config';
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
|
||||||
import authRoutes from './routes/auth';
|
import authRoutes from './routes/auth';
|
||||||
|
import videosRoutes from './routes/videos';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ app.get('/', (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use('/auth', authRoutes);
|
app.use('/auth', authRoutes);
|
||||||
|
app.use('/videos', videosRoutes);
|
||||||
|
|
||||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
console.error(err);
|
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