From bd3d17c192dc61f92257bf85e1a6f27f9f0dc4ed Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Sun, 7 Dec 2025 17:10:00 +0000 Subject: [PATCH] feat: integrate MinIO for video storage, add video routes, and update README with API documentation --- Backend/.env.example | 8 ++ Backend/README.md | 46 ++++++++++++ Backend/db/schema.ts | 1 + Backend/index.ts | 2 + Backend/routes/videos.ts | 154 +++++++++++++++++++++++++++++++++++++++ Backend/utils/minio.ts | 45 ++++++++++++ 6 files changed, 256 insertions(+) create mode 100644 Backend/routes/videos.ts create mode 100644 Backend/utils/minio.ts diff --git a/Backend/.env.example b/Backend/.env.example index c39bcfe..1b2f8ea 100644 --- a/Backend/.env.example +++ b/Backend/.env.example @@ -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 diff --git a/Backend/README.md b/Backend/README.md index e225fb7..352897c 100644 --- a/Backend/README.md +++ b/Backend/README.md @@ -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 ``` + +## 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 +{ + "fileName": "sample.mp4", + "prefix": "raw" +} +``` + +Get a presigned download URL: + +```bash +GET /videos/download-url?objectKey=raw//-sample.mp4 +Authorization: Bearer +``` + +List video objects: + +```bash +GET /videos?prefix=raw&limit=20 +Authorization: Bearer +``` + +Delete a video object: + +```bash +DELETE /videos?objectKey=raw//-sample.mp4 +Authorization: Bearer +``` + +**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. diff --git a/Backend/db/schema.ts b/Backend/db/schema.ts index c93bce7..3a53850 100644 --- a/Backend/db/schema.ts +++ b/Backend/db/schema.ts @@ -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(), diff --git a/Backend/index.ts b/Backend/index.ts index afc0d97..810f528 100644 --- a/Backend/index.ts +++ b/Backend/index.ts @@ -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); diff --git a/Backend/routes/videos.ts b/Backend/routes/videos.ts new file mode 100644 index 0000000..d64efa9 --- /dev/null +++ b/Backend/routes/videos.ts @@ -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; diff --git a/Backend/utils/minio.ts b/Backend/utils/minio.ts new file mode 100644 index 0000000..33055d3 --- /dev/null +++ b/Backend/utils/minio.ts @@ -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 | null = null; + +export const ensureMinioBucket = async (): Promise => { + 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; + } +};