Browse Source

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 <mertmit99@gmail.com>

---------

Signed-off-by: mertmit <mertmit99@gmail.com>
pull/9066/head
Mert E. 4 months ago committed by GitHub
parent
commit
1605e09d06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      packages/nc-gui/components/cell/attachment/Carousel.vue
  2. 8
      packages/nc-gui/components/cell/attachment/Modal.vue
  3. 5
      packages/nc-gui/components/cell/attachment/index.vue
  4. 50
      packages/nc-gui/components/cell/attachment/utils.ts
  5. 2
      packages/nc-gui/composables/useAttachment.ts
  6. 19
      packages/nc-gui/composables/useSharedView.ts
  7. 76
      packages/nocodb/src/controllers/attachments-secure.controller.ts
  8. 80
      packages/nocodb/src/controllers/attachments.controller.ts
  9. 48
      packages/nocodb/src/controllers/public-datas.controller.ts
  10. 12
      packages/nocodb/src/db/BaseModelSqlv2.ts
  11. 23
      packages/nocodb/src/helpers/attachmentHelpers.ts
  12. 51
      packages/nocodb/src/models/PresignedUrl.ts
  13. 4
      packages/nocodb/src/modules/jobs/jobs/data-export/data-export.processor.ts
  14. 10
      packages/nocodb/src/plugins/s3/S3.ts
  15. 159
      packages/nocodb/src/schema/swagger.json
  16. 50
      packages/nocodb/src/services/attachments.service.ts
  17. 48
      packages/nocodb/src/services/public-datas.service.ts

4
packages/nc-gui/components/cell/attachment/Carousel.vue

