diff --git a/server/.env.example b/server/.env.example index 4cacb95..0f7a80a 100644 --- a/server/.env.example +++ b/server/.env.example @@ -6,6 +6,7 @@ KEYCLOAK_AUDIENCE="toir-backend" KEYCLOAK_JWKS_URL="" # MinIO / S3 (вложения оборудования) +# В Docker: имя сервиса/контейнера MinIO и порт API S3 (обычно 9000), НЕ URL консоли (9001) и не NPM, если он отдаёт не S3 XML. S3_ENDPOINT="http://localhost:9000" S3_REGION="eu-central-1" S3_BUCKET="media" diff --git a/server/src/storage/storage.service.ts b/server/src/storage/storage.service.ts index 7f42adf..99bce9e 100644 --- a/server/src/storage/storage.service.ts +++ b/server/src/storage/storage.service.ts @@ -1,6 +1,6 @@ import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; 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'; export type StoredAttachmentMeta = { @@ -54,19 +54,27 @@ export class StorageService { async putObject(key: string, body: Buffer, contentType: string): Promise { this.assertConfigured(); - await this.client!.send( - new PutObjectCommand({ - Bucket: this.bucket, - Key: key, - Body: body, - ContentType: contentType || 'application/octet-stream', - }), - ); + try { + await this.client!.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: body, + ContentType: contentType || 'application/octet-stream', + }), + ); + } catch (err) { + throw interpretS3Failure('PutObject', err); + } } async deleteObject(key: string): Promise { 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 { @@ -80,8 +88,12 @@ export class StorageService { return `${base}/${path}`; } - const command = new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }); - return getSignedUrl(this.client!, command, { expiresIn: this.presignExpiresSeconds }); + try { + 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 { @@ -95,3 +107,39 @@ function sanitizeFileName(name: string): string { const base = name.replace(/[/\\]/g, '_').trim() || 'file'; 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}`); +}