diff --git a/Backend/.env.example b/Backend/.env.example index 1b2f8ea..2d12767 100644 --- a/Backend/.env.example +++ b/Backend/.env.example @@ -10,3 +10,5 @@ MINIO_SECRET_KEY=minioadmin MINIO_BUCKET=videos MINIO_REGION=us-east-1 MINIO_PRESIGNED_EXPIRY_SECONDS=600 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=strong-password diff --git a/Backend/README.md b/Backend/README.md index 352897c..c27015e 100644 --- a/Backend/README.md +++ b/Backend/README.md @@ -1,126 +1,99 @@ # backend -## Install +## Overview +Backend for the video upload prototype providing: +- JWT-based authentication +- Presigned MinIO uploads/downloads +- An authenticated video administration surface at `/admin` + +## Requirements +- [Bun](https://bun.sh) (tooling used for running scripts & dependency management) +- Postgres reachable via `DATABASE_URL` +- MinIO-compatible storage reachable via `MINIO_*` env vars +- `.env` file populated with secrets and credentials + +## Install ```bash bun install ``` -## Environment - -Create a `.env` file: +## Configuration +Copy the example environment file and adjust the values: ```bash cp .env.example .env ``` -Set: +Required env vars: -```bash -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 -``` +| Name | Purpose | +| --- | --- | +| `DATABASE_URL` | Postgres connection string | +| `JWT_SECRET` | Secret used to sign access tokens | +| `JWT_EXPIRES_IN` | Token expiry (e.g., `7d`) | +| `PORT` | HTTP port (default `3000`) | +| `MINIO_*` | Connection settings for the MinIO/S3 endpoint | +| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | Basic auth for `/admin` dashboard | -## Run app +## Running +- Start the server in development: ```bash bun run dev ``` -## Drizzle ORM +- Server boots after ensuring the configured MinIO bucket exists. -Generate migrations: +## Database (Drizzle ORM) +- Generate a migration: + ```bash + bun run db:generate + ``` +- Apply migrations: + ```bash + bun run db:migrate + ``` +- Open Drizzle Studio: + ```bash + bun run db:studio + ``` -```bash -bun run db:generate -``` +## API +All `/videos` and `/admin` routes require a valid JWT Bearer token except for the admin dashboard access, which uses HTTP Basic auth with `ADMIN_USERNAME`/`ADMIN_PASSWORD`. -Apply migrations: +### Authentication +| Endpoint | Description | +| --- | --- | +| `POST /auth/register` | Create a user (`email`, `password`, `name`) | +| `POST /auth/login` | Receive a token using `email`/`password` | +| `GET /auth/me` | Get the current user ([Authorization](#authorization)) | -```bash -bun run db:migrate -``` +### Authorization +All authenticated endpoints expect an `Authorization: Bearer ` header containing the JWT issued at login. -Open Drizzle Studio: +### Video Management +| Endpoint | Purpose | +| --- | --- | +| `POST /videos/upload-url` | Request a presigned PUT URL for a new video | +| `GET /videos/download-url` | Generate a signed GET URL to download a video | +| `GET /videos` | List objects in the configured bucket | +| `DELETE /videos` | Delete an object by `objectKey` | -```bash -bun run db:studio -``` +### Admin Dashboard +Access `/admin` with Basic auth to: -## Auth API +- Request presigned upload URLs +- Upload files directly via the generated URL +- List and delete objects within the MinIO bucket -Register: +The dashboard UI submits to `/admin/upload-url`, `/admin/objects`, and `/admin/object`. -```bash -POST /auth/register -{ - "email": "user@example.com", - "password": "password123", - "name": "User Name" -} -``` +## Schema +- `users` – email/username/password and timestamps +- `events` – user-created events with a unique `videoUrl` +- `videos` – upload metadata including `objectKey`, bucket, URLs, status, and timestamps -Login: - -```bash -POST /auth/login -{ - "email": "user@example.com", - "password": "password123" -} -``` - -Get current user: - -```bash -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. +## Notes +- MinIO bucket creation happens during startup, so the service must be able to reach the endpoint. +- Keep JWT and MinIO secrets out of source control. diff --git a/Backend/index.ts b/Backend/index.ts index 6f50cb9..400daaa 100644 --- a/Backend/index.ts +++ b/Backend/index.ts @@ -3,6 +3,7 @@ import express from 'express'; import authRoutes from './routes/auth'; import videosRoutes from './routes/videos'; +import adminRoutes from './routes/admin'; import { ensureMinioBucket } from './utils/minio'; const app = express(); @@ -15,6 +16,7 @@ app.get('/', (_req, res) => { app.use('/auth', authRoutes); app.use('/videos', videosRoutes); +app.use('/admin', adminRoutes); app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error(err); diff --git a/Backend/routes/admin.ts b/Backend/routes/admin.ts new file mode 100644 index 0000000..5ef0d96 --- /dev/null +++ b/Backend/routes/admin.ts @@ -0,0 +1,410 @@ +import { NextFunction, Request, Response, Router } from 'express'; +import { z } from 'zod'; + +import { ensureMinioBucket, minioBucket, minioClient, minioPresignedExpirySeconds } from '../utils/minio'; + +const adminUsername = process.env.ADMIN_USERNAME; +const adminPassword = process.env.ADMIN_PASSWORD; + +if (!adminUsername || !adminPassword) { + throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD must be set to use the admin dashboard'); +} + +const router = Router(); + +const uploadSchema = z.object({ + fileName: z.string().trim().min(1).max(255), + prefix: z.string().trim().optional(), +}); + +const objectKeySchema = 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 = (fileName: string, prefix?: string): string => { + const safePrefix = prefix ? sanitizeSegment(prefix).replace(/^\/+|\/+$/g, '') : 'admin-tests'; + const safeFileName = sanitizeSegment(fileName); + + return `${safePrefix}/admin/${Date.now()}-${safeFileName}`; +}; + +const sendAuthRequired = (res: Response, message: string): Response => { + res.setHeader('WWW-Authenticate', 'Basic realm="Video Admin Dashboard"'); + return res.status(401).json({ message }); +}; + +const requireAdminAuth = (req: Request, res: Response, next: NextFunction): void => { + const authorization = req.headers.authorization; + + if (!authorization?.startsWith('Basic ')) { + sendAuthRequired(res, 'Authorization required for admin dashboard'); + return; + } + + const rawCredentials = authorization.slice(6); + let decoded: string; + + try { + decoded = Buffer.from(rawCredentials, 'base64').toString('utf8'); + } catch { + sendAuthRequired(res, 'Malformed authorization header'); + return; + } + + const separatorIndex = decoded.indexOf(':'); + + if (separatorIndex < 0) { + sendAuthRequired(res, 'Malformed authorization header'); + return; + } + + const username = decoded.slice(0, separatorIndex); + const password = decoded.slice(separatorIndex + 1); + + if (username !== adminUsername || password !== adminPassword) { + sendAuthRequired(res, 'Invalid admin credentials'); + return; + } + + next(); +}; + +router.use(requireAdminAuth); + +router.get('/', (_req, res) => { + const html = ` + + + + + Video Admin Dashboard + + + +
+

