From 4584a0d58102ee1cf7328e9d5fdf3f77c55aa3b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Tue, 21 Apr 2026 01:26:00 +0300 Subject: [PATCH] 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. --- api-summary.json | 14 ++++++++ .../equipment/EquipmentAttachmentInput.tsx | 12 ++++--- .../equipment/EquipmentAttachmentLink.tsx | 31 ++++++++++++++++ .../src/resources/equipment/EquipmentList.tsx | 15 +++++++- .../src/resources/equipment/EquipmentShow.tsx | 15 +++++--- .../resources/equipment/attachmentDownload.ts | 26 ++++++++++++++ domain/toir.api.dsl | 8 +++++ .../modules/equipment/equipment.controller.ts | 14 ++++++++ .../modules/equipment/equipment.service.ts | 35 +++++++++++++++++++ server/src/storage/storage.service.ts | 25 +++++++++++++ 10 files changed, 184 insertions(+), 11 deletions(-) create mode 100644 client/src/resources/equipment/EquipmentAttachmentLink.tsx create mode 100644 client/src/resources/equipment/attachmentDownload.ts diff --git a/api-summary.json b/api-summary.json index 9c8323c..7d0b7e2 100644 --- a/api-summary.json +++ b/api-summary.json @@ -788,6 +788,20 @@ "description": null } ] + }, + { + "name": "downloadEquipmentAttachment", + "label": "GET /equipment/{id}/attachment/download", + "method": "GET", + "path": "/equipment/{id}/attachment/download", + "description": "Скачать файл-вложение (Content-Disposition: attachment, поток из MinIO)", + "attributes": [ + { + "name": "id", + "type": "uuid", + "description": null + } + ] } ] }, diff --git a/client/src/resources/equipment/EquipmentAttachmentInput.tsx b/client/src/resources/equipment/EquipmentAttachmentInput.tsx index a2702ed..0c840f7 100644 --- a/client/src/resources/equipment/EquipmentAttachmentInput.tsx +++ b/client/src/resources/equipment/EquipmentAttachmentInput.tsx @@ -3,6 +3,7 @@ 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'; +import { EquipmentAttachmentLink } from './EquipmentAttachmentLink'; export type FileAttachmentValue = { objectKey?: string; @@ -83,11 +84,12 @@ export function EquipmentAttachmentInput() { Вложение (файл в MinIO) - {att?.downloadUrl ? ( - - - {att.originalFileName || 'Скачать файл'} - + {att?.objectKey ? ( + + {att.sizeBytes != null ? ` (${Math.round(att.sizeBytes / 1024)} КБ)` : null} ) : ( diff --git a/client/src/resources/equipment/EquipmentAttachmentLink.tsx b/client/src/resources/equipment/EquipmentAttachmentLink.tsx new file mode 100644 index 0000000..d7d5aae --- /dev/null +++ b/client/src/resources/equipment/EquipmentAttachmentLink.tsx @@ -0,0 +1,31 @@ +import { Link } from '@mui/material'; +import { useNotify } from 'react-admin'; +import { downloadEquipmentAttachmentFile } from './attachmentDownload'; + +type Props = { + equipmentId: string; + /** Текст ссылки и имя при сохранении в браузере */ + fileName: string; +}; + +export function EquipmentAttachmentLink({ equipmentId, fileName }: Props) { + const notify = useNotify(); + const label = fileName.trim() || 'Скачать'; + + return ( + { + e.preventDefault(); + e.stopPropagation(); + void downloadEquipmentAttachmentFile(equipmentId, label).catch(() => + notify('Не удалось скачать файл', { type: 'warning' }), + ); + }} + sx={{ cursor: 'pointer', textAlign: 'left', verticalAlign: 'inherit' }} + > + {label} + + ); +} diff --git a/client/src/resources/equipment/EquipmentList.tsx b/client/src/resources/equipment/EquipmentList.tsx index 3814ade..e795377 100644 --- a/client/src/resources/equipment/EquipmentList.tsx +++ b/client/src/resources/equipment/EquipmentList.tsx @@ -11,6 +11,7 @@ import { TextInput, TopToolbar, } from 'react-admin'; +import { EquipmentAttachmentLink } from './EquipmentAttachmentLink'; import { equipmentStatusChoices } from './shared'; const equipmentFilters = [ @@ -37,7 +38,19 @@ export function EquipmentList() { (record?.attachment?.objectKey ? '✓' : '—')} + render={(record: { + id: string; + attachment?: { objectKey?: string; originalFileName?: string | null } | null; + }) => + record?.attachment?.objectKey ? ( + + ) : ( + '—' + ) + } /> diff --git a/client/src/resources/equipment/EquipmentShow.tsx b/client/src/resources/equipment/EquipmentShow.tsx index 7003c51..076fa07 100644 --- a/client/src/resources/equipment/EquipmentShow.tsx +++ b/client/src/resources/equipment/EquipmentShow.tsx @@ -1,4 +1,5 @@ import { DateField, FunctionField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin'; +import { EquipmentAttachmentLink } from './EquipmentAttachmentLink'; import { equipmentStatusChoices } from './shared'; export function EquipmentShow() { @@ -13,11 +14,15 @@ export function EquipmentShow() { - record?.attachment?.downloadUrl ? ( - - {record.attachment.originalFileName || 'Скачать'} - + render={(record: { + id: string; + attachment?: { objectKey?: string | null; originalFileName?: string | null } | null; + }) => + record?.attachment?.objectKey ? ( + ) : ( '—' ) diff --git a/client/src/resources/equipment/attachmentDownload.ts b/client/src/resources/equipment/attachmentDownload.ts new file mode 100644 index 0000000..d4aa2ba --- /dev/null +++ b/client/src/resources/equipment/attachmentDownload.ts @@ -0,0 +1,26 @@ +import { ensureFreshToken, getAccessToken } from '../../auth/keycloak'; +import { resolveApiBaseUrl } from '../../lib/apiBase'; + +/** Скачивание через API (JWT + Content-Disposition), без открытия URL MinIO в браузере. */ +export async function downloadEquipmentAttachmentFile(equipmentId: string, suggestedFileName: string): Promise { + await ensureFreshToken(); + const token = getAccessToken(); + const url = `${resolveApiBaseUrl()}/equipment/${equipmentId}/attachment/download`; + const response = await fetch(url, { + method: 'GET', + headers: token ? { Authorization: `Bearer ${token}`, Accept: '*/*' } : { Accept: '*/*' }, + }); + if (!response.ok) { + throw new Error(`Download failed: ${response.status}`); + } + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objectUrl; + a.download = suggestedFileName || 'download'; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(objectUrl); +} diff --git a/domain/toir.api.dsl b/domain/toir.api.dsl index 8e7b156..2867e05 100644 --- a/domain/toir.api.dsl +++ b/domain/toir.api.dsl @@ -361,6 +361,14 @@ api API.Equipment { type uuid; } } + + endpoint downloadEquipmentAttachment { + label "GET /equipment/{id}/attachment/download"; + description "Скачать файл-вложение (Content-Disposition: attachment, поток из MinIO)"; + attribute id { + type uuid; + } + } } api API.EquipmentStatusChange { diff --git a/server/src/modules/equipment/equipment.controller.ts b/server/src/modules/equipment/equipment.controller.ts index 445d702..e8c18fa 100644 --- a/server/src/modules/equipment/equipment.controller.ts +++ b/server/src/modules/equipment/equipment.controller.ts @@ -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 { + 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) { diff --git a/server/src/modules/equipment/equipment.service.ts b/server/src/modules/equipment/equipment.service.ts index 0997621..a43d04c 100644 --- a/server/src/modules/equipment/equipment.service.ts +++ b/server/src/modules/equipment/equipment.service.ts @@ -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( diff --git a/server/src/storage/storage.service.ts b/server/src/storage/storage.service.ts index 99bce9e..bb44241 100644 --- a/server/src/storage/storage.service.ts +++ b/server/src/storage/storage.service.ts @@ -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 { this.assertConfigured(); if (this.publicBaseUrl) {