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:
@@ -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"
|
||||||
|
|||||||
@@ -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,6 +54,7 @@ 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();
|
||||||
|
try {
|
||||||
await this.client!.send(
|
await this.client!.send(
|
||||||
new PutObjectCommand({
|
new PutObjectCommand({
|
||||||
Bucket: this.bucket,
|
Bucket: this.bucket,
|
||||||
@@ -62,11 +63,18 @@ export class StorageService {
|
|||||||
ContentType: contentType || 'application/octet-stream',
|
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();
|
||||||
|
try {
|
||||||
await this.client!.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const command = new GetObjectCommand({ Bucket: this.bucket, Key: objectKey });
|
const command = new GetObjectCommand({ Bucket: this.bucket, Key: objectKey });
|
||||||
return getSignedUrl(this.client!, command, { expiresIn: this.presignExpiresSeconds });
|
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}`);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user