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. 78
      packages/nocodb/src/controllers/attachments-secure.controller.ts
  8. 82
      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 { useAttachmentCell } from './utils'
const { selectedImage, visibleItems, downloadFile } = useAttachmentCell()!
const { selectedImage, visibleItems, downloadAttachment } = useAttachmentCell()!
const carouselRef = ref()
@ -65,7 +65,7 @@ useEventListener(container, 'click', (e) => {
<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"
@click.stop="downloadFile(selectedImage)"
@click.stop="downloadAttachment(selectedImage)"
>
<h3 class="group-hover:text-primary">{{ selectedImage && selectedImage.title }}</h3>
</div>

8
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]"
>
<NcButton type="primary" class="nc-attachment-download-all" @click="bulkDownloadFiles">
<NcButton type="primary" class="nc-attachment-download-all" @click="bulkDownloadAttachments">
{{ $t('activity.bulkDownload') }}
</NcButton>
</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">
<NcTooltip placement="bottom">
<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" />
</NcButton>
</NcTooltip>

5
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) => {
</div>
</template>
<LazyCellAttachmentModal />
<LazyCellAttachmentModal v-if="modalRendered" />
<LazyGeneralDeleteModal
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 { populateUniqueFileName } from 'nocodb-sdk'
import DOMPurify from 'isomorphic-dompurify'
import { saveAs } from 'file-saver'
import RenameFile from './RenameFile.vue'
import MdiPdfBox from '~icons/mdi/pdf-box'
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(
(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 { t } = useI18n()
@ -30,6 +37,8 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
const attachments = ref<AttachmentType[]>([])
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,

2
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
}

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

78
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,
});
}
}

82
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,
});
}
}

48
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,
});
}
}

12
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)),
);
}

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 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,

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/', '')),
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',
});
}

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

50
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) {

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

Loading…
Cancel
Save