Add file attachment functionality for equipment management

- Introduced DTO for file attachments with metadata (objectKey, originalFileName, contentType, sizeBytes, downloadUrl).
- Updated Equipment DTO to include an optional attachment field.
- Implemented endpoints for uploading and deleting equipment attachments.
- Enhanced Equipment service to handle file storage and retrieval using S3.
- Updated EquipmentEdit, EquipmentList, and EquipmentShow components to support file attachment input and display.
- Configured S3 settings in docker-compose and environment files.
This commit is contained in:
Первов Артем
2026-04-21 00:46:17 +03:00
parent 62446f2597
commit b60c4ee0ed
19 changed files with 2274 additions and 38 deletions

View File

@@ -4,6 +4,72 @@
], ],
"enums": [], "enums": [],
"dtos": [ "dtos": [
{
"name": "DTO.FileAttachment",
"description": "Метаданные файла в объектном хранилище (MinIO/S3); бинарные данные в БД не хранятся",
"fields": [
{
"name": "objectKey",
"type": "string",
"required": false,
"nullable": false,
"unique": false,
"primary": false,
"description": "Ключ объекта в бакете",
"map": null,
"sync": false,
"label": null
},
{
"name": "originalFileName",
"type": "string",
"required": false,
"nullable": true,
"unique": false,
"primary": false,
"description": "Исходное имя файла",
"map": null,
"sync": false,
"label": null
},
{
"name": "contentType",
"type": "string",
"required": false,
"nullable": true,
"unique": false,
"primary": false,
"description": "MIME-тип",
"map": null,
"sync": false,
"label": null
},
{
"name": "sizeBytes",
"type": "integer",
"required": false,
"nullable": true,
"unique": false,
"primary": false,
"description": "Размер в байтах",
"map": null,
"sync": false,
"label": null
},
{
"name": "downloadUrl",
"type": "string",
"required": false,
"nullable": true,
"unique": false,
"primary": false,
"description": "Ссылка на скачивание (публичная или presigned)",
"map": null,
"sync": false,
"label": null
}
]
},
{ {
"name": "DTO.Equipment", "name": "DTO.Equipment",
"description": "Полный response-объект для единицы оборудования", "description": "Полный response-объект для единицы оборудования",
@@ -79,6 +145,18 @@
"map": "Equipment.status", "map": "Equipment.status",
"sync": false, "sync": false,
"label": null "label": null
},
{
"name": "attachment",
"type": "DTO.FileAttachment",
"required": false,
"nullable": true,
"unique": false,
"primary": false,
"description": "Вложение (файл в MinIO/S3)",
"map": "Equipment.attachment",
"sync": false,
"label": null
} }
] ]
}, },
@@ -682,6 +760,34 @@
"description": null "description": null
} }
] ]
},
{
"name": "uploadEquipmentAttachment",
"label": "POST /equipment/{id}/attachment",
"method": "POST",
"path": "/equipment/{id}/attachment",
"description": "Загрузить файл-вложение (multipart/form-data, поле file)",
"attributes": [
{
"name": "id",
"type": "uuid",
"description": null
}
]
},
{
"name": "deleteEquipmentAttachment",
"label": "DELETE /equipment/{id}/attachment",
"method": "DELETE",
"path": "/equipment/{id}/attachment",
"description": "Удалить файл-вложение из хранилища и из записи",
"attributes": [
{
"name": "id",
"type": "uuid",
"description": null
}
]
} }
] ]
}, },

11
client/src/lib/apiBase.ts Normal file
View File

@@ -0,0 +1,11 @@
import { env } from '../config/env';
/** Resolves VITE_API_URL like `/api` to an absolute origin URL. */
export function resolveApiBaseUrl(): string {
const base = env.apiUrl;
if (base.startsWith('http://') || base.startsWith('https://')) {
return base.replace(/\/$/, '');
}
const path = base.startsWith('/') ? base : `/${base}`;
return `${window.location.origin.replace(/\/$/, '')}${path}`.replace(/\/$/, '');
}

View File

