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

@@ -788,6 +788,20 @@
"description": null "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
}
]
} }
] ]
}, },

View File

@@ -3,6 +3,7 @@ import { useNotify, useRecordContext, useRefresh } from 'react-admin';
import { useCallback, useState, type ChangeEvent } from 'react'; import { useCallback, useState, type ChangeEvent } from 'react';
import { ensureFreshToken, getAccessToken } from '../../auth/keycloak'; import { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
import { resolveApiBaseUrl } from '../../lib/apiBase'; import { resolveApiBaseUrl } from '../../lib/apiBase';
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
export type FileAttachmentValue = { export type FileAttachmentValue = {
objectKey?: string; objectKey?: string;
@@ -83,11 +84,12 @@ export function EquipmentAttachmentInput() {
<Typography component="label" variant="body2"> <Typography component="label" variant="body2">
Вложение (файл в MinIO) Вложение (файл в MinIO)
</Typography> </Typography>
{att?.downloadUrl ? ( {att?.objectKey ? (
<Typography variant="body2"> <Typography variant="body2" component="div">
<a href={att.downloadUrl} target="_blank" rel="noreferrer"> <EquipmentAttachmentLink
{att.originalFileName || 'Скачать файл'} equipmentId={String(record.id)}
</a> fileName={att.originalFileName || 'Скачать файл'}
/>
{att.sizeBytes != null ? ` (${Math.round(att.sizeBytes / 1024)} КБ)` : null} {att.sizeBytes != null ? ` (${Math.round(att.sizeBytes / 1024)} КБ)` : null}
</Typography> </Typography>
) : ( ) : (

View File

@@ -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 (
<Link
component="button"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void downloadEquipmentAttachmentFile(equipmentId, label).catch(() =>
notify('Не удалось скачать файл', { type: 'warning' }),
);
}}
sx={{ cursor: 'pointer', textAlign: 'left', verticalAlign: 'inherit' }}
>
{label}
</Link>
);
}

View File

@@ -11,6 +11,7 @@ import {
TextInput, TextInput,
TopToolbar, TopToolbar,
} from 'react-admin'; } from 'react-admin';
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
import { equipmentStatusChoices } from './shared'; import { equipmentStatusChoices } from './shared';
const equipmentFilters = [ const equipmentFilters = [
@@ -37,7 +38,19 @@ export function EquipmentList() {
<SelectField source="status" choices={equipmentStatusChoices} /> <SelectField source="status" choices={equipmentStatusChoices} />
<FunctionField <FunctionField
label="Файл" label="Файл"
render={(record: { attachment?: { objectKey?: string } | null }) => (record?.attachment?.objectKey ? '✓' : '—')} render={(record: {
id: string;
attachment?: { objectKey?: string; originalFileName?: string | null } | null;
}) =>
record?.attachment?.objectKey ? (
<EquipmentAttachmentLink
equipmentId={record.id}
fileName={record.attachment.originalFileName ?? 'файл'}
/>
) : (
'—'
)
}
/> />
</Datagrid> </Datagrid>
</List> </List>

View File

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

View File

@@ -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<void> {
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);
}

View File

@@ -361,6 +361,14 @@ api API.Equipment {
type uuid; type uuid;
} }
} }
endpoint downloadEquipmentAttachment {
label "GET /equipment/{id}/attachment/download";
description "Скачать файл-вложение (Content-Disposition: attachment, поток из MinIO)";
attribute id {
type uuid;
}
}
} }
api API.EquipmentStatusChange { api API.EquipmentStatusChange {

View File

@@ -8,6 +8,7 @@ import {
Post, Post,
Query, Query,
Res, Res,
StreamableFile,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@@ -51,6 +52,19 @@ export class EquipmentController {
return this.equipmentService.removeAttachment(id); 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') @Roles('viewer', 'editor', 'admin')
@Get(':id') @Get(':id')
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {

View File

@@ -13,6 +13,7 @@ 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 type { StoredAttachmentMeta } from '../../storage/storage.service';
import { StorageService } from '../../storage/storage.service'; import { StorageService } from '../../storage/storage.service';
import type { Readable } from 'node:stream';
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'; import { isEquipmentAttachmentStored } from './attachment.types';
@@ -153,6 +154,40 @@ export class EquipmentService {
return this.toRecord(updated); 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) { async removeAttachment(id: string) {
if (!this.storage.isConfigured()) { if (!this.storage.isConfigured()) {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(

View File

@@ -2,6 +2,7 @@ import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } fro
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { HttpException, Injectable, ServiceUnavailableException } from '@nestjs/common'; import { HttpException, Injectable, ServiceUnavailableException } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Readable } from 'node:stream';
export type StoredAttachmentMeta = { export type StoredAttachmentMeta = {
objectKey: string; 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> { async getDownloadUrl(objectKey: string): Promise<string> {
this.assertConfigured(); this.assertConfigured();
if (this.publicBaseUrl) { if (this.publicBaseUrl) {