Files
iistwin/server/utils/s3.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

158 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});
}