Добавлена функциональность черновиков, добавлен в таблицу статей id который будет сохранять идентификатор при импорте.

This commit is contained in:
anibilag 2025-02-26 23:35:32 +03:00
parent 3b1b702b00
commit 433aa38218
11 changed files with 408 additions and 114 deletions

110
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@aws-sdk/client-s3": "^3.734.0",
"@aws-sdk/s3-request-presigner": "^3.734.0",
"@prisma/client": "^6.3.1",
"axios": "^1.7.9",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
@ -2456,6 +2457,23 @@
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -2646,6 +2664,18 @@
"text-hex": "1.0.x"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
@ -2762,6 +2792,15 @@
"ms": "2.0.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -2886,6 +2925,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -3026,6 +3080,41 @@
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -3129,6 +3218,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -3661,6 +3765,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",

View File

@ -15,6 +15,7 @@
"@aws-sdk/client-s3": "^3.734.0",
"@aws-sdk/s3-request-presigner": "^3.734.0",
"@prisma/client": "^6.3.1",
"axios": "^1.7.9",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.7",

View File

@ -22,6 +22,7 @@ model User {
model Article {
id String @id @default(uuid())
importId Int @default(0)
title String
excerpt String
content String
@ -35,6 +36,7 @@ model Article {
author User @relation(fields: [authorId], references: [id])
authorId String
gallery GalleryImage[]
isActive Boolean @default(false)
}
model GalleryImage {

View File

@ -1,11 +1,14 @@
import { Request, Response } from 'express';
import axios from "axios";
import { prisma } from '../../../lib/prisma';
import { AuthRequest } from '../../../middleware/auth';
import { checkPermission } from '../../../utils/permissions';
import { logger } from '../../../config/logger';
import { Article } from "../../../types";
import path from "path";
import {uploadBufferToS3} from "../../../services/s3Service";
const DEFAULT_COVER_IMAGE = 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&q=80&w=2070';
const DEFAULT_COVER_IMAGE = '/images/cover-placeholder.webp';
export async function getArticle(req: Request, res: Response) : Promise<void> {
try {
@ -140,6 +143,47 @@ export async function updateArticle(req: AuthRequest, res: Response) : Promise<v
}
}
export async function activeArticle(req: AuthRequest, res: Response) : Promise<void> {
try {
const { isActive } = req.body;
if (!req.user) {
res.status(401).json({ error: 'Пользователь не вошел в систему' });
return
}
const article = await prisma.article.findUnique({
where: { id: req.params.id }
});
if (!article) {
res.status(404).json({ error: 'Статья не найдена' });
return
}
const updatedArticle = await prisma.article.update({
where: { id: req.params.id },
data: {
isActive: !isActive
},
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
}
});
res.json(updatedArticle);
} catch (error) {
logger.error('Ошибка активирования статьи:', error);
res.status(500).json({ error: 'Серверная ошибка' });
}
}
export async function deleteArticle(req: AuthRequest, res: Response) : Promise<void> {
try {
if (!req.user) {
@ -175,6 +219,11 @@ export async function deleteArticle(req: AuthRequest, res: Response) : Promise<v
export async function importArticles(req: AuthRequest, res: Response) : Promise<void> {
const articles: Article[] = req.body;
if (!req.user) {
res.status(401).json({ error: 'Пользователь не вошел в систему' });
return
}
if (!Array.isArray(articles) || articles.length === 0) {
res.status(400).json({ message: 'Ожидается непустой массив статей в теле запроса' });
return
@ -205,6 +254,39 @@ export async function importArticles(req: AuthRequest, res: Response) : Promise<
});
// Шаг 2: Обработка coverImage
if (article.coverImage) {
const folder = `articles/${newArticle.id}`;
const imageUrl = article.coverImage;
try {
// Извлечение изображение в виде буфера массива
const imageResponse = await axios.get(imageUrl, { responseType: 'arraybuffer' });
// Создание буфера из данных ответа
const imageBuffer = Buffer.from(imageResponse.data, 'binary');
// Получить тип контента и расширение
const contentType = imageResponse.headers['content-type'];
const fileExtension = path.extname(new URL(imageUrl).pathname) ||
(contentType.includes('image/') ? `.${contentType.split('/')[1]}` : '');
// Генерация имени файла
const filename = `cover-${Date.now()}${fileExtension}`;
// Загрузка буфера в S3
const uploadedUrl = await uploadBufferToS3(
imageBuffer,
folder,
filename,
contentType,
'original',
80
);
} catch (imageError) {
console.error(`Ошибка загрузки coverImage для статьи ${article.id}:`, imageError);
}
}
importedCount++;

View File

@ -10,6 +10,8 @@ export async function listArticles(req: Request, res: Response) {
const perPage = 6;
const isActiveParam = req.query.isDraft;
// Преобразование и проверка categoryId и cityId
const catId = Number(req.query.categoryId);
const citId = Number(req.query.cityId);
@ -17,6 +19,7 @@ export async function listArticles(req: Request, res: Response) {
const where: Prisma.ArticleWhereInput = {
...(Number.isInteger(catId) && catId > 0 ? { categoryId: catId } : {}),
...(Number.isInteger(citId) && citId > 0 ? { cityId: citId } : {}),
...(isActiveParam === "true" ? { isActive: false } : isActiveParam === "false" ? {} : { isActive: true }),
};
// Рассчитываем пропуск записей

View File

@ -2,7 +2,7 @@ import express from 'express';
import { auth } from '../../middleware/auth';
import { searchArticles } from './controllers/search';
import { listArticles } from './controllers/list';
import { getArticle, createArticle, updateArticle, deleteArticle, importArticles } from './controllers/crud';
import { getArticle, createArticle, updateArticle, deleteArticle, importArticles, activeArticle } from './controllers/crud';
const router = express.Router();
@ -19,6 +19,7 @@ router.post('/import', auth, importArticles);
router.get('/:id', getArticle);
router.post('/', auth, createArticle);
router.put('/:id', auth, updateArticle);
router.put('/active/:id', auth, activeArticle);
router.delete('/:id', auth, deleteArticle);
export default router;

View File

@ -0,0 +1,100 @@
import { Request, Response } from 'express';
import axios from "axios";
import { logger } from '../../../config/logger';
import { uploadBufferToS3, uploadFile } from '../../../services/s3Service';
// Обработчик загрузки файла в S3
export const handleFileUpload = async (req: Request, res: Response): Promise<void> => {
if (!req.file) {
res.status(400).json({ error: 'Нет файла для загрузки' });
return;
}
const file = req.file as Express.MulterS3.File;
const folder = req.body.folder as string;
const resolutionId = req.body.resolutionId as string;
const quality = Number(req.body.quality) || 80;
try {
// Upload file to S3
const fileUrl = await uploadFile(folder, file, resolutionId, quality);
res.json({ message: 'Файл успешно загружен', fileUrl });
} catch (error) {
logger.error(`Ошибка загрузки файла в S3: ${file.key}`);
console.error(error);
res.status(500).json({ error: 'Ошибка загрузки файла в S3' });
}
};
// Handle URL to image upload
export const handleUrlToImageUpload = async (req: Request, res: Response): Promise<void> => {
const { imageUrl, folder, resolutionId, quality, filename } = req.body;
if (!imageUrl) {
res.status(400).json({ error: 'No image URL provided' });
return;
}
try {
// Fetch the image
const imageResponse = await axios.get(imageUrl, { responseType: 'arraybuffer' });
const imageBuffer = Buffer.from(imageResponse.data, 'binary');
// Upload to S3
const fileUrl = await uploadBufferToS3(
imageBuffer,
folder || 'images',
filename,
imageResponse.headers['content-type'],
resolutionId,
Number(quality) || 80
);
res.json({ message: 'Image successfully uploaded from URL', fileUrl });
} catch (error) {
logger.error(`Error uploading image from URL: ${imageUrl}`);
console.error(error);
res.status(500).json({ error: 'Error uploading image from URL' });
}
};
// Handle buffer upload
export const handleBufferUpload = async (req: Request, res: Response): Promise<void> => {
const { buffer, folder, filename, mimetype, resolutionId, quality } = req.body;
if (!buffer) {
res.status(400).json({ error: 'No buffer provided' });
return;
}
try {
// Convert base64 buffer to actual buffer if needed
let actualBuffer: Buffer;
if (typeof buffer === 'string') {
// Assuming base64 encoded
actualBuffer = Buffer.from(buffer, 'base64');
} else if (Buffer.isBuffer(buffer)) {
actualBuffer = buffer;
} else {
throw new Error('Invalid buffer format');
}
// Upload to S3
const fileUrl = await uploadBufferToS3(
actualBuffer,
folder || 'buffers',
filename,
mimetype,
resolutionId,
Number(quality) || 80
);
res.json({ message: 'Buffer successfully uploaded', fileUrl });
} catch (error) {
logger.error('Error uploading buffer to S3');
console.error(error);
res.status(500).json({ error: 'Error uploading buffer to S3' });
}
};

View File

@ -1,58 +1,17 @@
import express from 'express';
import { auth } from '../../middleware/auth';
import { createS3Client, getMulterS3Config, uploadToS3 } from '../../services/s3Service';
import { logger } from '../../config/logger';
import { multerUpload } from '../../services/s3Service';
import { handleBufferUpload, handleFileUpload, handleUrlToImageUpload } from './controllers/upload';
const router = express.Router();
// Конфигурация для подключения к S3
const BUCKET_NAME = process.env.AWS_S3_BUCKET || '';
const REGION = process.env.AWS_REGION || 'ru-central1'
const AWS_ENDPOINT = process.env.AWS_ENDPOINT || ''
const ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || '';
const SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || '';
// Route for uploading image from a file
router.post('/upload-url', auth, multerUpload, handleFileUpload);
// Создаем клиент S3
const s3Client = createS3Client(REGION, AWS_ENDPOINT, ACCESS_KEY_ID, SECRET_ACCESS_KEY);
// Route for uploading image from a URL
router.post('/upload-from-url', auth, handleUrlToImageUpload);
// Настройка Multer
const upload = getMulterS3Config(s3Client, BUCKET_NAME);
// Роут для загрузки изображения
router.post('/upload-url', auth, upload, async (req, res): Promise<void> => {
if (!req.file) {
res.status(400).json({ error: 'No file uploaded' });
return
}
const file = req.file as Express.MulterS3.File;
const folder = req.body.folder as string;
const resolutionId = req.body.resolutionId as string;
const quality = 80;
try {
// Загружаем файл в S3 после оптимизации
const fileUrl = await uploadToS3(s3Client, BUCKET_NAME, folder, file, resolutionId, quality);
res.json({ message: 'Файл успешно загружен', fileUrl });
} catch (error) {
logger.error(`Ошибка загрузки файла в S3: ${file.key}`);
console.error(error);
res.status(500).json({ error: 'Ошибка загрузки файла в S3' });
}
});
/*
router.get('/:id', auth, async (req, res) => {
try {
const { id } = req.params;
const image = await getImage(id, BUCKET_NAME);
res.json(image);
} catch (error) {
logger.error('Error fetching image:', error);
res.status(500).json({ error: 'Failed to fetch image' });
}
});
*/
// Route for uploading from a buffer
router.post('/upload-buffer', auth, handleBufferUpload);
export default router;

View File

@ -1,20 +1,30 @@
import { S3Client, PutObjectCommand, GetObjectCommand} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { v4 as uuidv4 } from 'uuid';
import multer from 'multer';
import sharp from 'sharp';
import { logger } from '../config/logger';
import { imageResolutions } from '../config/imageResolutions';
// Функция для создания клиента S3
export const createS3Client = (region: string, endpoint: string, accessKeyId: string, secretAccessKey: string) => {
return new S3Client({
region: region,
endpoint: endpoint,
credentials: {
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey,
}
});
};
// Конфигурация для подключения к S3
const BUCKET_NAME = process.env.AWS_S3_BUCKET || '';
const REGION = process.env.AWS_REGION || 'ru-central1';
const AWS_ENDPOINT = process.env.AWS_ENDPOINT || '';
const ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || '';
const SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || '';
// Создаем клиент S3
export const s3Client = new S3Client({
region: REGION,
endpoint: AWS_ENDPOINT,
credentials: {
accessKeyId: ACCESS_KEY_ID,
secretAccessKey: SECRET_ACCESS_KEY,
},
});
// Export bucket name
export const bucketName = BUCKET_NAME;
// Функция для изменения размеров и оптимизации изображения
const resizeAndOptimizeImage = async (buffer: Buffer, width: number, height: number, quality: number): Promise<Buffer> => {
@ -27,7 +37,7 @@ const resizeAndOptimizeImage = async (buffer: Buffer, width: number, height: num
};
// Функция для получения конфигурации Multer-S3
export const getMulterS3Config = (
const getMulterS3Config = (
s3Client: S3Client,
bucketName: string
) => {
@ -42,6 +52,9 @@ export const getMulterS3Config = (
}).single('file'); // Обрабатываем один файл
};
// Default multer upload middleware using the default S3 client
export const multerUpload = getMulterS3Config(s3Client, bucketName);
// Функция для загрузки файла в S3 после оптимизации
export const uploadToS3 = async (
s3Client: S3Client,
@ -88,18 +101,78 @@ export const uploadToS3 = async (
}
};
/*
export const getImage = async (imageId: string, bucketName: string) => {
/**
* Function to upload a buffer to S3
* @param buffer - The buffer to upload
* @param folder - The folder path in S3
* @param filename - Optional filename (if not provided, a UUID will be generated)
* @param mimetype - The MIME type of the buffer content
* @param resolutionId - Optional resolution identifier
* @param quality - Optional quality setting for image optimization
* @returns URL of the uploaded file
*/
export const uploadBufferToS3 = async (
buffer: Buffer,
folder: string,
filename?: string,
mimetype?: string,
resolutionId?: string,
quality: number = 80
): Promise<string> => {
try {
const command = new GetObjectCommand({
Bucket: bucketName,
Key: `uploads/${imageId}`
});
// Generate filename if not provided
const finalFilename = filename || `${uuidv4()}${mimetype ? getExtensionFromMimeType(mimetype) : '.bin'}`;
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
return { url };
// Create the S3 key (path)
const key = folder ? `${folder}/${finalFilename}` : finalFilename;
// Here you could add image optimization for the buffer if needed
// For example, using Sharp library to resize/compress the image
// Upload buffer to S3
await s3Client.send(
new PutObjectCommand({
Bucket: bucketName,
Key: key,
Body: buffer,
ContentType: mimetype,
})
);
// Return the file URL
const fileUrl = `https://${bucketName}.s3.regru.cloud/${finalFilename}`;
logger.info(`Buffer successfully uploaded to S3: ${key}`);
return fileUrl;
} catch (error) {
throw error;
logger.error('Error uploading buffer to S3');
console.error(error);
throw new Error('Error uploading buffer to S3');
}
};
*/
// Helper function to get file extension from MIME type
function getExtensionFromMimeType(mimetype: string): string {
const mimeToExt: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'application/pdf': '.pdf',
'text/plain': '.txt',
'text/html': '.html',
'application/json': '.json',
};
return mimeToExt[mimetype] || '';
}
// Simplified version of uploadToS3 that uses the default client and bucket
export const uploadFile = async (
folder: string,
file: Express.MulterS3.File,
resolutionId: string,
quality: number = 80
): Promise<string> => {
return uploadToS3(s3Client, bucketName, folder, file, resolutionId, quality);
};

View File

@ -1,19 +1,3 @@
import {CategoryName, CategoryIds} from './index';
/*
export interface UserPermissions {
categories: {
[key in CategoryName]: {
create: boolean;
edit: boolean;
delete: boolean;
};
};
cities: number[];
isAdmin: boolean;
}
*/
export interface UserPermissions {
categories: {
[key: string]: { edit: boolean; create: boolean; delete: boolean };

View File

@ -1,5 +1,6 @@
export interface Article {
id: string;
importId: number;
title: string;
excerpt: string;
content: string;
@ -13,6 +14,7 @@ export interface Article {
likes: number;
dislikes: number;
userReaction?: 'like' | 'dislike' | null;
isActive: boolean;
}
export interface GalleryImage {
@ -28,26 +30,3 @@ export interface Author {
avatar: string;
bio: string;
}
// export interface Category {
// id: number;
// name: CategoryName;
// }
export type CategoryName = 'Film' | 'Theater' | 'Music' | 'Sports' | 'Art' | 'Legends' | 'Anniversaries' | 'Memory';
export type CategoryIds = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8';
export type City = 'New York' | 'London';
/*
export const CategoryMap: Record<string, number> = {
'Film': 1,
'Theater': 2,
'Music': 3,
'Sports': 4,
'Art': 5,
'Legends': 6,
'Anniversaries': 7,
'Memory': 8,
};
*/