@@ -0,0 +1,109 @@
import { Button, Stack, Typography } from '@mui/material';
import { useNotify, useRecordContext, useRefresh } from 'react-admin';
import { useCallback, useState, type ChangeEvent } from 'react';
import { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
import { resolveApiBaseUrl } from '../../lib/apiBase';
export type FileAttachmentValue = {
objectKey?: string;
originalFileName?: string | null;
contentType?: string | null;
sizeBytes?: number | null;
downloadUrl?: string | null;
} | null;
export function EquipmentAttachmentInput() {
const record = useRecordContext();
const notify = useNotify();
const refresh = useRefresh();
const [busy, setBusy] = useState(false);
const upload = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file || !record?.id) {
return;
}
setBusy(true);
try {
await ensureFreshToken();
const token = getAccessToken();
const form = new FormData();
form.append('file', file);
const url = `${resolveApiBaseUrl()}/equipment/${record.id}/attachment`;
const response = await fetch(url, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
body: form,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
notify('Файл сохранён', { type: 'success' });
refresh();
} catch {
notify('Не удалось загрузить файл', { type: 'warning' });
} finally {
setBusy(false);
}
},
[notify, refresh, record?.id],
);
const remove = useCallback(async () => {
if (!record?.id) {
return;
}
setBusy(true);
try {
await ensureFreshToken();
const token = getAccessToken();
const url = `${resolveApiBaseUrl()}/equipment/${record.id}/attachment`;
const response = await fetch(url, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}`, Accept: 'application/json' } : { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
notify('Вложение удалено', { type: 'success' });
refresh();
} catch {
notify('Не удалось удалить файл', { type: 'warning' });
} finally {
setBusy(false);
}
}, [notify, refresh, record?.id]);
const att = (record as { attachment?: FileAttachmentValue })?.attachment;
return (
<Stack spacing={1} sx={{ maxWidth: 480 }}>
<Typography component="label" variant="body2">
Вложение (файл в MinIO)
</Typography>
{att?.downloadUrl ? (
<Typography variant="body2">
<a href={att.downloadUrl} target="_blank" rel="noreferrer">
{att.originalFileName || 'Скачать файл'}
</a>
{att.sizeBytes != null ? ` (${Math.round(att.sizeBytes / 1024)} КБ)` : null}
</Typography>
) : (
<Typography variant="body2" color="text.secondary">
Файл не прикреплён
</Typography>
)}
<Stack direction="row" spacing={1} alignItems="center">
<Button variant="outlined" component="label" disabled={busy || !record?.id}>
Выбрать файл
<input type="file" hidden onChange={upload} />
</Button>
<Button variant="text" color="error" disabled={busy || !att?.objectKey} onClick={remove}>
Удалить
</Button>
</Stack>
</Stack>
);
}

View File

@@ -1,4 +1,5 @@
import { DateInput, Edit, SelectInput, SimpleForm, TextInput as RaTextInput } from 'react-admin'; import { DateInput, Edit, SelectInput, SimpleForm, TextInput as RaTextInput } from 'react-admin';
import { EquipmentAttachmentInput } from './EquipmentAttachmentInput';
import { equipmentStatusChoices } from './shared'; import { equipmentStatusChoices } from './shared';
const equipmentLabels = { const equipmentLabels = {
@@ -18,6 +19,7 @@ export function EquipmentEdit() {
<RaTextInput source="name" label={equipmentLabels.name} /> <RaTextInput source="name" label={equipmentLabels.name} />
<RaTextInput source="serialNumber" label={equipmentLabels.serialNumber} /> <RaTextInput source="serialNumber" label={equipmentLabels.serialNumber} />
<SelectInput source="status" label={equipmentLabels.status} choices={equipmentStatusChoices} /> <SelectInput source="status" label={equipmentLabels.status} choices={equipmentStatusChoices} />
<EquipmentAttachmentInput />
</SimpleForm> </SimpleForm>
</Edit> </Edit>
); );

View File

@@ -3,6 +3,7 @@ import {
Datagrid, Datagrid,
DateField, DateField,
FilterButton, FilterButton,
FunctionField,
List, List,
SelectArrayInput, SelectArrayInput,
SelectField, SelectField,
@@ -34,6 +35,10 @@ export function EquipmentList() {
<DateField source="dateOfInspection" /> <DateField source="dateOfInspection" />
<DateField source="commissionedAt" /> <DateField source="commissionedAt" />
<SelectField source="status" choices={equipmentStatusChoices} /> <SelectField source="status" choices={equipmentStatusChoices} />
<FunctionField
label="Файл"
render={(record: { attachment?: { objectKey?: string } | null }) => (record?.attachment?.objectKey ? '✓' : '—')}
/>
</Datagrid> </Datagrid>
</List> </List>
); );

View File

@@ -1,4 +1,4 @@
import { DateField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin'; import { DateField, FunctionField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
import { equipmentStatusChoices } from './shared'; import { equipmentStatusChoices } from './shared';
export function EquipmentShow() { export function EquipmentShow() {
@@ -11,6 +11,18 @@ export function EquipmentShow() {
<DateField source="dateOfInspection" /> <DateField source="dateOfInspection" />
<DateField source="commissionedAt" /> <DateField source="commissionedAt" />
<SelectField source="status" choices={equipmentStatusChoices} /> <SelectField source="status" choices={equipmentStatusChoices} />
<FunctionField
label="Вложение"
render={(record: { attachment?: { downloadUrl?: string | null; originalFileName?: string | null } | null }) =>
record?.attachment?.downloadUrl ? (
<a href={record.attachment.downloadUrl} target="_blank" rel="noreferrer">
{record.attachment.originalFileName || 'Скачать'}
</a>
) : (
'—'
)
}
/>
</SimpleShowLayout> </SimpleShowLayout>
</Show> </Show>
); );

View File

@@ -38,6 +38,14 @@ services:
KEYCLOAK_ISSUER_URL: ${KEYCLOAK_ISSUER_URL:-https://sso.greact.ru/realms/toir} KEYCLOAK_ISSUER_URL: ${KEYCLOAK_ISSUER_URL:-https://sso.greact.ru/realms/toir}
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE:-toir-backend} KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE:-toir-backend}
KEYCLOAK_JWKS_URL: ${KEYCLOAK_JWKS_URL:-} KEYCLOAK_JWKS_URL: ${KEYCLOAK_JWKS_URL:-}
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-eu-central-1}
S3_BUCKET: ${S3_BUCKET:-media}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-true}
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
S3_OBJECT_PREFIX: ${S3_OBJECT_PREFIX:-toir-light/equipment}
healthcheck: healthcheck:
test: test:
[ [

View File

@@ -1,3 +1,31 @@
dto DTO.FileAttachment {
description "Метаданные файла в объектном хранилище (MinIO/S3); бинарные данные в БД не хранятся";
attribute objectKey {
type string;
description "Ключ объекта в бакете";
}
attribute originalFileName {
type string;
is nullable;
description "Исходное имя файла";
}
attribute contentType {
type string;
is nullable;
description "MIME-тип";
}
attribute sizeBytes {
type integer;
is nullable;
description "Размер в байтах";
}
attribute downloadUrl {
type string;
is nullable;
description "Ссылка на скачивание (публичная или presigned)";
}
}
dto DTO.Equipment { dto DTO.Equipment {
description "Полный response-объект для единицы оборудования"; description "Полный response-объект для единицы оборудования";
attribute id { attribute id {
@@ -31,6 +59,12 @@ dto DTO.Equipment {
description "Текущий статус"; description "Текущий статус";
map Equipment.status; map Equipment.status;
} }
attribute attachment {
type DTO.FileAttachment;
is nullable;
description "Вложение (файл в MinIO/S3)";
map Equipment.attachment;
}
} }
dto DTO.EquipmentCreate { dto DTO.EquipmentCreate {
@@ -311,6 +345,22 @@ api API.Equipment {
type uuid; type uuid;
} }
} }
endpoint uploadEquipmentAttachment {
label "POST /equipment/{id}/attachment";
description "Загрузить файл-вложение (multipart/form-data, поле file)";
attribute id {
type uuid;
}
}
endpoint deleteEquipmentAttachment {
label "DELETE /equipment/{id}/attachment";
description "Удалить файл-вложение из хранилища и из записи";
attribute id {
type uuid;
}
}
} }
api API.EquipmentStatusChange { api API.EquipmentStatusChange {

View File

@@ -4,3 +4,16 @@ CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru"
KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir" KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir"
KEYCLOAK_AUDIENCE="toir-backend" KEYCLOAK_AUDIENCE="toir-backend"
KEYCLOAK_JWKS_URL="" KEYCLOAK_JWKS_URL=""
# MinIO / S3 (вложения оборудования)
S3_ENDPOINT="http://localhost:9000"
S3_REGION="eu-central-1"
S3_BUCKET="media"
S3_ACCESS_KEY_ID=""
S3_SECRET_ACCESS_KEY=""
S3_FORCE_PATH_STYLE="true"
# Если бакет публичный: базовый URL для прямых ссылок (без presign)
S3_PUBLIC_BASE_URL=""
# Префикс ключей объектов в бакете
S3_OBJECT_PREFIX="toir-light/equipment"
S3_PRESIGN_EXPIRES_SECONDS="3600"

1711
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,8 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1033.0",
"@aws-sdk/s3-request-presigner": "^3.1033.0",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3", "@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
@@ -30,6 +32,7 @@
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"dotenv": "^17.4.0", "dotenv": "^17.4.0",
"jose": "^6.2.2", "jose": "^6.2.2",
"multer": "^2.1.1",
"pg": "^8.20.0", "pg": "^8.20.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
@@ -42,6 +45,7 @@
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^2.1.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Equipment" ADD COLUMN IF NOT EXISTS "attachment" JSONB;

View File

@@ -20,6 +20,8 @@ model Equipment {
dateOfInspection DateTime? dateOfInspection DateTime?
commissionedAt DateTime? commissionedAt DateTime?
status EquipmentStatus @default(Active) status EquipmentStatus @default(Active)
/// JSON: { objectKey, originalFileName, contentType, sizeBytes } — файл в MinIO/S3
attachment Json?
changeEquipmentStatuses ChangeEquipmentStatus[] changeEquipmentStatuses ChangeEquipmentStatus[]
} }

View File

@@ -0,0 +1,14 @@
export type EquipmentAttachmentStored = {
objectKey: string;
originalFileName: string;
contentType: string;
sizeBytes: number;
};
export function isEquipmentAttachmentStored(value: unknown): value is EquipmentAttachmentStored {
if (!value || typeof value !== 'object') {
return false;
}
const o = value as Record<string, unknown>;
return typeof o.objectKey === 'string' && o.objectKey.length > 0;
}

View File

@@ -8,9 +8,13 @@ import {
Post, Post,
Query, Query,
Res, Res,
UploadedFile,
UseGuards, UseGuards,
UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express'; import type { Response } from 'express';
import { memoryStorage } from 'multer';
import { Roles } from '../../auth/decorators/roles.decorator'; import { Roles } from '../../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../auth/guards/roles.guard'; import { RolesGuard } from '../../auth/guards/roles.guard';
@@ -29,6 +33,24 @@ export class EquipmentController {
return this.equipmentService.findAll(query, response); return this.equipmentService.findAll(query, response);
} }
@Roles('editor', 'admin')
@Post(':id/attachment')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 },
}),
)
uploadAttachment(@Param('id') id: string, @UploadedFile() file: Express.Multer.File) {
return this.equipmentService.uploadAttachment(id, file);
}
@Roles('editor', 'admin')
@Delete(':id/attachment')
removeAttachment(@Param('id') id: string) {
return this.equipmentService.removeAttachment(id);
}
@Roles('viewer', 'editor', 'admin') @Roles('viewer', 'editor', 'admin')
@Get(':id') @Get(':id')
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { StorageModule } from '../../storage/storage.module';
import { EquipmentController } from './equipment.controller'; import { EquipmentController } from './equipment.controller';
import { EquipmentService } from './equipment.service'; import { EquipmentService } from './equipment.service';
@Module({ @Module({
imports: [AuthModule], imports: [AuthModule, StorageModule],
controllers: [EquipmentController], controllers: [EquipmentController],
providers: [EquipmentService], providers: [EquipmentService],
}) })

View File

@@ -1,14 +1,27 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { EquipmentStatus, Prisma } from '@prisma/client'; import { EquipmentStatus, Prisma } from '@prisma/client';
import type { Equipment } from '@prisma/client';
import { Response } from 'express'; import { Response } from 'express';
import type { Express } from 'express';
import { setListHeaders } from '../../common/http'; import { setListHeaders } from '../../common/http';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import type { StoredAttachmentMeta } from '../../storage/storage.service';
import { StorageService } from '../../storage/storage.service';
import { CreateEquipmentDto } from './dto/create-equipment.dto'; import { CreateEquipmentDto } from './dto/create-equipment.dto';
import { UpdateEquipmentDto } from './dto/update-equipment.dto'; import { UpdateEquipmentDto } from './dto/update-equipment.dto';
import { isEquipmentAttachmentStored } from './attachment.types';
@Injectable() @Injectable()
export class EquipmentService { export class EquipmentService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
) {}
async findAll(query: Record<string, unknown>, response: Response) { async findAll(query: Record<string, unknown>, response: Response) {
const start = Number(query._start ?? 0); const start = Number(query._start ?? 0);
@@ -51,7 +64,7 @@ export class EquipmentService {
]); ]);
setListHeaders(response, total, start, end); setListHeaders(response, total, start, end);
return items.map((item) => this.toRecord(item)); return Promise.all(items.map((item) => this.toRecord(item)));
} }
async findOne(id: string) { async findOne(id: string) {
@@ -94,19 +107,88 @@ export class EquipmentService {
} }
async remove(id: string) { async remove(id: string) {
await this.findOne(id); const item = await this.prisma.equipment.findUnique({ where: { id } });
if (!item) {
throw new NotFoundException(`Equipment ${id} not found`);
}
await this.deleteStoredAttachmentIfAny(item);
const deleted = await this.prisma.equipment.delete({ where: { id } }); const deleted = await this.prisma.equipment.delete({ where: { id } });
return this.toRecord(deleted); return this.toRecord(deleted);
} }
private toRecord(item: { async uploadAttachment(id: string, file: Express.Multer.File | undefined) {
id: string; if (!file?.buffer?.length) {
name: string; throw new BadRequestException('File is required (multipart field "file").');
serialNumber: string; }
dateOfInspection: Date | null; if (!this.storage.isConfigured()) {
commissionedAt: Date | null; throw new ServiceUnavailableException(
status: EquipmentStatus; 'Object storage is not configured (set S3_* environment variables).',
}) { );
}
const item = await this.prisma.equipment.findUnique({ where: { id } });
if (!item) {
throw new NotFoundException(`Equipment ${id} not found`);
}
await this.deleteStoredAttachmentIfAny(item);
const objectKey = this.storage.buildEquipmentObjectKey(id, file.originalname);
const meta: StoredAttachmentMeta = {
objectKey,
originalFileName: file.originalname,
contentType: file.mimetype || 'application/octet-stream',
sizeBytes: file.size,
};
await this.storage.putObject(objectKey, file.buffer, meta.contentType);
const updated = await this.prisma.equipment.update({
where: { id },
data: { attachment: meta as unknown as Prisma.InputJsonValue },
});
return this.toRecord(updated);
}
async removeAttachment(id: string) {
if (!this.storage.isConfigured()) {
throw new ServiceUnavailableException(
'Object storage is not configured (set S3_* environment variables).',
);
}
const item = await this.prisma.equipment.findUnique({ where: { id } });
if (!item) {
throw new NotFoundException(`Equipment ${id} not found`);
}
await this.deleteStoredAttachmentIfAny(item);
const updated = await this.prisma.equipment.update({
where: { id },
data: { attachment: Prisma.JsonNull },
});
return this.toRecord(updated);
}
private async deleteStoredAttachmentIfAny(item: Equipment) {
const raw = item.attachment;
if (!isEquipmentAttachmentStored(raw)) {
return;
}
if (!this.storage.isConfigured()) {
return;
}
try {
await this.storage.deleteObject(raw.objectKey);
} catch {
// best-effort: DB will still clear / overwrite
}
}
private async toRecord(item: Equipment) {
return { return {
id: item.id, id: item.id,
name: item.name, name: item.name,
@@ -114,6 +196,31 @@ export class EquipmentService {
dateOfInspection: item.dateOfInspection?.toISOString() ?? null, dateOfInspection: item.dateOfInspection?.toISOString() ?? null,
commissionedAt: item.commissionedAt?.toISOString() ?? null, commissionedAt: item.commissionedAt?.toISOString() ?? null,
status: item.status, status: item.status,
attachment: await this.serializeAttachment(item.attachment),
};
}
private async serializeAttachment(raw: Prisma.JsonValue | null) {
if (raw === null || raw === undefined) {
return null;
}
if (!isEquipmentAttachmentStored(raw)) {
return null;
}
let downloadUrl: string | null = null;
if (this.storage.isConfigured()) {
try {
downloadUrl = await this.storage.getDownloadUrl(raw.objectKey);
} catch {
downloadUrl = null;
}
}
return {
objectKey: raw.objectKey,
originalFileName: raw.originalFileName,
contentType: raw.contentType,
sizeBytes: raw.sizeBytes,
downloadUrl,
}; };
} }
} }

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { StorageService } from './storage.service';
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View File

@@ -0,0 +1,97 @@
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 { randomUUID } from 'node:crypto';
export type StoredAttachmentMeta = {
objectKey: string;
originalFileName: string;
contentType: string;
sizeBytes: number;
};
@Injectable()
export class StorageService {
private readonly client: S3Client | null;
private readonly bucket: string;
private readonly publicBaseUrl: string | null;
private readonly presignExpiresSeconds: number;
constructor() {
const endpoint = process.env.S3_ENDPOINT?.trim();
const region = process.env.S3_REGION?.trim() || 'us-east-1';
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim();
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim();
this.bucket = process.env.S3_BUCKET?.trim() || '';
const forcePathStyle = (process.env.S3_FORCE_PATH_STYLE ?? 'true').toLowerCase() === 'true';
this.publicBaseUrl = process.env.S3_PUBLIC_BASE_URL?.trim() || null;
this.presignExpiresSeconds = Number(process.env.S3_PRESIGN_EXPIRES_SECONDS ?? '3600');
if (!endpoint || !accessKeyId || !secretAccessKey || !this.bucket) {
this.client = null;
return;
}
this.client = new S3Client({
region,
endpoint,
credentials: { accessKeyId, secretAccessKey },
forcePathStyle,
});
}
isConfigured(): boolean {
return this.client !== null;
}
assertConfigured() {
if (!this.client) {
throw new ServiceUnavailableException(
'Object storage is not configured (set S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY).',
);
}
}
async putObject(key: string, body: Buffer, contentType: string): Promise<void> {
this.assertConfigured();
await this.client!.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: body,
ContentType: contentType || 'application/octet-stream',
}),
);
}
async deleteObject(key: string): Promise<void> {
this.assertConfigured();
await this.client!.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
}
async getDownloadUrl(objectKey: string): Promise<string> {
this.assertConfigured();
if (this.publicBaseUrl) {
const base = this.publicBaseUrl.replace(/\/$/, '');
const path = objectKey
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
return `${base}/${path}`;
}
const command = new GetObjectCommand({ Bucket: this.bucket, Key: objectKey });
return getSignedUrl(this.client!, command, { expiresIn: this.presignExpiresSeconds });
}
buildEquipmentObjectKey(equipmentId: string, originalName: string): string {
const prefix = (process.env.S3_OBJECT_PREFIX ?? 'toir-light/equipment').replace(/^\/+|\/+$/g, '');
const safe = sanitizeFileName(originalName);
return `${prefix}/${equipmentId}/${randomUUID()}-${safe}`;
}
}
function sanitizeFileName(name: string): string {
const base = name.replace(/[/\\]/g, '_').trim() || 'file';
return base.slice(0, 200);
}