Add download functionality for equipment attachments

- Introduced a new endpoint `GET /equipment/{id}/attachment/download` in the API for downloading equipment attachment files.
- Implemented the `downloadEquipmentAttachmentFile` function in the client to handle file downloads via the API, ensuring proper token management and blob handling.
- Updated the EquipmentAttachmentInput, EquipmentList, and EquipmentShow components to utilize the new download link, enhancing user experience by allowing direct downloads without exposing the MinIO URL.
- Added a new EquipmentAttachmentLink component to encapsulate the download link logic and improve code reusability.
This commit is contained in:
Первов Артем
2026-04-21 01:26:00 +03:00
parent eb36c04a4b
commit 4584a0d581
10 changed files with 184 additions and 11 deletions

View File

@@ -8,6 +8,7 @@ import {
Post,
Query,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
@@ -51,6 +52,19 @@ export class EquipmentController {
return this.equipmentService.removeAttachment(id);
}
@Roles('viewer', 'editor', 'admin')
@Get(':id/attachment/download')
async downloadAttachment(@Param('id') id: string): Promise<StreamableFile> {
const { stream, contentType, fileName, contentLength } =
await this.equipmentService.getAttachmentDownloadStream(id);
const encoded = encodeURIComponent(fileName);
return new StreamableFile(stream, {
type: contentType,
disposition: `attachment; filename*=UTF-8''${encoded}`,
length: contentLength,
});
}
@Roles('viewer', 'editor', 'admin')
@Get(':id')
findOne(@Param('id') id: string) {

View File

@@ -13,6 +13,7 @@ import { setListHeaders } from '../../common/http';
import { PrismaService } from '../../prisma/prisma.service';
import type { StoredAttachmentMeta } from '../../storage/storage.service';
import { StorageService } from '../../storage/storage.service';
import type { Readable } from 'node:stream';
import { CreateEquipmentDto } from './dto/create-equipment.dto';
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
import { isEquipmentAttachmentStored } from './attachment.types';
@@ -153,6 +154,40 @@ export class EquipmentService {
return this.toRecord(updated);
}
async getAttachmentDownloadStream(id: string): Promise<{
stream: Readable;
contentType: string;
fileName: string;
contentLength?: number;
}> {
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`);
}
if (!isEquipmentAttachmentStored(item.attachment)) {
throw new NotFoundException(`Вложение для оборудования ${id} не найдено`);
}
const raw = item.attachment;
const fileName = decodeUtf8FilenameFromMultipart(raw.originalFileName) || 'file';
const { stream, contentType, contentLength } = await this.storage.getObjectStream(raw.objectKey);
const len =
typeof contentLength === 'bigint' ? Number(contentLength) : contentLength;
return {
stream,
contentType: raw.contentType || contentType,
fileName,
contentLength: len,
};
}
async removeAttachment(id: string) {
if (!this.storage.isConfigured()) {
throw new ServiceUnavailableException(

View File

@@ -2,6 +2,7 @@ import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } fro
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { HttpException, Injectable, ServiceUnavailableException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { Readable } from 'node:stream';
export type StoredAttachmentMeta = {
objectKey: string;
@@ -77,6 +78,30 @@ export class StorageService {
}
}
async getObjectStream(objectKey: string): Promise<{
stream: Readable;
contentType: string;
contentLength?: number;
}> {
this.assertConfigured();
try {
const out = await this.client!.send(
new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }),
);
if (!out.Body) {
throw new ServiceUnavailableException('S3 GetObject: пустое тело ответа');
}
const stream = out.Body as Readable;
return {
stream,
contentType: out.ContentType || 'application/octet-stream',
contentLength: out.ContentLength,
};
} catch (err) {
throw interpretS3Failure('GetObject', err);
}
}
async getDownloadUrl(objectKey: string): Promise<string> {
this.assertConfigured();
if (this.publicBaseUrl) {