This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { paths } from '../config/paths.mjs';
|
||||
import {
|
||||
handleCreateArticle,
|
||||
handleDeleteArticle,
|
||||
handleListArticles,
|
||||
handleUpdateArticle,
|
||||
} from '../entities/article/articleHandlers.mjs';
|
||||
import { handleListAssets, handleUploadAsset } from '../entities/asset/assetHandlers.mjs';
|
||||
import { httpError } from '../shared/http/httpError.mjs';
|
||||
import { sendError } from '../shared/http/sendError.mjs';
|
||||
import { serveFile } from '../shared/static/serveFile.mjs';
|
||||
|
||||
export async function handleRequest(request, response) {
|
||||
try {
|
||||
return await routeRequest(request, response);
|
||||
} catch (error) {
|
||||
return sendError(response, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function routeRequest(request, response) {
|
||||
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
||||
|
||||
if (url.pathname === '/api/articles' && request.method === 'GET') {
|
||||
return await handleListArticles(request, response);
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/articles' && request.method === 'POST') {
|
||||
return await handleCreateArticle(request, response);
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/assets' && request.method === 'GET') {
|
||||
return await handleListAssets(request, response);
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/assets' && request.method === 'POST') {
|
||||
return await handleUploadAsset(request, response);
|
||||
}
|
||||
|
||||
const articleRoute = url.pathname.match(/^\/api\/articles\/(.+)$/);
|
||||
if (articleRoute) {
|
||||
return await routeArticleBySlug(request, response, decodeURIComponent(articleRoute[1]));
|
||||
}
|
||||
|
||||
return await routeStaticFile(request, response, url.pathname);
|
||||
}
|
||||
|
||||
async function routeArticleBySlug(request, response, slug) {
|
||||
if (request.method === 'PUT') {
|
||||
return await handleUpdateArticle(request, response, slug);
|
||||
}
|
||||
|
||||
if (request.method === 'DELETE') {
|
||||
return await handleDeleteArticle(request, response, slug);
|
||||
}
|
||||
|
||||
throw httpError(405, 'Method not allowed');
|
||||
}
|
||||
|
||||
async function routeStaticFile(request, response, pathname) {
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
throw httpError(405, 'Method not allowed');
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/assets/')) {
|
||||
return await serveFile(response, paths.webPublicRoot, pathname, request.method);
|
||||
}
|
||||
|
||||
const adminPath = pathname === '/' ? '/index.html' : pathname;
|
||||
return await serveFile(response, paths.adminPublicRoot, adminPath, request.method);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { paths } from '../config/paths.mjs';
|
||||
import { serverConfig } from '../config/server.mjs';
|
||||
import { handleRequest } from './requestHandler.mjs';
|
||||
|
||||
export function startServer() {
|
||||
const server = createServer(handleRequest);
|
||||
|
||||
server.on('error', (error) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
console.error(`Port ${serverConfig.port} is already in use. Run with ADMIN_PORT=3334 npm run admin:posts.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
server.listen(serverConfig.port, serverConfig.host, () => {
|
||||
console.log(`Local posts admin: http://${serverConfig.host}:${serverConfig.port}`);
|
||||
console.log(`Editing: ${paths.articlesFile}`);
|
||||
});
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => {
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const requestLimits = {
|
||||
maxBodySize: 30 * 1024 * 1024,
|
||||
maxUploadSize: 20 * 1024 * 1024,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const repoRoot = resolve(adminRoot, '..');
|
||||
const webRoot = resolve(repoRoot, 'web');
|
||||
|
||||
export const paths = {
|
||||
adminPublicRoot: resolve(adminRoot, 'public'),
|
||||
articlesFile: resolve(webRoot, 'data', 'articles.json'),
|
||||
uploadRoot: resolve(webRoot, 'public', 'assets', 'uploads'),
|
||||
webPublicRoot: resolve(webRoot, 'public'),
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export const serverConfig = {
|
||||
host: process.env.ADMIN_HOST || '127.0.0.1',
|
||||
port: Number(process.env.ADMIN_PORT || 3333),
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { handleCreateArticle } from './handleCreateArticle.mjs';
|
||||
export { handleDeleteArticle } from './handleDeleteArticle.mjs';
|
||||
export { handleListArticles } from './handleListArticles.mjs';
|
||||
export { handleUpdateArticle } from './handleUpdateArticle.mjs';
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { paths } from '../../config/paths.mjs';
|
||||
import { httpError } from '../../shared/http/httpError.mjs';
|
||||
|
||||
export async function readArticles() {
|
||||
const raw = await readFile(paths.articlesFile, 'utf8');
|
||||
const articles = JSON.parse(raw);
|
||||
|
||||
if (!Array.isArray(articles)) {
|
||||
throw httpError(500, 'Article database must be a JSON array');
|
||||
}
|
||||
|
||||
return articles;
|
||||
}
|
||||
|
||||
export async function writeArticles(articles) {
|
||||
const temporaryFile = `${paths.articlesFile}.${process.pid}.tmp`;
|
||||
await writeFile(temporaryFile, `${JSON.stringify(articles, null, 2)}\n`, 'utf8');
|
||||
await rename(temporaryFile, paths.articlesFile);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { httpError } from '../../shared/http/httpError.mjs';
|
||||
|
||||
export function normalizeArticle(input) {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw httpError(400, 'Article payload must be an object');
|
||||
}
|
||||
|
||||
const article = {
|
||||
...input,
|
||||
slug: readRequiredString(input, 'slug'),
|
||||
title: readRequiredString(input, 'title'),
|
||||
date: readRequiredString(input, 'date'),
|
||||
author: readRequiredString(input, 'author'),
|
||||
categories: normalizeCategories(input.categories),
|
||||
cover: readRequiredString(input, 'cover'),
|
||||
excerpt: readRequiredString(input, 'excerpt'),
|
||||
contentHtml: readRequiredString(input, 'contentHtml', { preserveWhitespace: true }),
|
||||
readingMinutes: normalizeReadingMinutes(input.readingMinutes),
|
||||
};
|
||||
|
||||
if (/[/?#]/.test(article.slug)) {
|
||||
throw httpError(400, 'Slug must not contain "/", "?" or "#"');
|
||||
}
|
||||
|
||||
if (Number.isNaN(new Date(article.date).getTime())) {
|
||||
throw httpError(400, 'Date must be a valid date string');
|
||||
}
|
||||
|
||||
return article;
|
||||
}
|
||||
|
||||
export function assertUniqueSlug(articles, slug, exceptSlug) {
|
||||
const duplicate = articles.find((article) => article.slug === slug && article.slug !== exceptSlug);
|
||||
|
||||
if (duplicate) {
|
||||
throw httpError(409, `Slug "${slug}" is already used`);
|
||||
}
|
||||
}
|
||||
|
||||
function readRequiredString(input, key, options = {}) {
|
||||
const value = input[key];
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
throw httpError(400, `"${key}" must be a string`);
|
||||
}
|
||||
|
||||
const normalized = options.preserveWhitespace ? value : value.trim();
|
||||
if (!normalized) {
|
||||
throw httpError(400, `"${key}" is required`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeCategories(value) {
|
||||
const categories = Array.isArray(value)
|
||||
? value
|
||||
: String(value || '')
|
||||
.split(',')
|
||||
.map((category) => category.trim());
|
||||
|
||||
const normalized = categories.filter(Boolean);
|
||||
if (normalized.length === 0) {
|
||||
throw httpError(400, 'At least one category is required');
|
||||
}
|
||||
|
||||
return [...new Set(normalized)];
|
||||
}
|
||||
|
||||
function normalizeReadingMinutes(value) {
|
||||
const minutes = Number(value);
|
||||
|
||||
if (!Number.isInteger(minutes) || minutes < 1) {
|
||||
throw httpError(400, '"readingMinutes" must be a positive integer');
|
||||
}
|
||||
|
||||
return minutes;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readJsonBody } from '../../shared/http/readJsonBody.mjs';
|
||||
import { sendJson } from '../../shared/http/sendJson.mjs';
|
||||
import { readArticles, writeArticles } from './articleRepository.mjs';
|
||||
import { assertUniqueSlug, normalizeArticle } from './articleValidator.mjs';
|
||||
|
||||
export async function handleCreateArticle(request, response) {
|
||||
const payload = await readJsonBody(request);
|
||||
const article = normalizeArticle(payload.article || payload);
|
||||
const articles = await readArticles();
|
||||
|
||||
assertUniqueSlug(articles, article.slug);
|
||||
articles.unshift(article);
|
||||
await writeArticles(articles);
|
||||
|
||||
return sendJson(response, { article }, 201);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { httpError } from '../../shared/http/httpError.mjs';
|
||||
import { sendJson } from '../../shared/http/sendJson.mjs';
|
||||
import { readArticles, writeArticles } from './articleRepository.mjs';
|
||||
|
||||
export async function handleDeleteArticle(_request, response, slug) {
|
||||
const articles = await readArticles();
|
||||
const index = articles.findIndex((article) => article.slug === slug);
|
||||
|
||||
if (index === -1) {
|
||||
throw httpError(404, `Article "${slug}" was not found`);
|
||||
}
|
||||
|
||||
const [article] = articles.splice(index, 1);
|
||||
await writeArticles(articles);
|
||||
|
||||
return sendJson(response, { article });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { sendJson } from '../../shared/http/sendJson.mjs';
|
||||
import { readArticles } from './articleRepository.mjs';
|
||||
|
||||
export async function handleListArticles(_request, response) {
|
||||
return sendJson(response, { articles: await readArticles() });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { httpError } from '../../shared/http/httpError.mjs';
|
||||
import { readJsonBody } from '../../shared/http/readJsonBody.mjs';
|
||||
import { sendJson } from '../../shared/http/sendJson.mjs';
|
||||
import { readArticles, writeArticles } from './articleRepository.mjs';
|
||||
import { assertUniqueSlug, normalizeArticle } from './articleValidator.mjs';
|
||||
|
||||
export async function handleUpdateArticle(request, response, currentSlug) {
|
||||
const payload = await readJsonBody(request);
|
||||
const articles = await readArticles();
|
||||
const index = articles.findIndex((article) => article.slug === currentSlug);
|
||||
|
||||
if (index === -1) {
|
||||
throw httpError(404, `Article "${currentSlug}" was not found`);
|
||||
}
|
||||
|
||||
const article = normalizeArticle(payload.article || payload);
|
||||
assertUniqueSlug(articles, article.slug, currentSlug);
|
||||
articles[index] = article;
|
||||
await writeArticles(articles);
|
||||
|
||||
return sendJson(response, { article });
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { handleListAssets } from './handleListAssets.mjs';
|
||||
export { handleUploadAsset } from './handleUploadAsset.mjs';
|
||||
@@ -0,0 +1,20 @@
|
||||
export const imageExtensions = new Set(['.avif', '.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp']);
|
||||
|
||||
export const assetExtensions = new Set([
|
||||
...imageExtensions,
|
||||
'.css',
|
||||
'.csv',
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.json',
|
||||
'.md',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.ogg',
|
||||
'.pdf',
|
||||
'.txt',
|
||||
'.webm',
|
||||
'.xls',
|
||||
'.xlsx',
|
||||
'.zip',
|
||||
]);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { extname, resolve } from 'node:path';
|
||||
import { httpError } from '../../shared/http/httpError.mjs';
|
||||
|
||||
export async function getUniqueFileName(root, fileName) {
|
||||
const extension = extname(fileName);
|
||||
const baseName = extension ? fileName.slice(0, -extension.length) : fileName;
|
||||
|
||||
for (let index = 0; index < 1000; index += 1) {
|
||||
const candidate = index === 0 ? fileName : `${baseName}-${index + 1}${extension}`;
|
||||
|
||||
try {
|
||||
await stat(resolve(root, candidate));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw httpError(500, 'Could not pick a unique file name');
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { paths } from '../../config/paths.mjs';
|
||||
import { sendJson } from '../../shared/http/sendJson.mjs';
|
||||
import { listAssets } from './listAssets.mjs';
|
||||
|
||||
export async function handleListAssets(_request, response) {
|
||||
return sendJson(response, { assets: await listAssets(resolve(paths.webPublicRoot, 'assets'), '/assets') });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { extname, resolve } from 'node:path';
|
||||
import { requestLimits } from '../../config/limits.mjs';
|
||||
import { paths } from '../../config/paths.mjs';
|
||||
import { httpError } from '../../shared/http/httpError.mjs';
|
||||
import { readJsonBody } from '../../shared/http/readJsonBody.mjs';
|
||||
import { sendJson } from '../../shared/http/sendJson.mjs';
|
||||
import { assetExtensions, imageExtensions } from './assetTypes.mjs';
|
||||
import { getUniqueFileName } from './getUniqueFileName.mjs';
|
||||
import { parseUploadPayload } from './parseUploadPayload.mjs';
|
||||
import { sanitizeFileName } from './sanitizeFileName.mjs';
|
||||
|
||||
export async function handleUploadAsset(request, response) {
|
||||
const payload = await readJsonBody(request);
|
||||
const fileName = sanitizeFileName(readRequiredString(payload, 'fileName'));
|
||||
const extension = extname(fileName).toLowerCase();
|
||||
|
||||
validateExtension(extension);
|
||||
const fileBuffer = parseUploadPayload(payload);
|
||||
validateUploadSize(fileBuffer.length);
|
||||
|
||||
await mkdir(paths.uploadRoot, { recursive: true });
|
||||
const uniqueFileName = await getUniqueFileName(paths.uploadRoot, fileName);
|
||||
await writeFile(resolve(paths.uploadRoot, uniqueFileName), fileBuffer);
|
||||
|
||||
return sendJson(response, { asset: createAssetResponse(uniqueFileName, extension, fileBuffer.length) }, 201);
|
||||
}
|
||||
|
||||
function createAssetResponse(fileName, extension, size) {
|
||||
return {
|
||||
fileName,
|
||||
isImage: imageExtensions.has(extension),
|
||||
path: `/assets/uploads/${fileName}`,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
function readRequiredString(input, key) {
|
||||
const value = input[key];
|
||||
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw httpError(400, `"${key}" is required`);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function validateExtension(extension) {
|
||||
if (!assetExtensions.has(extension)) {
|
||||
throw httpError(400, `Files with "${extension || 'no'}" extension are not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateUploadSize(size) {
|
||||
if (size === 0) {
|
||||
throw httpError(400, 'Uploaded file is empty');
|
||||
}
|
||||
|
||||
if (size > requestLimits.maxUploadSize) {
|
||||
throw httpError(413, `Uploaded file must be ${Math.round(requestLimits.maxUploadSize / 1024 / 1024)} MB or smaller`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { extname, resolve } from 'node:path';
|
||||
import { imageExtensions } from './assetTypes.mjs';
|
||||
|
||||
export async function listAssets(root, publicPrefix) {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
const assets = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const filePath = resolve(root, entry.name);
|
||||
const assetPath = `${publicPrefix}/${entry.name}`;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
assets.push(...(await listAssets(filePath, assetPath)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && imageExtensions.has(extname(entry.name).toLowerCase())) {
|
||||
assets.push(assetPath);
|
||||
}
|
||||
}
|
||||
|
||||
return assets.sort((left, right) => left.localeCompare(right, 'ru'));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { httpError } from '../../shared/http/httpError.mjs';
|
||||
|
||||
export function parseUploadPayload(payload) {
|
||||
if (typeof payload.dataUrl === 'string') {
|
||||
const match = payload.dataUrl.match(/^data:[^;]+;base64,(.+)$/);
|
||||
|
||||
if (!match) {
|
||||
throw httpError(400, '"dataUrl" must be a base64 data URL');
|
||||
}
|
||||
|
||||
return Buffer.from(match[1], 'base64');
|
||||
}
|
||||
|
||||
if (typeof payload.contentBase64 === 'string') {
|
||||
return Buffer.from(payload.contentBase64, 'base64');
|
||||
}
|
||||
|
||||
throw httpError(400, 'Upload payload must include "dataUrl" or "contentBase64"');
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { extname } from 'node:path';
|
||||
|
||||
export function sanitizeFileName(fileName) {
|
||||
const extension = extname(fileName).toLowerCase();
|
||||
const rawBaseName = fileName.slice(0, extension ? -extension.length : undefined);
|
||||
const baseName =
|
||||
rawBaseName
|
||||
.normalize('NFKD')
|
||||
.trim()
|
||||
.replace(/^\.+/, '')
|
||||
.replace(/[^\p{L}\p{N}._-]+/gu, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 90) || 'asset';
|
||||
|
||||
return `${baseName}${extension}`;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user