This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user