Video Admin Dashboard

+

Use this interface to request a presigned upload URL, push a file into MinIO, and inspect the objects stored in ${minioBucket}.

+
+

Upload a file

+
+ + + +
+
Ready
+
+
+

Bucket contents

+ +
    +
    +
    + + +`; + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(html); +}); + +router.post('/upload-url', async (req, res) => { + const parsed = uploadSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); + return; + } + + await ensureMinioBucket(); + + const objectKey = buildObjectKey(parsed.data.fileName, parsed.data.prefix); + const uploadUrl = await minioClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds); + const expiresAt = new Date(Date.now() + minioPresignedExpirySeconds * 1000); + + res.status(201).json({ + message: 'Upload URL generated', + bucket: minioBucket, + objectKey, + uploadUrl, + expiresAt, + expiresInSeconds: minioPresignedExpirySeconds, + }); +}); + +router.get('/objects', 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); + let finished = false; + + const finish = () => { + if (finished) return; + finished = true; + resolve(results); + }; + + 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) => { + if (!finished) { + finished = true; + reject(error); + } + }); + + stream.on('end', finish); + stream.on('close', finish); + }); + + res.json({ bucket: minioBucket, count: objects.length, objects }); +}); + +router.delete('/object', async (req, res) => { + const parsed = objectKeySchema.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;