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,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}`;
}