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) => {
{{ $t('title.downloadFile') }}
-
+
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;
+ }
}