Files
Final-Year-Project/Backend/routes/admin.ts

412 lines
12 KiB
TypeScript

import type { NextFunction, Request, Response } from 'express';
import { 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;