Enhance S3 error handling in StorageService and update .env.example documentation

- Added detailed error handling for S3 operations in StorageService to interpret and throw appropriate exceptions based on the response.
- Updated .env.example to clarify the S3 endpoint configuration for MinIO in Docker.
This commit is contained in:
Первов Артем
2026-04-21 01:02:10 +03:00
parent 327d3a95a3
commit 7735988ed5
2 changed files with 61 additions and 12 deletions

View File

@@ -6,6 +6,7 @@ KEYCLOAK_AUDIENCE="toir-backend"
KEYCLOAK_JWKS_URL="" KEYCLOAK_JWKS_URL=""
# MinIO / S3 (вложения оборудования) # MinIO / S3 (вложения оборудования)
# В Docker: имя сервиса/контейнера MinIO и порт API S3 (обычно 9000), НЕ URL консоли (9001) и не NPM, если он отдаёт не S3 XML.
S3_ENDPOINT="http://localhost:9000" S3_ENDPOINT="http://localhost:9000"
S3_REGION="eu-central-1" S3_REGION="eu-central-1"
S3_BUCKET="media" S3_BUCKET="media"

View File

@@ -1,6 +1,6 @@
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { HttpException, Injectable, ServiceUnavailableException } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
export type StoredAttachmentMeta = { export type StoredAttachmentMeta = {
@@ -54,19 +54,27 @@ export class StorageService {
async putObject(key: string, body: Buffer, contentType: string): Promise<void> { async putObject(key: string, body: Buffer, contentType: string): Promise<void> {
this.assertConfigured(); this.assertConfigured();
await this.client!.send( try {
new PutObjectCommand({ await this.client!.send(
Bucket: this.bucket, new PutObjectCommand({
Key: key, Bucket: this.bucket,
Body: body, Key: key,
ContentType: contentType || 'application/octet-stream', Body: body,
}), ContentType: contentType || 'application/octet-stream',
); }),
);
} catch (err) {
throw interpretS3Failure('PutObject', err);
}
} }
async deleteObject(key: string): Promise<void> { async deleteObject(key: string): Promise<void> {
this.assertConfigured(); this.assertConfigured();
await this.client!.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key })); try {
await this.client!.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
} catch (err) {
throw interpretS3Failure('DeleteObject', err);
}
} }
async getDownloadUrl(objectKey: string): Promise<string> { async getDownloadUrl(objectKey: string): Promise<string> {
@@ -80,8 +88,12 @@ export class StorageService {
return `${base}/${path}`; return `${base}/${path}`;
} }
const command = new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }); try {
return getSignedUrl(this.client!, command, { expiresIn: this.presignExpiresSeconds }); const command = new GetObjectCommand({ Bucket: this.bucket, Key: objectKey });
return await getSignedUrl(this.client!, command, { expiresIn: this.presignExpiresSeconds });
} catch (err) {
throw interpretS3Failure('GetObject (presign)', err);
}
} }
buildEquipmentObjectKey(equipmentId: string, originalName: string): string { buildEquipmentObjectKey(equipmentId: string, originalName: string): string {
@@ -95,3 +107,39 @@ function sanitizeFileName(name: string): string {
const base = name.replace(/[/\\]/g, '_').trim() || 'file'; const base = name.replace(/[/\\]/g, '_').trim() || 'file';
return base.slice(0, 200); return base.slice(0, 200);
} }
/**
* AWS SDK expects XML from S3-compatible APIs. A JSON/HTML 404 (e.g. wrong reverse-proxy URL)
* surfaces as fast-xml-parser "char '{' is not expected".
*/
function interpretS3Failure(operation: string, err: unknown): never {
if (err instanceof HttpException) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
const status = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
const code = (err as { Code?: string; name?: string }).Code ?? (err as { name?: string }).name;
const looksLikeNonXml =
message.includes("char '{'") ||
message.includes('Deserialization error') ||
message.includes('fast-xml-parser');
if (looksLikeNonXml || status === 404) {
throw new ServiceUnavailableException(
`Хранилище S3/MinIO вернуло ответ не в формате S3 XML (${operation}, HTTP ${status ?? '?' }). ` +
'Обычно это неверный S3_ENDPOINT: укажите API MinIO внутри Docker-сети, например ' +
'`http://<имя-контейнера-minio>:9000` (порт S3 API), а не публичный HTTPS URL NPM, если он отдаёт JSON/HTML 404. ' +
'Проверьте также S3_BUCKET и что контейнер toir-light-server в одной сети с MinIO.',
);
}
if (code === 'NoSuchBucket' || message.includes('NoSuchBucket')) {
throw new ServiceUnavailableException(
`Бакет «${process.env.S3_BUCKET}» не найден в MinIO (${operation}). Создайте бакет или поправьте S3_BUCKET.`,
);
}
throw new ServiceUnavailableException(`S3 ${operation}: ${message}`);
}