From 1605e09d06a0a52b88474006aebfe97af028e19e Mon Sep 17 00:00:00 2001 From: "Mert E." Date: Wed, 24 Jul 2024 17:06:07 +0300 Subject: [PATCH] feat: allow preview for media types (#9052) * feat: allow preview for media types * feat: attachment download endpoint * fix: render attachment modal only on expand * feat: attachment download front-end * fix: add download attachment endpoint to secure controller * fix: use api instead of direct access * fix: bulk download attachments * feat: public attachment download api * fix: add swagger to support shared base * fix: apply latest signed url change Signed-off-by: mertmit --------- Signed-off-by: mertmit --- .../components/cell/attachment/Carousel.vue | 4 +- .../components/cell/attachment/Modal.vue | 8 +- .../components/cell/attachment/index.vue | 5 +- .../components/cell/attachment/utils.ts | 50 ++++-- packages/nc-gui/composables/useAttachment.ts | 2 +- packages/nc-gui/composables/useSharedView.ts | 19 +++ .../attachments-secure.controller.ts | 78 +++++++-- .../src/controllers/attachments.controller.ts | 82 +++++++-- .../controllers/public-datas.controller.ts | 48 +++++- packages/nocodb/src/db/BaseModelSqlv2.ts | 12 ++ .../nocodb/src/helpers/attachmentHelpers.ts | 23 +++ packages/nocodb/src/models/PresignedUrl.ts | 51 +++++- .../jobs/data-export/data-export.processor.ts | 4 + packages/nocodb/src/plugins/s3/S3.ts | 10 +- packages/nocodb/src/schema/swagger.json | 159 ++++++++++++++++++ .../src/services/attachments.service.ts | 50 +++++- .../src/services/public-datas.service.ts | 48 ++++++ 17 files changed, 586 insertions(+), 67 deletions(-) create mode 100644 packages/nocodb/src/helpers/attachmentHelpers.ts diff --git a/packages/nc-gui/components/cell/attachment/Carousel.vue b/packages/nc-gui/components/cell/attachment/Carousel.vue index 6f309467c4..d77bf4402a 100644 --- a/packages/nc-gui/components/cell/attachment/Carousel.vue +++ b/packages/nc-gui/components/cell/attachment/Carousel.vue @@ -2,7 +2,7 @@ import { onKeyDown } from '@vueuse/core' import { useAttachmentCell } from './utils' -const { selectedImage, visibleItems, downloadFile } = useAttachmentCell()! +const { selectedImage, visibleItems, downloadAttachment } = useAttachmentCell()! const carouselRef = ref() @@ -65,7 +65,7 @@ useEventListener(container, 'click', (e) => {

{{ selectedImage && selectedImage.title }}

diff --git a/packages/nc-gui/components/cell/attachment/Modal.vue b/packages/nc-gui/components/cell/attachment/Modal.vue index 8369dcf327..a952a31b2b 100644 --- a/packages/nc-gui/components/cell/attachment/Modal.vue +++ b/packages/nc-gui/components/cell/attachment/Modal.vue @@ -16,11 +16,11 @@ const { FileIcon, removeFile, onDrop, - downloadFile, + downloadAttachment, updateModelValue, selectedImage, selectedVisibleItems, - bulkDownloadFiles, + bulkDownloadAttachments, renameFile, } = useAttachmentCell()! @@ -113,7 +113,7 @@ const handleFileDelete = (i: number) => { v-if="selectedVisibleItems.includes(true) && selectedVisibleItems.length > 1" class="flex flex-1 items-center gap-3 justify-end mr-[30px]" > - + {{ $t('activity.bulkDownload') }} @@ -169,7 +169,7 @@ const handleFileDelete = (i: number) => {
- + diff --git a/packages/nc-gui/components/cell/attachment/index.vue b/packages/nc-gui/components/cell/attachment/index.vue index 35de9619bf..dc2796d3a7 100644 --- a/packages/nc-gui/components/cell/attachment/index.vue +++ b/packages/nc-gui/components/cell/attachment/index.vue @@ -44,6 +44,7 @@ const { isPublic, isForm, column, + modalRendered, modalVisible, attachments, visibleItems, @@ -124,6 +125,7 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e) => { if (isNewAttachmentModalOpen.value) return e.stopPropagation() if (!modalVisible.value && !isMobileMode.value) { + modalRendered.value = true modalVisible.value = true } else { // click Attach File button @@ -157,6 +159,7 @@ const openAttachment = (item: any) => { const onExpand = () => { if (isMobileMode.value) return + modalRendered.value = true modalVisible.value = true } @@ -351,7 +354,7 @@ const handleFileDelete = (i: number) => {
- + []) => void) => { + const { $api } = useNuxtApp() + + const baseURL = $api.instance.defaults.baseURL + + const { row } = useSmartsheetRowStoreOrThrow() + + const { fetchSharedViewAttachment } = useSharedView() + const isReadonly = inject(ReadonlyInj, ref(false)) const { t } = useI18n() @@ -30,6 +37,8 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( const attachments = ref([]) + const modalRendered = ref(false) + const modalVisible = ref(false) /** for image carousel */ @@ -49,8 +58,6 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( const { appInfo } = useGlobal() - const { getAttachmentSrc } = useAttachment() - const defaultAttachmentMeta = { ...(appInfo.value.ee && { // Maximum Number of Attachments per cell @@ -314,16 +321,36 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( } /** bulk download selected files */ - async function bulkDownloadFiles() { - await Promise.all(selectedVisibleItems.value.map(async (v, i) => v && (await downloadFile(visibleItems.value[i])))) + async function bulkDownloadAttachments() { + await Promise.all(selectedVisibleItems.value.map(async (v, i) => v && (await downloadAttachment(visibleItems.value[i])))) selectedVisibleItems.value = Array.from({ length: visibleItems.value.length }, () => false) } /** download a file */ - async function downloadFile(item: AttachmentType) { - const src = await getAttachmentSrc(item) - if (src) { - saveAs(src, item.title) + async function downloadAttachment(item: AttachmentType) { + if (!meta.value || !column.value) return + + const modelId = meta.value.id + const columnId = column.value.id + const rowId = extractPkFromRow(unref(row).row, meta.value.columns!) + const src = item.url || item.path + if (modelId && columnId && rowId && src) { + const apiPromise = isPublic.value + ? () => fetchSharedViewAttachment(columnId, rowId, src) + : () => + $api.dbDataTableRow.attachmentDownload(modelId, columnId, rowId, { + urlOrPath: src, + }) + + await apiPromise().then((res) => { + if (res?.path) { + window.open(`${baseURL}/${res.path}`, '_blank') + } else if (res?.url) { + window.open(res.url, '_blank') + } else { + message.error('Failed to download file') + } + }) } else { message.error('Failed to download file') } @@ -380,17 +407,18 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( api, open: () => open(), onDrop, + modalRendered, modalVisible, FileIcon, removeFile, renameFile, - downloadFile, + downloadAttachment, updateModelValue, selectedImage, uploadViaUrl, selectedVisibleItems, storedFiles, - bulkDownloadFiles, + bulkDownloadAttachments, defaultAttachmentMeta, startCamera, stopCamera, diff --git a/packages/nc-gui/composables/useAttachment.ts b/packages/nc-gui/composables/useAttachment.ts index 0b6dd5f51c..dc81ad0ffa 100644 --- a/packages/nc-gui/composables/useAttachment.ts +++ b/packages/nc-gui/composables/useAttachment.ts @@ -20,7 +20,7 @@ const useAttachment = () => { for (const source of sources) { try { // test if the source is accessible or not - const res = await fetch(source, { method: 'HEAD' }) + const res = await fetch(source, { method: 'HEAD', mode: 'no-cors' }) if (res.ok) { return source } diff --git a/packages/nc-gui/composables/useSharedView.ts b/packages/nc-gui/composables/useSharedView.ts index ff1f77da5c..0872843466 100644 --- a/packages/nc-gui/composables/useSharedView.ts +++ b/packages/nc-gui/composables/useSharedView.ts @@ -316,6 +316,24 @@ export function useSharedView() { ) } + const fetchSharedViewAttachment = async (columnId: string, rowId: string, urlOrPath: string) => { + if (!sharedView.value) return + + return await $api.public.dataAttachmentDownload( + sharedView.value.uuid!, + columnId, + rowId, + { + urlOrPath, + } as any, + { + headers: { + 'xc-password': password.value, + }, + }, + ) + } + const exportFile = async ( fields: any[], offset: number, @@ -348,6 +366,7 @@ export function useSharedView() { fetchSharedViewGroupedData, fetchAggregatedData, fetchBulkAggregatedData, + fetchSharedViewAttachment, paginationData, sorts, exportFile, diff --git a/packages/nocodb/src/controllers/attachments-secure.controller.ts b/packages/nocodb/src/controllers/attachments-secure.controller.ts index 9d5a69a8e1..46b2f2d4ca 100644 --- a/packages/nocodb/src/controllers/attachments-secure.controller.ts +++ b/packages/nocodb/src/controllers/attachments-secure.controller.ts @@ -6,6 +6,7 @@ import { HttpCode, Param, Post, + Query, Req, Res, UploadedFiles, @@ -18,15 +19,24 @@ import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { Response } from 'express'; import type { AttachmentReqType, FileType } from 'nocodb-sdk'; import type { NcRequest } from '~/interface/config'; +import { NcContext } from '~/interface/config'; import { GlobalGuard } from '~/guards/global/global.guard'; import { AttachmentsService } from '~/services/attachments.service'; -import { PresignedUrl } from '~/models'; +import { Column, PresignedUrl } from '~/models'; import { UploadAllowedInterceptor } from '~/interceptors/is-upload-allowed/is-upload-allowed.interceptor'; import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard'; +import { DataTableService } from '~/services/data-table.service'; +import { DataApiLimiterGuard } from '~/guards/data-api-limiter.guard'; +import { TenantContext } from '~/decorators/tenant-context.decorator'; +import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware'; +import { NcError } from '~/helpers/catchError'; @Controller() export class AttachmentsSecureController { - constructor(private readonly attachmentsService: AttachmentsService) {} + constructor( + private readonly attachmentsService: AttachmentsService, + private readonly dataTableService: DataTableService, + ) {} @UseGuards(MetaApiLimiterGuard, GlobalGuard) @Post(['/api/v1/db/storage/upload', '/api/v2/storage/upload']) @@ -75,30 +85,70 @@ export class AttachmentsSecureController { const fpath = queryHelper[0]; - let queryFilename = null; + let queryResponseContentType = null; + let queryResponseContentDisposition = null; if (queryHelper.length > 1) { const query = new URLSearchParams(queryHelper[1]); - queryFilename = query.get('filename'); + queryResponseContentType = query.get('response-content-type'); + queryResponseContentDisposition = query.get( + 'response-content-disposition', + ); } const file = await this.attachmentsService.getFile({ path: path.join('nc', 'uploads', fpath), }); - if (this.attachmentsService.previewAvailable(file.type)) { - if (queryFilename) { - res.setHeader( - 'Content-Disposition', - `attachment; filename=${queryFilename}`, - ); - } - res.sendFile(file.path); - } else { - res.download(file.path, queryFilename); + if (queryResponseContentType) { + res.setHeader('Content-Type', queryResponseContentType); } + + if (queryResponseContentDisposition) { + res.setHeader('Content-Disposition', queryResponseContentDisposition); + } + + res.sendFile(file.path); } catch (e) { res.status(404).send('Not found'); } } + + @UseGuards(DataApiLimiterGuard, GlobalGuard) + @Get('/api/v2/downloadAttachment/:modelId/:columnId/:rowId') + @Acl('dataRead') + async downloadAttachment( + @TenantContext() context: NcContext, + @Param('modelId') modelId: string, + @Param('columnId') columnId: string, + @Param('rowId') rowId: string, + @Query('urlOrPath') urlOrPath: string, + ) { + const column = await Column.get(context, { + colId: columnId, + }); + + if (!column) { + NcError.fieldNotFound(columnId); + } + + const record = await this.dataTableService.dataRead(context, { + baseId: context.base_id, + modelId, + rowId, + query: { + fields: column.title, + }, + }); + + if (!record) { + NcError.recordNotFound(rowId); + } + + return this.attachmentsService.getAttachmentFromRecord({ + record, + column, + urlOrPath, + }); + } } diff --git a/packages/nocodb/src/controllers/attachments.controller.ts b/packages/nocodb/src/controllers/attachments.controller.ts index 1ae7d13f8a..3e6f8ec2c4 100644 --- a/packages/nocodb/src/controllers/attachments.controller.ts +++ b/packages/nocodb/src/controllers/attachments.controller.ts @@ -21,11 +21,21 @@ import { GlobalGuard } from '~/guards/global/global.guard'; import { AttachmentsService } from '~/services/attachments.service'; import { PresignedUrl } from '~/models'; import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard'; -import { NcRequest } from '~/interface/config'; +import { NcContext, NcRequest } from '~/interface/config'; +import { isPreviewAllowed } from '~/helpers/attachmentHelpers'; +import { DataTableService } from '~/services/data-table.service'; +import { TenantContext } from '~/decorators/tenant-context.decorator'; +import { DataApiLimiterGuard } from '~/guards/data-api-limiter.guard'; +import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware'; +import { Column } from '~/models'; +import { NcError } from '~/helpers/catchError'; @Controller() export class AttachmentsController { - constructor(private readonly attachmentsService: AttachmentsService) {} + constructor( + private readonly attachmentsService: AttachmentsService, + private readonly dataTableService: DataTableService, + ) {} @UseGuards(MetaApiLimiterGuard, GlobalGuard) @Post(['/api/v1/db/storage/upload', '/api/v2/storage/upload']) @@ -73,7 +83,7 @@ export class AttachmentsController { path: path.join('nc', 'uploads', filename), }); - if (this.attachmentsService.previewAvailable(file.type)) { + if (isPreviewAllowed({ mimetype: file.type, path: file.path })) { if (queryFilename) { res.setHeader( 'Content-Disposition', @@ -110,7 +120,7 @@ export class AttachmentsController { ), }); - if (this.attachmentsService.previewAvailable(file.type)) { + if (isPreviewAllowed({ mimetype: file.type, path: file.path })) { if (queryFilename) { res.setHeader( 'Content-Disposition', @@ -135,30 +145,70 @@ export class AttachmentsController { const fpath = queryHelper[0]; - let queryFilename = null; + let queryResponseContentType = null; + let queryResponseContentDisposition = null; if (queryHelper.length > 1) { const query = new URLSearchParams(queryHelper[1]); - queryFilename = query.get('filename'); + queryResponseContentType = query.get('ResponseContentType'); + queryResponseContentDisposition = query.get( + 'ResponseContentDisposition', + ); } const file = await this.attachmentsService.getFile({ path: path.join('nc', 'uploads', fpath), }); - if (this.attachmentsService.previewAvailable(file.type)) { - if (queryFilename) { - res.setHeader( - 'Content-Disposition', - `attachment; filename=${queryFilename}`, - ); - } - res.sendFile(file.path); - } else { - res.download(file.path, queryFilename); + if (queryResponseContentType) { + res.setHeader('Content-Type', queryResponseContentType); } + + if (queryResponseContentDisposition) { + res.setHeader('Content-Disposition', queryResponseContentDisposition); + } + + res.sendFile(file.path); } catch (e) { res.status(404).send('Not found'); } } + + @UseGuards(DataApiLimiterGuard, GlobalGuard) + @Get('/api/v2/downloadAttachment/:modelId/:columnId/:rowId') + @Acl('dataRead') + async downloadAttachment( + @TenantContext() context: NcContext, + @Param('modelId') modelId: string, + @Param('columnId') columnId: string, + @Param('rowId') rowId: string, + @Query('urlOrPath') urlOrPath: string, + ) { + const column = await Column.get(context, { + colId: columnId, + }); + + if (!column) { + NcError.fieldNotFound(columnId); + } + + const record = await this.dataTableService.dataRead(context, { + baseId: context.base_id, + modelId, + rowId, + query: { + fields: column.title, + }, + }); + + if (!record) { + NcError.recordNotFound(rowId); + } + + return this.attachmentsService.getAttachmentFromRecord({ + record, + column, + urlOrPath, + }); + } } diff --git a/packages/nocodb/src/controllers/public-datas.controller.ts b/packages/nocodb/src/controllers/public-datas.controller.ts index cd401ba6b2..b66abf9782 100644 --- a/packages/nocodb/src/controllers/public-datas.controller.ts +++ b/packages/nocodb/src/controllers/public-datas.controller.ts @@ -4,6 +4,7 @@ import { HttpCode, Param, Post, + Query, Req, UseGuards, UseInterceptors, @@ -13,11 +14,17 @@ import { PublicDatasService } from '~/services/public-datas.service'; import { PublicApiLimiterGuard } from '~/guards/public-api-limiter.guard'; import { TenantContext } from '~/decorators/tenant-context.decorator'; import { NcContext, NcRequest } from '~/interface/config'; +import { Column } from '~/models'; +import { AttachmentsService } from '~/services/attachments.service'; +import { NcError } from '~/helpers/catchError'; @UseGuards(PublicApiLimiterGuard) @Controller() export class PublicDatasController { - constructor(protected readonly publicDatasService: PublicDatasService) {} + constructor( + protected readonly publicDatasService: PublicDatasService, + protected readonly attachmentsService: AttachmentsService, + ) {} @Get([ '/api/v1/db/public/shared-view/:sharedViewUuid/rows', @@ -189,4 +196,43 @@ export class PublicDatasController { ); return paginatedResponse; } + + @Get( + '/api/v2/public/shared-view/:sharedViewUuid/downloadAttachment/:columnId/:rowId', + ) + async downloadPublicAttachment( + @TenantContext() context: NcContext, + @Req() req: NcRequest, + @Param('sharedViewUuid') sharedViewUuid: string, + @Param('columnId') columnId: string, + @Param('rowId') rowId: string, + @Query('urlOrPath') urlOrPath: string, + ) { + const column = await Column.get(context, { + colId: columnId, + }); + + if (!column) { + NcError.fieldNotFound(columnId); + } + + const record = await this.publicDatasService.dataRead(context, { + sharedViewUuid, + query: { + fields: column.title, + }, + rowId, + password: req.headers?.['xc-password'] as string, + }); + + if (!record) { + NcError.recordNotFound(rowId); + } + + return this.attachmentsService.getAttachmentFromRecord({ + record, + column, + urlOrPath, + }); + } } diff --git a/packages/nocodb/src/db/BaseModelSqlv2.ts b/packages/nocodb/src/db/BaseModelSqlv2.ts index 481d3958a9..ccc6c91818 100644 --- a/packages/nocodb/src/db/BaseModelSqlv2.ts +++ b/packages/nocodb/src/db/BaseModelSqlv2.ts @@ -7237,12 +7237,18 @@ class BaseModelSqlv2 { /^download\//, '', ), + preview: true, + mimetype: lookedUpAttachment.mimetype, + filename: lookedUpAttachment.title, }).then((r) => (lookedUpAttachment.signedPath = r)), ); } else if (lookedUpAttachment?.url) { promises.push( PresignedUrl.getSignedUrl({ pathOrUrl: lookedUpAttachment.url, + preview: true, + mimetype: lookedUpAttachment.mimetype, + filename: lookedUpAttachment.title, }).then((r) => (lookedUpAttachment.signedUrl = r)), ); } @@ -7252,12 +7258,18 @@ class BaseModelSqlv2 { promises.push( PresignedUrl.getSignedUrl({ pathOrUrl: attachment.path.replace(/^download\//, ''), + preview: true, + mimetype: attachment.mimetype, + filename: attachment.title, }).then((r) => (attachment.signedPath = r)), ); } else if (attachment?.url) { promises.push( PresignedUrl.getSignedUrl({ pathOrUrl: attachment.url, + preview: true, + mimetype: attachment.mimetype, + filename: attachment.title, }).then((r) => (attachment.signedUrl = r)), ); } diff --git a/packages/nocodb/src/helpers/attachmentHelpers.ts b/packages/nocodb/src/helpers/attachmentHelpers.ts new file mode 100644 index 0000000000..a1b5d023e5 --- /dev/null +++ b/packages/nocodb/src/helpers/attachmentHelpers.ts @@ -0,0 +1,23 @@ +import mime from 'mime/lite'; + +const previewableMimeTypes = ['image', 'pdf', 'video', 'audio']; + +export function isPreviewAllowed(args: { mimetype?: string; path?: string }) { + const { mimetype, path } = args; + + if (mimetype) { + return previewableMimeTypes.some((type) => mimetype.includes(type)); + } else if (path) { + const ext = path.split('.').pop(); + + // clear query params + const extWithoutQuery = ext?.split('?')[0]; + + if (extWithoutQuery) { + const mimeType = mime.getType(extWithoutQuery); + return previewableMimeTypes.some((type) => mimeType?.includes(type)); + } + } + + return false; +} diff --git a/packages/nocodb/src/models/PresignedUrl.ts b/packages/nocodb/src/models/PresignedUrl.ts index 756378ff5e..cb10d70373 100644 --- a/packages/nocodb/src/models/PresignedUrl.ts +++ b/packages/nocodb/src/models/PresignedUrl.ts @@ -3,6 +3,7 @@ import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2'; import Noco from '~/Noco'; import NocoCache from '~/cache/NocoCache'; import { CacheGetType, CacheScope } from '~/utils/globals'; +import { isPreviewAllowed } from '~/helpers/attachmentHelpers'; function roundExpiry(date) { const msInHour = 10 * 60 * 1000; @@ -91,6 +92,8 @@ export default class PresignedUrl { pathOrUrl: string; expireSeconds?: number; filename?: string; + preview?: boolean; + mimetype?: string; }, ncMeta = Noco.ncMeta, ) { @@ -100,7 +103,15 @@ export default class PresignedUrl { ? decodeURI(new URL(param.pathOrUrl).pathname) : param.pathOrUrl.replace(/^\/+/, ''); - const { expireSeconds = DEFAULT_EXPIRE_SECONDS, filename } = param; + const { + expireSeconds = DEFAULT_EXPIRE_SECONDS, + filename, + mimetype, + } = param; + + const preview = param.preview + ? isPreviewAllowed({ path, mimetype }) + : false; const expireAt = roundExpiry( new Date(new Date().getTime() + expireSeconds * 1000), @@ -113,8 +124,35 @@ export default class PresignedUrl { let tempUrl; + const pathParameters: { + [key: string]: string; + } = {}; + + if (preview) { + pathParameters.ResponseContentDisposition = `inline;`; + + if (filename) { + pathParameters.ResponseContentDisposition += ` filename="${filename}"`; + } + } else { + pathParameters.ResponseContentDisposition = `attachment;`; + + if (filename) { + pathParameters.ResponseContentDisposition += ` filename="${filename}"`; + } + } + + if (mimetype) { + pathParameters.ResponseContentType = mimetype; + } + + // append query params to the cache path + const cachePath = `${path}?${new URLSearchParams( + pathParameters, + ).toString()}`; + const url = await NocoCache.get( - `${CacheScope.PRESIGNED_URL}:path:${path}`, + `${CacheScope.PRESIGNED_URL}:path:${cachePath}`, CacheGetType.TYPE_OBJECT, ); @@ -135,10 +173,10 @@ export default class PresignedUrl { tempUrl = await (storageAdapter as any).getSignedUrl( path, expiresInSeconds, - filename, + pathParameters, ); await this.add({ - path: path, + path: cachePath, url: tempUrl, expires_at: expireAt, expiresInSeconds, @@ -149,10 +187,7 @@ export default class PresignedUrl { ? param.pathOrUrl : `dltemp/${nanoid(16)}/${expireAt.getTime()}/${path}`; - // if filename is present, add it to the destination - if (filename) { - path = `${path}?filename=${encodeURIComponent(filename)}`; - } + path = `${path}?${new URLSearchParams(pathParameters).toString()}`; await this.add({ path: path, diff --git a/packages/nocodb/src/modules/jobs/jobs/data-export/data-export.processor.ts b/packages/nocodb/src/modules/jobs/jobs/data-export/data-export.processor.ts index 3ff0993b17..6bfb579dbb 100644 --- a/packages/nocodb/src/modules/jobs/jobs/data-export/data-export.processor.ts +++ b/packages/nocodb/src/modules/jobs/jobs/data-export/data-export.processor.ts @@ -96,12 +96,16 @@ export class DataExportProcessor { pathOrUrl: path.join(destPath.replace('nc/uploads/', '')), filename: `${model.title} (${getViewTitle(view)}).csv`, expireSeconds: 3 * 60 * 60, // 3 hours + preview: false, + mimetype: 'text/csv', }); } else { url = await PresignedUrl.getSignedUrl({ pathOrUrl: url, filename: `${model.title} (${getViewTitle(view)}).csv`, expireSeconds: 3 * 60 * 60, // 3 hours + preview: false, + mimetype: 'text/csv', }); } diff --git a/packages/nocodb/src/plugins/s3/S3.ts b/packages/nocodb/src/plugins/s3/S3.ts index 86e4ef62cc..3632ffbeaf 100644 --- a/packages/nocodb/src/plugins/s3/S3.ts +++ b/packages/nocodb/src/plugins/s3/S3.ts @@ -107,13 +107,15 @@ export default class S3 implements IStorageAdapterV2 { }); } - public async getSignedUrl(key, expiresInSeconds = 7200, filename?: string) { + public async getSignedUrl( + key, + expiresInSeconds = 7200, + pathParameters?: { [key: string]: string }, + ) { const command = new GetObjectCommand({ Key: key, Bucket: this.input.bucket, - ...(filename - ? { ResponseContentDisposition: `attachment; filename="${filename}" ` } - : {}), + ...pathParameters, }); return getSignedUrl(this.s3Client, command, { expiresIn: expiresInSeconds, diff --git a/packages/nocodb/src/schema/swagger.json b/packages/nocodb/src/schema/swagger.json index d8a95d91c1..7edc82e705 100644 --- a/packages/nocodb/src/schema/swagger.json +++ b/packages/nocodb/src/schema/swagger.json @@ -12473,6 +12473,91 @@ } } }, + "/api/v2/public/shared-view/{sharedViewUuid}/downloadAttachment/{columnId}/{rowId}": { + "parameters": [ + { + "schema": { + "type": "string", + "example": "24a6d0bb-e45d-4b1a-bfef-f492d870de9f" + }, + "name": "sharedViewUuid", + "in": "path", + "required": true, + "description": "Shared View UUID" + }, + { + "schema": { + "$ref": "#/components/schemas/Id", + "example": "cl_8iw2o4ejzvdyna", + "type": "string" + }, + "name": "columnId", + "in": "path", + "required": true, + "description": "Unique Column ID" + }, + { + "schema": { + "example": "1" + }, + "name": "rowId", + "in": "path", + "required": true, + "description": "Unique Row ID" + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "xc-password", + "description": "Shared view password" + } + ], + "get": { + "summary": "Get Shared View Attachment", + "operationId": "public-data-attachment-download", + "description": "Download attachment from a shared view", + "tags": [ + "Public" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "urlOrPath", + "description": "URL or Path of the attachment" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "URL to download the attachment" + }, + "path": { + "type": "string", + "description": "Path to download the attachment" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + } + }, "/api/v1/db/public/shared-view/{sharedViewUuid}/group/{columnId}": { "parameters": [ { @@ -17605,6 +17690,80 @@ ] } }, + "/api/v2/downloadAttachment/{modelId}/{columnId}/{rowId}": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "modelId", + "in": "path", + "required": true, + "description": "Model ID" + }, + { + "schema": { + "type": "string" + }, + "name": "columnId", + "in": "path", + "required": true, + "description": "Column ID" + }, + { + "schema": { + "type": "string" + }, + "name": "rowId", + "in": "path", + "required": true, + "description": "Row ID" + }, + { + "schema": { + "type": "string" + }, + "name": "urlOrPath", + "in": "query", + "description": "URL or Path of the attachment" + } + ], + "get": { + "summary": "Download Attachment", + "operationId": "db-data-table-row-attachment-download", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "URL to download attachment" + }, + "path": { + "type": "string", + "description": "Path to download attachment" + } + } + } + } + } + } + }, + "tags": [ + "DB Data Table Row" + ], + "description": "Download attachment from a given row", + "parameters": [ + { + "$ref": "#/components/parameters/xc-auth" + } + ] + } + }, "/api/v2/tables/{tableId}/links/{columnId}/records": { "parameters": [ { diff --git a/packages/nocodb/src/services/attachments.service.ts b/packages/nocodb/src/services/attachments.service.ts index 6e98fe1788..53bc03d4a2 100644 --- a/packages/nocodb/src/services/attachments.service.ts +++ b/packages/nocodb/src/services/attachments.service.ts @@ -83,10 +83,14 @@ export class AttachmentsService { attachment.signedPath = await PresignedUrl.getSignedUrl({ pathOrUrl: attachment.path.replace(/^download\//, ''), + preview: true, + mimetype: attachment.mimetype, }); } else { attachment.signedUrl = await PresignedUrl.getSignedUrl({ pathOrUrl: attachment.url, + preview: true, + mimetype: attachment.mimetype, }); } @@ -212,12 +216,48 @@ export class AttachmentsService { return { path: filePath, type }; } - previewAvailable(mimetype: string) { - const available = ['image', 'pdf', 'text/plain']; - if (available.some((type) => mimetype.includes(type))) { - return true; + async getAttachmentFromRecord(param: { + record: any; + column: { title: string }; + urlOrPath: string; + }) { + const { record, column, urlOrPath } = param; + + const attachment = record[column.title]; + + if (!attachment || !attachment.length) { + NcError.genericNotFound('Attachment', urlOrPath); + } + + const fileObject = attachment.find( + (a) => a.url === urlOrPath || a.path === urlOrPath, + ); + + if (!fileObject) { + NcError.genericNotFound('Attachment', urlOrPath); + } + + if (fileObject?.path) { + const signedPath = await PresignedUrl.getSignedUrl({ + pathOrUrl: fileObject.path.replace(/^download\//, ''), + preview: false, + filename: fileObject.title, + mimetype: fileObject.mimetype, + expireSeconds: 5 * 60, + }); + + return { path: signedPath }; + } else if (fileObject?.url) { + const signedUrl = await PresignedUrl.getSignedUrl({ + pathOrUrl: fileObject.url, + preview: false, + filename: fileObject.title, + mimetype: fileObject.mimetype, + expireSeconds: 5 * 60, + }); + + return { url: signedUrl }; } - return false; } sanitizeUrlPath(paths) { diff --git a/packages/nocodb/src/services/public-datas.service.ts b/packages/nocodb/src/services/public-datas.service.ts index c3efc70675..1a2350158a 100644 --- a/packages/nocodb/src/services/public-datas.service.ts +++ b/packages/nocodb/src/services/public-datas.service.ts @@ -737,4 +737,52 @@ export class PublicDatasService { return new PagedResponseImpl(data, { ...param.query, count }); } + + async dataRead( + context: NcContext, + param: { + sharedViewUuid: string; + rowId: string; + password?: string; + query: any; + }, + ) { + const { sharedViewUuid, rowId, password, query = {} } = param; + const view = await View.getByUUID(context, sharedViewUuid); + + if (!view) NcError.viewNotFound(sharedViewUuid); + if ( + view.type !== ViewTypes.GRID && + view.type !== ViewTypes.KANBAN && + view.type !== ViewTypes.GALLERY && + view.type !== ViewTypes.MAP && + view.type !== ViewTypes.CALENDAR + ) { + NcError.notFound('Not found'); + } + + if (view.password && view.password !== password) { + return NcError.invalidSharedViewPassword(); + } + + const model = await Model.getByIdOrName(context, { + id: view?.fk_model_id, + }); + + const source = await Source.get(context, model.source_id); + + const baseModel = await Model.getBaseModelSQL(context, { + id: model.id, + viewId: view?.id, + dbDriver: await NcConnectionMgrv2.get(source), + }); + + const row = await baseModel.readByPk(rowId, false, query); + + if (!row) { + NcError.recordNotFound(param.rowId); + } + + return row; + } }