add simple admin service
Build and deploy / deploy (push) Successful in 16s

This commit is contained in:
2026-05-18 02:48:47 +03:00
parent 10a83d39a2
commit 3a3b2bcfc0
84 changed files with 2134 additions and 5 deletions
@@ -0,0 +1,5 @@
export function httpError(status, message) {
const error = new Error(message);
error.status = status;
return error;
}
@@ -0,0 +1,27 @@
import { requestLimits } from '../../config/limits.mjs';
import { httpError } from './httpError.mjs';
export async function readJsonBody(request) {
const chunks = [];
let bodySize = 0;
for await (const chunk of request) {
bodySize += chunk.length;
if (bodySize > requestLimits.maxBodySize) {
throw httpError(413, 'Request body is too large');
}
chunks.push(chunk);
}
if (chunks.length === 0) {
throw httpError(400, 'Request body is empty');
}
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
throw httpError(400, 'Request body must be valid JSON');
}
}
+12
View File
@@ -0,0 +1,12 @@
import { sendJson } from './sendJson.mjs';
export function sendError(response, error) {
const status = error.status || 500;
const message = status >= 500 ? 'Internal server error' : error.message;
if (status >= 500) {
console.error(error);
}
return sendJson(response, { error: message }, status);
}
+10
View File
@@ -0,0 +1,10 @@
export function sendJson(response, body, status = 200) {
const payload = `${JSON.stringify(body)}\n`;
response.writeHead(status, {
'Content-Length': Buffer.byteLength(payload),
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
});
response.end(payload);
}
@@ -0,0 +1,25 @@
export const mimeTypes = {
'.csv': 'text/csv; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.ogg': 'audio/ogg',
'.pdf': 'application/pdf',
'.png': 'image/png',
'.svg': 'image/svg+xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.webm': 'video/webm',
'.webp': 'image/webp',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.zip': 'application/zip',
};
@@ -0,0 +1,13 @@
import { resolve, sep } from 'node:path';
export function resolveInside(root, pathname) {
let decodedPath;
try {
decodedPath = decodeURIComponent(pathname);
} catch {
return null;
}
const filePath = resolve(root, decodedPath.replace(/^\/+/, ''));
return filePath === root || filePath.startsWith(`${root}${sep}`) ? filePath : null;
}
@@ -0,0 +1,44 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { extname } from 'node:path';
import { httpError } from '../http/httpError.mjs';
import { mimeTypes } from './mimeTypes.mjs';
import { resolveInside } from './resolveInside.mjs';
export async function serveFile(response, root, pathname, method) {
const filePath = resolveInside(root, pathname);
if (!filePath) {
throw httpError(403, 'Forbidden');
}
const fileStat = await getFileStat(filePath);
response.writeHead(200, {
'Content-Length': fileStat.size,
'Content-Type': mimeTypes[extname(filePath).toLowerCase()] || 'application/octet-stream',
'X-Content-Type-Options': 'nosniff',
});
if (method === 'HEAD') {
response.end();
return;
}
createReadStream(filePath).pipe(response);
}
async function getFileStat(filePath) {
let fileStat;
try {
fileStat = await stat(filePath);
} catch {
throw httpError(404, 'Not found');
}
if (!fileStat.isFile()) {
throw httpError(404, 'Not found');
}
return fileStat;
}