- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
158 lines
5.1 KiB
TypeScript
158 lines
5.1 KiB
TypeScript
import {
|
||
S3Client,
|
||
PutObjectCommand,
|
||
DeleteObjectCommand,
|
||
GetObjectCommand,
|
||
CreateBucketCommand,
|
||
HeadBucketCommand,
|
||
} from '@aws-sdk/client-s3';
|
||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||
import crypto from 'crypto';
|
||
import path from 'path';
|
||
import fssync from 'fs';
|
||
import fs from 'fs/promises';
|
||
|
||
export const isS3Enabled = !!(process.env.MINIO_ENDPOINT || process.env.S3_ENDPOINT);
|
||
|
||
export const s3Client = isS3Enabled
|
||
? new S3Client({
|
||
endpoint: process.env.MINIO_ENDPOINT || process.env.S3_ENDPOINT,
|
||
region: process.env.S3_REGION || 'us-east-1',
|
||
credentials: {
|
||
accessKeyId: process.env.MINIO_ACCESS_KEY || process.env.S3_ACCESS_KEY || '',
|
||
secretAccessKey: process.env.MINIO_SECRET_KEY || process.env.S3_SECRET_KEY || '',
|
||
},
|
||
forcePathStyle: true,
|
||
})
|
||
: null;
|
||
|
||
export const S3_BUCKET = process.env.MINIO_BUCKET || process.env.S3_BUCKET || 'files';
|
||
|
||
export async function ensureS3Bucket(): Promise<void> {
|
||
if (!s3Client) return;
|
||
try {
|
||
await s3Client.send(new HeadBucketCommand({ Bucket: S3_BUCKET }));
|
||
} catch {
|
||
try {
|
||
await s3Client.send(new CreateBucketCommand({ Bucket: S3_BUCKET }));
|
||
console.log(`[S3] Bucket "${S3_BUCKET}" создан`);
|
||
} catch (createErr: unknown) {
|
||
const msg = createErr instanceof Error ? createErr.message : String(createErr);
|
||
if (!msg.includes('BucketAlreadyOwnedByYou') && !msg.includes('BucketAlreadyExists')) {
|
||
console.error('[S3] Ошибка создания bucket:', createErr);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function uploadToS3(
|
||
buffer: Buffer,
|
||
originalName: string,
|
||
mimeType: string,
|
||
): Promise<{ url: string; key: string }> {
|
||
if (!s3Client) throw new Error('S3 не настроен');
|
||
const ext = path.extname(originalName);
|
||
const key = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}${ext}`;
|
||
await s3Client.send(
|
||
new PutObjectCommand({
|
||
Bucket: S3_BUCKET,
|
||
Key: key,
|
||
Body: buffer,
|
||
ContentType: mimeType,
|
||
ContentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(originalName)}`,
|
||
}),
|
||
);
|
||
return { url: `/api/files/${key}`, key };
|
||
}
|
||
|
||
// Загружает файл в S3 потоком с диска (используется для файлов > 5 МБ в S3-режиме)
|
||
export async function uploadToS3FromFile(
|
||
filePath: string,
|
||
originalName: string,
|
||
mimeType: string,
|
||
): Promise<{ url: string; key: string }> {
|
||
if (!s3Client) throw new Error('S3 не настроен');
|
||
const ext = path.extname(originalName);
|
||
const key = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}${ext}`;
|
||
const stat = await fs.stat(filePath);
|
||
const fileStream = fssync.createReadStream(filePath);
|
||
await s3Client.send(
|
||
new PutObjectCommand({
|
||
Bucket: S3_BUCKET,
|
||
Key: key,
|
||
Body: fileStream,
|
||
ContentLength: stat.size,
|
||
ContentType: mimeType,
|
||
ContentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(originalName)}`,
|
||
}),
|
||
);
|
||
return { url: `/api/files/${key}`, key };
|
||
}
|
||
|
||
export async function deleteFromS3(key: string): Promise<void> {
|
||
if (!s3Client) return;
|
||
try {
|
||
await s3Client.send(new DeleteObjectCommand({ Bucket: S3_BUCKET, Key: key }));
|
||
} catch (e: unknown) {
|
||
const code = (e as any)?.name || (e as any)?.code;
|
||
if (code !== 'NoSuchKey') {
|
||
console.error('[S3] Ошибка удаления объекта:', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function streamFromS3(key: string): Promise<{
|
||
body: NodeJS.ReadableStream;
|
||
contentType?: string;
|
||
contentDisposition?: string;
|
||
contentLength?: number;
|
||
} | null> {
|
||
if (!s3Client) return null;
|
||
try {
|
||
const result = await s3Client.send(
|
||
new GetObjectCommand({ Bucket: S3_BUCKET, Key: key }),
|
||
);
|
||
return {
|
||
body: result.Body as NodeJS.ReadableStream,
|
||
contentType: result.ContentType,
|
||
contentDisposition: result.ContentDisposition,
|
||
contentLength: result.ContentLength,
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function getPresignedUrl(
|
||
key: string,
|
||
expiresInSeconds = 300,
|
||
contentDisposition?: string,
|
||
): Promise<string | null> {
|
||
if (!s3Client) return null;
|
||
try {
|
||
const command = new GetObjectCommand({
|
||
Bucket: S3_BUCKET,
|
||
Key: key,
|
||
ResponseContentDisposition: contentDisposition,
|
||
});
|
||
return await getSignedUrl(s3Client, command, { expiresIn: expiresInSeconds });
|
||
} catch (err) {
|
||
console.error('[S3] Ошибка генерации presigned URL:', err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function downloadFromS3(key: string, destPath: string): Promise<void> {
|
||
if (!s3Client) throw new Error('S3 не настроен');
|
||
const result = await s3Client.send(new GetObjectCommand({ Bucket: S3_BUCKET, Key: key }));
|
||
if (!result.Body) throw new Error('Пустое тело файла из S3');
|
||
const writeStream = fssync.createWriteStream(destPath);
|
||
const bodyStream = result.Body as NodeJS.ReadableStream;
|
||
return new Promise((resolve, reject) => {
|
||
bodyStream.pipe(writeStream);
|
||
writeStream.on('finish', resolve);
|
||
writeStream.on('error', reject);
|
||
bodyStream.on('error', reject);
|
||
});
|
||
}
|