feat: add admin dashboard with basic auth and update README for configuration and API details
This commit is contained in:
@@ -10,3 +10,5 @@ MINIO_SECRET_KEY=minioadmin
|
|||||||
MINIO_BUCKET=videos
|
MINIO_BUCKET=videos
|
||||||
MINIO_REGION=us-east-1
|
MINIO_REGION=us-east-1
|
||||||
MINIO_PRESIGNED_EXPIRY_SECONDS=600
|
MINIO_PRESIGNED_EXPIRY_SECONDS=600
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=strong-password
|
||||||
|
|||||||
@@ -1,126 +1,99 @@
|
|||||||
# backend
|
# 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
|
```bash
|
||||||
bun install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment
|
## Configuration
|
||||||
|
Copy the example environment file and adjust the values:
|
||||||
Create a `.env` file:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Set:
|
Required env vars:
|
||||||
|
|
||||||
```bash
|
| Name | Purpose |
|
||||||
DATABASE_URL=postgres://username:password@localhost:5432/database_name
|
| --- | --- |
|
||||||
JWT_SECRET=replace_with_a_long_random_secret
|
| `DATABASE_URL` | Postgres connection string |
|
||||||
JWT_EXPIRES_IN=7d
|
| `JWT_SECRET` | Secret used to sign access tokens |
|
||||||
PORT=3000
|
| `JWT_EXPIRES_IN` | Token expiry (e.g., `7d`) |
|
||||||
MINIO_ENDPOINT=localhost
|
| `PORT` | HTTP port (default `3000`) |
|
||||||
MINIO_PORT=9000
|
| `MINIO_*` | Connection settings for the MinIO/S3 endpoint |
|
||||||
MINIO_USE_SSL=false
|
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | Basic auth for `/admin` dashboard |
|
||||||
MINIO_ACCESS_KEY=minioadmin
|
|
||||||
MINIO_SECRET_KEY=minioadmin
|
|
||||||
MINIO_BUCKET=videos
|
|
||||||
MINIO_REGION=us-east-1
|
|
||||||
MINIO_PRESIGNED_EXPIRY_SECONDS=600
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run app
|
## Running
|
||||||
|
- Start the server in development:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Drizzle ORM
|
- Server boots after ensuring the configured MinIO bucket exists.
|
||||||
|
|
||||||
Generate migrations:
|
|
||||||
|
|
||||||
|
## Database (Drizzle ORM)
|
||||||
|
- Generate a migration:
|
||||||
```bash
|
```bash
|
||||||
bun run db:generate
|
bun run db:generate
|
||||||
```
|
```
|
||||||
|
- Apply migrations:
|
||||||
Apply migrations:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run db:migrate
|
bun run db:migrate
|
||||||
```
|
```
|
||||||
|
- Open Drizzle Studio:
|
||||||
Open Drizzle Studio:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run db:studio
|
bun run db:studio
|
||||||
```
|
```
|
||||||
|
|
||||||
## Auth API
|
## 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`.
|
||||||
|
|
||||||
Register:
|
### 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
|
### Authorization
|
||||||
POST /auth/register
|
All authenticated endpoints expect an `Authorization: Bearer <token>` header containing the JWT issued at login.
|
||||||
{
|
|
||||||
"email": "user@example.com",
|
|
||||||
"password": "password123",
|
|
||||||
"name": "User Name"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Login:
|
### 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
|
### Admin Dashboard
|
||||||
POST /auth/login
|
Access `/admin` with Basic auth to:
|
||||||
{
|
|
||||||
"email": "user@example.com",
|
|
||||||
"password": "password123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Get current user:
|
- Request presigned upload URLs
|
||||||
|
- Upload files directly via the generated URL
|
||||||
|
- List and delete objects within the MinIO bucket
|
||||||
|
|
||||||
```bash
|
The dashboard UI submits to `/admin/upload-url`, `/admin/objects`, and `/admin/object`.
|
||||||
GET /auth/me
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Video API (Dummy MinIO S3 Integration)
|
## 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
|
||||||
|
|
||||||
All routes require a JWT Bearer token.
|
## Notes
|
||||||
|
- MinIO bucket creation happens during startup, so the service must be able to reach the endpoint.
|
||||||
Create a presigned upload URL:
|
- Keep JWT and MinIO secrets out of source control.
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import express from 'express';
|
|||||||
|
|
||||||
import authRoutes from './routes/auth';
|
import authRoutes from './routes/auth';
|
||||||
import videosRoutes from './routes/videos';
|
import videosRoutes from './routes/videos';
|
||||||
|
import adminRoutes from './routes/admin';
|
||||||
import { ensureMinioBucket } from './utils/minio';
|
import { ensureMinioBucket } from './utils/minio';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -15,6 +16,7 @@ app.get('/', (_req, res) => {
|
|||||||
|
|
||||||
app.use('/auth', authRoutes);
|
app.use('/auth', authRoutes);
|
||||||
app.use('/videos', videosRoutes);
|
app.use('/videos', videosRoutes);
|
||||||
|
app.use('/admin', adminRoutes);
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
410
Backend/routes/admin.ts
Normal file
410
Backend/routes/admin.ts
Normal file
@@ -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 = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Video Admin Dashboard</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
margin: 2rem;
|
||||||
|
color: #0c0d0f;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
form > * {
|
||||||
|
display: block;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-button {
|
||||||
|
border: none;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2f80ed;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 1rem;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-list li {
|
||||||
|
background: white;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monospace {
|
||||||
|
font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.object-list li {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Video Admin Dashboard</h1>
|
||||||
|
<p>Use this interface to request a presigned upload URL, push a file into MinIO, and inspect the objects stored in <strong>${minioBucket}</strong>.</p>
|
||||||
|
<section>
|
||||||
|
<h2>Upload a file</h2>
|
||||||
|
<form id="upload-form">
|
||||||
|
<label>
|
||||||
|
Pick a file
|
||||||
|
<input type="file" id="file-input" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Prefix
|
||||||
|
<input type="text" id="prefix-input" placeholder="Optional folder prefix" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="pill-button">Upload via presigned URL</button>
|
||||||
|
</form>
|
||||||
|
<div class="status" id="status">Ready</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Bucket contents</h2>
|
||||||
|
<button id="refresh" class="pill-button">Refresh list</button>
|
||||||
|
<ul class="object-list" id="object-list"></ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const objectList = document.getElementById('object-list');
|
||||||
|
const refreshButton = document.getElementById('refresh');
|
||||||
|
|
||||||
|
const formatSize = (bytes) => {
|
||||||
|
if (!bytes && bytes !== 0) return 'N/A';
|
||||||
|
const kb = bytes / 1024;
|
||||||
|
if (kb < 1) return bytes + ' B';
|
||||||
|
const mb = kb / 1024;
|
||||||
|
if (mb < 1) return kb.toFixed(2) + ' KB';
|
||||||
|
return mb.toFixed(2) + ' MB';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchObjects = async () => {
|
||||||
|
try {
|
||||||
|
statusEl.textContent = 'Fetching objects…';
|
||||||
|
const response = await fetch('/admin/objects', { credentials: 'include' });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.message || response.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = 'Found ' + payload.objects.length + ' object(s)';
|
||||||
|
objectList.innerHTML = payload.objects
|
||||||
|
.map(
|
||||||
|
(item) => (
|
||||||
|
'<li>' +
|
||||||
|
'<div>' +
|
||||||
|
'<div class="monospace">' +
|
||||||
|
item.objectKey +
|
||||||
|
'</div>' +
|
||||||
|
'<small>' +
|
||||||
|
formatSize(item.size) +
|
||||||
|
'</small>' +
|
||||||
|
'</div>' +
|
||||||
|
'<button data-key="' +
|
||||||
|
item.objectKey +
|
||||||
|
'" class="pill-button" style="background:#f97316">Delete</button>' +
|
||||||
|
'</li>'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.textContent = 'Unable to load objects: ' + error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
objectList.addEventListener('click', async (event) => {
|
||||||
|
const button = event.target.closest('button');
|
||||||
|
if (!button || !button.dataset.key) return;
|
||||||
|
|
||||||
|
if (!confirm('Delete ' + button.dataset.key + '?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
statusEl.textContent = 'Deleting object…';
|
||||||
|
const response = await fetch('/admin/object?objectKey=' + encodeURIComponent(button.dataset.key), {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.message || response.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = payload.message;
|
||||||
|
fetchObjects();
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.textContent = 'Delete failed: ' + error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('upload-form').addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const fileInput = document.getElementById('file-input');
|
||||||
|
const prefixInput = document.getElementById('prefix-input');
|
||||||
|
const file = fileInput.files?.[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
statusEl.textContent = 'Choose a file before uploading.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
statusEl.textContent = 'Requesting upload URL…';
|
||||||
|
const uploadMetaResponse = await fetch('/admin/upload-url', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
fileName: file.name,
|
||||||
|
prefix: prefixInput.value.trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadMeta = await uploadMetaResponse.json();
|
||||||
|
|
||||||
|
if (!uploadMetaResponse.ok) {
|
||||||
|
throw new Error(uploadMeta.message || uploadMetaResponse.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = 'Uploading to MinIO…';
|
||||||
|
|
||||||
|
const putResponse = await fetch(uploadMeta.uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: file,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': file.type || 'application/octet-stream',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!putResponse.ok) {
|
||||||
|
throw new Error('MinIO rejected the file upload');
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = 'Upload complete · ' + uploadMeta.objectKey;
|
||||||
|
fetchObjects();
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.textContent = 'Upload failed: ' + error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshButton.addEventListener('click', fetchObjects);
|
||||||
|
fetchObjects();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
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;
|
||||||
Reference in New Issue
Block a user