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:
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user