@ -2,7 +2,7 @@
import { onKeyDown } from '@vueuse/core' import { onKeyDown } from '@vueuse/core'
import { useAttachmentCell } from './utils' import { useAttachmentCell } from './utils'
const { selectedImage, visibleItems, downloadFile } = useAttachmentCell()! const { selectedImage, visibleItems, downloadAttachment } = useAttachmentCell()!
const carouselRef = ref() const carouselRef = ref()
@ -65,7 +65,7 @@ useEventListener(container, 'click', (e) => {
<div <div
class="keep-open select-none group hover:(ring-1 ring-accent) ring-opacity-100 cursor-pointer leading-8 inline-block px-3 py-1 bg-gray-300 text-white mb-4 text-center rounded shadow" class="keep-open select-none group hover:(ring-1 ring-accent) ring-opacity-100 cursor-pointer leading-8 inline-block px-3 py-1 bg-gray-300 text-white mb-4 text-center rounded shadow"
@click.stop="downloadFile(selectedImage)" @click.stop="downloadAttachment(selectedImage)"
> >
<h3 class="group-hover:text-primary">{{ selectedImage && selectedImage.title }}</h3> <h3 class="group-hover:text-primary">{{ selectedImage && selectedImage.title }}</h3>
</div> </div>

8
packages/nc-gui/components/cell/attachment/Modal.vue

@ -16,11 +16,11 @@ const {
FileIcon, FileIcon,
removeFile, removeFile,
onDrop, onDrop,
downloadFile, downloadAttachment,
updateModelValue, updateModelValue,
selectedImage, selectedImage,
selectedVisibleItems, selectedVisibleItems,
bulkDownloadFiles, bulkDownloadAttachments,
renameFile, renameFile,
} = useAttachmentCell()! } = useAttachmentCell()!
@ -113,7 +113,7 @@ const handleFileDelete = (i: number) => {
v-if="selectedVisibleItems.includes(true) && selectedVisibleItems.length > 1" v-if="selectedVisibleItems.includes(true) && selectedVisibleItems.length > 1"
class="flex flex-1 items-center gap-3 justify-end mr-[30px]" class="flex flex-1 items-center gap-3 justify-end mr-[30px]"
> >
<NcButton type="primary" class="nc-attachment-download-all" @click="bulkDownloadFiles"> <NcButton type="primary" class="nc-attachment-download-all" @click="bulkDownloadAttachments">
{{ $t('activity.bulkDownload') }} {{ $t('activity.bulkDownload') }}
</NcButton> </NcButton>
</div> </div>
@ -169,7 +169,7 @@ const handleFileDelete = (i: number) => {
<div class="flex-none hide-ui transition-all transition-ease-in-out !h-6 flex items-center bg-white"> <div class="flex-none hide-ui transition-all transition-ease-in-out !h-6 flex items-center bg-white">
<NcTooltip placement="bottom"> <NcTooltip placement="bottom">
<template #title> {{ $t('title.downloadFile') }} </template> <template #title> {{ $t('title.downloadFile') }} </template>
<NcButton class="!text-gray-500" size="xsmall" type="text" @click="downloadFile(item)"> <NcButton class="!text-gray-500" size="xsmall" type="text" @click="downloadAttachment(item)">
<component :is="iconMap.download" /> <component :is="iconMap.download" />
</NcButton> </NcButton>
</NcTooltip> </NcTooltip>

5
packages/nc-gui/components/cell/attachment/index.vue

@ -44,6 +44,7 @@ const {
isPublic, isPublic,
isForm, isForm,
column, column,
modalRendered,
modalVisible, modalVisible,
attachments, attachments,
visibleItems, visibleItems,
@ -124,6 +125,7 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e) => {
if (isNewAttachmentModalOpen.value) return if (isNewAttachmentModalOpen.value) return
e.stopPropagation() e.stopPropagation()
if (!modalVisible.value && !isMobileMode.value) { if (!modalVisible.value && !isMobileMode.value) {
modalRendered.value = true
modalVisible.value = true modalVisible.value = true
} else { } else {
// click Attach File button // click Attach File button
@ -157,6 +159,7 @@ const openAttachment = (item: any) => {
const onExpand = () => { const onExpand = () => {
if (isMobileMode.value) return if (isMobileMode.value) return
modalRendered.value = true
modalVisible.value = true modalVisible.value = true
} }
@ -351,7 +354,7 @@ const handleFileDelete = (i: number) => {
</div> </div>
</template> </template>
<LazyCellAttachmentModal /> <LazyCellAttachmentModal v-if="modalRendered" />
<LazyGeneralDeleteModal <LazyGeneralDeleteModal
v-if="isForm || isExpandedForm" v-if="isForm || isExpandedForm"

50
packages/nc-gui/components/cell/attachment/utils.ts

@ -1,7 +1,6 @@
import type { AttachmentReqType, AttachmentType } from 'nocodb-sdk' import type { AttachmentReqType, AttachmentType } from 'nocodb-sdk'
import { populateUniqueFileName } from 'nocodb-sdk' import { populateUniqueFileName } from 'nocodb-sdk'
import DOMPurify from 'isomorphic-dompurify' import DOMPurify from 'isomorphic-dompurify'
import { saveAs } from 'file-saver'
import RenameFile from './RenameFile.vue' import RenameFile from './RenameFile.vue'
import MdiPdfBox from '~icons/mdi/pdf-box' import MdiPdfBox from '~icons/mdi/pdf-box'
import MdiFileWordOutline from '~icons/mdi/file-word-outline' import MdiFileWordOutline from '~icons/mdi/file-word-outline'
@ -11,6 +10,14 @@ import IcOutlineInsertDriveFile from '~icons/ic/outline-insert-drive-file'
export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
(updateModelValue: (data: string | Record<string, any>[]) => void) => { (updateModelValue: (data: string | Record<string, any>[]) => void) => {
const { $api } = useNuxtApp()
const baseURL = $api.instance.defaults.baseURL
const { row } = useSmartsheetRowStoreOrThrow()
const { fetchSharedViewAttachment } = useSharedView()
const isReadonly = inject(ReadonlyInj, ref(false)) const isReadonly = inject(ReadonlyInj, ref(false))
const { t } = useI18n() const { t } = useI18n()
@ -30,6 +37,8 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
const attachments = ref<AttachmentType[]>([]) const attachments = ref<AttachmentType[]>([])
const modalRendered = ref(false)
const modalVisible = ref(false) const modalVisible = ref(false)
/** for image carousel */ /** for image carousel */
@ -49,8 +58,6 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
const { appInfo } = useGlobal() const { appInfo } = useGlobal()
const { getAttachmentSrc } = useAttachment()
const defaultAttachmentMeta = { const defaultAttachmentMeta = {
...(appInfo.value.ee && { ...(appInfo.value.ee && {
// Maximum Number of Attachments per cell // Maximum Number of Attachments per cell
@ -314,16 +321,36 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
} }
/** bulk download selected files */ /** bulk download selected files */
async function bulkDownloadFiles() { async function bulkDownloadAttachments() {
await Promise.all(selectedVisibleItems.value.map(async (v, i) => v && (await downloadFile(visibleItems.value[i])))) await Promise.all(selectedVisibleItems.value.map(async (v, i) => v && (await downloadAttachment(visibleItems.value[i]))))
selectedVisibleItems.value = Array.from({ length: visibleItems.value.length }, () => false) selectedVisibleItems.value = Array.from({ length: visibleItems.value.length }, () => false)
} }
/** download a file */ /** download a file */
async function downloadFile(item: AttachmentType) { async function downloadAttachment(item: AttachmentType) {
const src = await getAttachmentSrc(item) if (!meta.value || !column.value) return
if (src) {
saveAs(src, item.title) 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 { } else {
message.error('Failed to download file') message.error('Failed to download file')
} }
@ -380,17 +407,18 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
api, api,
open: () => open(), open: () => open(),
onDrop, onDrop,
modalRendered,
modalVisible, modalVisible,
FileIcon, FileIcon,
removeFile, removeFile,
renameFile, renameFile,
downloadFile, downloadAttachment,
updateModelValue, updateModelValue,
selectedImage, selectedImage,
uploadViaUrl, uploadViaUrl,
selectedVisibleItems, selectedVisibleItems,
storedFiles, storedFiles,
bulkDownloadFiles, bulkDownloadAttachments,
defaultAttachmentMeta, defaultAttachmentMeta,
startCamera, startCamera,
stopCamera, stopCamera,

2
packages/nc-gui/composables/useAttachment.ts

@ -20,7 +20,7 @@ const useAttachment = () => {
for (const source of sources) { for (const source of sources) {
try { try {
// test if the source is accessible or not // 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) { if (res.ok) {
return source return source
} }

19
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 ( const exportFile = async (
fields: any[], fields: any[],
offset: number, offset: number,
@ -348,6 +366,7 @@ export function useSharedView() {
fetchSharedViewGroupedData, fetchSharedViewGroupedData,
fetchAggregatedData, fetchAggregatedData,
fetchBulkAggregatedData, fetchBulkAggregatedData,
fetchSharedViewAttachment,
paginationData, paginationData,
sorts, sorts,
exportFile, exportFile,

76
packages/nocodb/src/controllers/attachments-secure.controller.ts

@ -6,6 +6,7 @@ import {
HttpCode, HttpCode,
Param, Param,
Post, Post,
Query,
Req, Req,
Res, Res,
UploadedFiles, UploadedFiles,
@ -18,15 +19,24 @@ import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { Response } from 'express'; import { Response } from 'express';
import type { AttachmentReqType, FileType } from 'nocodb-sdk'; import type { AttachmentReqType, FileType } from 'nocodb-sdk';
import type { NcRequest } from '~/interface/config'; import type { NcRequest } from '~/interface/config';
import { NcContext } from '~/interface/config';
import { GlobalGuard } from '~/guards/global/global.guard'; import { GlobalGuard } from '~/guards/global/global.guard';
import { AttachmentsService } from '~/services/attachments.service'; 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 { UploadAllowedInterceptor } from '~/interceptors/is-upload-allowed/is-upload-allowed.interceptor';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard'; 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() @Controller()
export class AttachmentsSecureController { export class AttachmentsSecureController {
constructor(private readonly attachmentsService: AttachmentsService) {} constructor(
private readonly attachmentsService: AttachmentsService,
private readonly dataTableService: DataTableService,
) {}
@UseGuards(MetaApiLimiterGuard, GlobalGuard) @UseGuards(MetaApiLimiterGuard, GlobalGuard)
@Post(['/api/v1/db/storage/upload', '/api/v2/storage/upload']) @Post(['/api/v1/db/storage/upload', '/api/v2/storage/upload'])
@ -75,30 +85,70 @@ export class AttachmentsSecureController {
const fpath = queryHelper[0]; const fpath = queryHelper[0];
let queryFilename = null; let queryResponseContentType = null;
let queryResponseContentDisposition = null;
if (queryHelper.length > 1) { if (queryHelper.length > 1) {
const query = new URLSearchParams(queryHelper[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({ const file = await this.attachmentsService.getFile({
path: path.join('nc', 'uploads', fpath), path: path.join('nc', 'uploads', fpath),
}); });
if (this.attachmentsService.previewAvailable(file.type)) { if (queryResponseContentType) {
if (queryFilename) { res.setHeader('Content-Type', queryResponseContentType);
res.setHeader(
'Content-Disposition',
`attachment; filename=${queryFilename}`,
);
} }
res.sendFile(file.path);
} else { if (queryResponseContentDisposition) {
res.download(file.path, queryFilename); res.setHeader('Content-Disposition', queryResponseContentDisposition);
} }
res.sendFile(file.path);
} catch (e) { } catch (e) {
res.status(404).send('Not found'); 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,
});
}
} }

80
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 { AttachmentsService } from '~/services/attachments.service';
import { PresignedUrl } from '~/models'; import { PresignedUrl } from '~/models';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard'; 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() @Controller()
export class AttachmentsController { export class AttachmentsController {
constructor(private readonly attachmentsService: AttachmentsService) {} constructor(
private readonly attachmentsService: AttachmentsService,
private readonly dataTableService: DataTableService,
) {}
@UseGuards(MetaApiLimiterGuard, GlobalGuard) @UseGuards(MetaApiLimiterGuard, GlobalGuard)
@Post(['/api/v1/db/storage/upload', '/api/v2/storage/upload']) @Post(['/api/v1/db/storage/upload', '/api/v2/storage/upload'])
@ -73,7 +83,7 @@ export class AttachmentsController {
path: path.join('nc', 'uploads', filename), path: path.join('nc', 'uploads', filename),
}); });
if (this.attachmentsService.previewAvailable(file.type)) { if (isPreviewAllowed({ mimetype: file.type, path: file.path })) {
if (queryFilename) { if (queryFilename) {
res.setHeader( res.setHeader(
'Content-Disposition', '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) { if (queryFilename) {
res.setHeader( res.setHeader(
'Content-Disposition', 'Content-Disposition',
@ -135,30 +145,70 @@ export class AttachmentsController {
const fpath = queryHelper[0]; const fpath = queryHelper[0];
let queryFilename = null; let queryResponseContentType = null;
let queryResponseContentDisposition = null;
if (queryHelper.length > 1) { if (queryHelper.length > 1) {
const query = new URLSearchParams(queryHelper[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({ const file = await this.attachmentsService.getFile({
path: path.join('nc', 'uploads', fpath), path: path.join('nc', 'uploads', fpath),
}); });
if (this.attachmentsService.previewAvailable(file.type)) { if (queryResponseContentType) {
if (queryFilename) { res.setHeader('Content-Type', queryResponseContentType);
res.setHeader(
'Content-Disposition',
`attachment; filename=${queryFilename}`,
);
} }
res.sendFile(file.path);
} else { if (queryResponseContentDisposition) {
res.download(file.path, queryFilename); res.setHeader('Content-Disposition', queryResponseContentDisposition);
} }
res.sendFile(file.path);
} catch (e) { } catch (e) {
res.status(404).send('Not found'); 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,
});
}
} }

48
packages/nocodb/src/controllers/public-datas.controller.ts

@ -4,6 +4,7 @@ import {
HttpCode, HttpCode,
Param, Param,
Post, Post,
Query,
Req, Req,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@ -13,11 +14,17 @@ import { PublicDatasService } from '~/services/public-datas.service';
import { PublicApiLimiterGuard } from '~/guards/public-api-limiter.guard'; import { PublicApiLimiterGuard } from '~/guards/public-api-limiter.guard';
import { TenantContext } from '~/decorators/tenant-context.decorator'; import { TenantContext } from '~/decorators/tenant-context.decorator';
import { NcContext, NcRequest } from '~/interface/config'; import { NcContext, NcRequest } from '~/interface/config';
import { Column } from '~/models';
import { AttachmentsService } from '~/services/attachments.service';
import { NcError } from '~/helpers/catchError';
@UseGuards(PublicApiLimiterGuard) @UseGuards(PublicApiLimiterGuard)
@Controller() @Controller()
export class PublicDatasController { export class PublicDatasController {
constructor(protected readonly publicDatasService: PublicDatasService) {} constructor(
protected readonly publicDatasService: PublicDatasService,
protected readonly attachmentsService: AttachmentsService,
) {}
@Get([ @Get([
'/api/v1/db/public/shared-view/:sharedViewUuid/rows', '/api/v1/db/public/shared-view/:sharedViewUuid/rows',
@ -189,4 +196,43 @@ export class PublicDatasController {
); );
return paginatedResponse; 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,
});
}
} }

12
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -7237,12 +7237,18 @@ class BaseModelSqlv2 {
/^download\//, /^download\//,
'', '',
), ),
preview: true,
mimetype: lookedUpAttachment.mimetype,
filename: lookedUpAttachment.title,
}).then((r) => (lookedUpAttachment.signedPath = r)), }).then((r) => (lookedUpAttachment.signedPath = r)),
); );
} else if (lookedUpAttachment?.url) { } else if (lookedUpAttachment?.url) {
promises.push( promises.push(
PresignedUrl.getSignedUrl({ PresignedUrl.getSignedUrl({
pathOrUrl: lookedUpAttachment.url, pathOrUrl: lookedUpAttachment.url,
preview: true,
mimetype: lookedUpAttachment.mimetype,
filename: lookedUpAttachment.title,
}).then((r) => (lookedUpAttachment.signedUrl = r)), }).then((r) => (lookedUpAttachment.signedUrl = r)),
); );
} }
@ -7252,12 +7258,18 @@ class BaseModelSqlv2 {
promises.push( promises.push(
PresignedUrl.getSignedUrl({ PresignedUrl.getSignedUrl({
pathOrUrl: attachment.path.replace(/^download\//, ''), pathOrUrl: attachment.path.replace(/^download\//, ''),
preview: true,
mimetype: attachment.mimetype,
filename: attachment.title,
}).then((r) => (attachment.signedPath = r)), }).then((r) => (attachment.signedPath = r)),
); );
} else if (attachment?.url) { } else if (attachment?.url) {
promises.push( promises.push(
PresignedUrl.getSignedUrl({ PresignedUrl.getSignedUrl({
pathOrUrl: attachment.url, pathOrUrl: attachment.url,
preview: true,
mimetype: attachment.mimetype,
filename: attachment.title,
}).then((r) => (attachment.signedUrl = r)), }).then((r) => (attachment.signedUrl = r)),
); );
} }

23
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;
}

51
packages/nocodb/src/models/PresignedUrl.ts

@ -3,6 +3,7 @@ import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2';
import Noco from '~/Noco'; import Noco from '~/Noco';
import NocoCache from '~/cache/NocoCache'; import NocoCache from '~/cache/NocoCache';
import { CacheGetType, CacheScope } from '~/utils/globals'; import { CacheGetType, CacheScope } from '~/utils/globals';
import { isPreviewAllowed } from '~/helpers/attachmentHelpers';
function roundExpiry(date) { function roundExpiry(date) {
const msInHour = 10 * 60 * 1000; const msInHour = 10 * 60 * 1000;
@ -91,6 +92,8 @@ export default class PresignedUrl {
pathOrUrl: string; pathOrUrl: string;
expireSeconds?: number; expireSeconds?: number;
filename?: string; filename?: string;
preview?: boolean;
mimetype?: string;
}, },
ncMeta = Noco.ncMeta, ncMeta = Noco.ncMeta,
) { ) {
@ -100,7 +103,15 @@ export default class PresignedUrl {
? decodeURI(new URL(param.pathOrUrl).pathname) ? decodeURI(new URL(param.pathOrUrl).pathname)
: param.pathOrUrl.replace(/^\/+/, ''); : 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( const expireAt = roundExpiry(
new Date(new Date().getTime() + expireSeconds * 1000), new Date(new Date().getTime() + expireSeconds * 1000),
@ -113,8 +124,35 @@ export default class PresignedUrl {
let tempUrl; 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( const url = await NocoCache.get(
`${CacheScope.PRESIGNED_URL}:path:${path}`, `${CacheScope.PRESIGNED_URL}:path:${cachePath}`,
CacheGetType.TYPE_OBJECT, CacheGetType.TYPE_OBJECT,
); );
@ -135,10 +173,10 @@ export default class PresignedUrl {
tempUrl = await (storageAdapter as any).getSignedUrl( tempUrl = await (storageAdapter as any).getSignedUrl(
path, path,
expiresInSeconds, expiresInSeconds,
filename, pathParameters,
); );
await this.add({ await this.add({
path: path, path: cachePath,
url: tempUrl, url: tempUrl,
expires_at: expireAt, expires_at: expireAt,
expiresInSeconds, expiresInSeconds,
@ -149,10 +187,7 @@ export default class PresignedUrl {
? param.pathOrUrl ? param.pathOrUrl
: `dltemp/${nanoid(16)}/${expireAt.getTime()}/${path}`; : `dltemp/${nanoid(16)}/${expireAt.getTime()}/${path}`;
// if filename is present, add it to the destination path = `${path}?${new URLSearchParams(pathParameters).toString()}`;
if (filename) {
path = `${path}?filename=${encodeURIComponent(filename)}`;
}
await this.add({ await this.add({
path: path, path: path,

4
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/', '')), pathOrUrl: path.join(destPath.replace('nc/uploads/', '')),
filename: `${model.title} (${getViewTitle(view)}).csv`, filename: `${model.title} (${getViewTitle(view)}).csv`,
expireSeconds: 3 * 60 * 60, // 3 hours expireSeconds: 3 * 60 * 60, // 3 hours
preview: false,
mimetype: 'text/csv',
}); });
} else { } else {
url = await PresignedUrl.getSignedUrl({ url = await PresignedUrl.getSignedUrl({
pathOrUrl: url, pathOrUrl: url,
filename: `${model.title} (${getViewTitle(view)}).csv`, filename: `${model.title} (${getViewTitle(view)}).csv`,
expireSeconds: 3 * 60 * 60, // 3 hours expireSeconds: 3 * 60 * 60, // 3 hours
preview: false,
mimetype: 'text/csv',
}); });
} }

10
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({ const command = new GetObjectCommand({
Key: key, Key: key,
Bucket: this.input.bucket, Bucket: this.input.bucket,
...(filename ...pathParameters,
? { ResponseContentDisposition: `attachment; filename="${filename}" ` }
: {}),
}); });
return getSignedUrl(this.s3Client, command, { return getSignedUrl(this.s3Client, command, {
expiresIn: expiresInSeconds, expiresIn: expiresInSeconds,

159
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}": { "/api/v1/db/public/shared-view/{sharedViewUuid}/group/{columnId}": {
"parameters": [ "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": { "/api/v2/tables/{tableId}/links/{columnId}/records": {
"parameters": [ "parameters": [
{ {

50
packages/nocodb/src/services/attachments.service.ts

@ -83,10 +83,14 @@ export class AttachmentsService {
attachment.signedPath = await PresignedUrl.getSignedUrl({ attachment.signedPath = await PresignedUrl.getSignedUrl({
pathOrUrl: attachment.path.replace(/^download\//, ''), pathOrUrl: attachment.path.replace(/^download\//, ''),
preview: true,
mimetype: attachment.mimetype,
}); });
} else { } else {
attachment.signedUrl = await PresignedUrl.getSignedUrl({ attachment.signedUrl = await PresignedUrl.getSignedUrl({
pathOrUrl: attachment.url, pathOrUrl: attachment.url,
preview: true,
mimetype: attachment.mimetype,
}); });
} }
@ -212,12 +216,48 @@ export class AttachmentsService {
return { path: filePath, type }; return { path: filePath, type };
} }
previewAvailable(mimetype: string) { async getAttachmentFromRecord(param: {
const available = ['image', 'pdf', 'text/plain']; record: any;
if (available.some((type) => mimetype.includes(type))) { column: { title: string };
return true; 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) { sanitizeUrlPath(paths) {

48
packages/nocodb/src/services/public-datas.service.ts

@ -737,4 +737,52 @@ export class PublicDatasService {
return new PagedResponseImpl(data, { ...param.query, count }); 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;
}
} }

Loading…
Cancel
Save