Browse Source

feat: thumbnail generator (#8974)

* feat: thumbnail-processor

* fix: use signedPath instead of path

* fix: rebase

* fix: minor fix

* fix: url path

* fix: use thumbnails for attachments carousel nav
pull/9075/head
Anbarasu 4 months ago committed by GitHub
parent
commit
cb3a9cfb0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      packages/nc-gui/components/cell/attachment/Carousel.vue
  2. 4
      packages/nc-gui/components/cell/attachment/Modal.vue
  3. 15
      packages/nc-gui/components/cell/attachment/Preview/Image.vue
  4. 27
      packages/nc-gui/components/cell/attachment/index.vue
  5. 2
      packages/nc-gui/components/smartsheet/Gallery.vue
  6. 2
      packages/nc-gui/components/smartsheet/Kanban.vue
  7. 2
      packages/nc-gui/components/smartsheet/calendar/SideMenu.vue
  8. 2
      packages/nc-gui/components/virtual-cell/components/ListItem.vue
  9. 6
      packages/nc-gui/composables/useAttachment.ts
  10. 1
      packages/nocodb/.gitignore
  11. 9
      packages/nocodb/src/controllers/attachments-secure.controller.ts
  12. 21
      packages/nocodb/src/controllers/attachments.controller.spec.ts
  13. 17
      packages/nocodb/src/controllers/attachments.controller.ts
  14. 197
      packages/nocodb/src/db/BaseModelSqlv2.ts
  15. 7
      packages/nocodb/src/interface/Jobs.ts
  16. 6
      packages/nocodb/src/modules/jobs/fallback/fallback-queue.service.ts
  17. 2
      packages/nocodb/src/modules/jobs/jobs.module.ts
  18. 152
      packages/nocodb/src/modules/jobs/jobs/thumbnail-generator/thumbnail-generator.processor.ts
  19. 5
      packages/nocodb/src/modules/noco.module.ts
  20. 60
      packages/nocodb/src/plugins/backblaze/Backblaze.ts
  21. 31
      packages/nocodb/src/plugins/gcs/Gcs.ts
  22. 60
      packages/nocodb/src/plugins/linode/LinodeObjectStorage.ts
  23. 70
      packages/nocodb/src/plugins/mino/Minio.ts
  24. 65
      packages/nocodb/src/plugins/ovhCloud/OvhCloud.ts
  25. 19
      packages/nocodb/src/plugins/s3/S3.ts
  26. 60
      packages/nocodb/src/plugins/scaleway/ScalewayObjectStorage.ts
  27. 60
      packages/nocodb/src/plugins/spaces/Spaces.ts
  28. 25
      packages/nocodb/src/plugins/storage/Local.ts
  29. 60
      packages/nocodb/src/plugins/upcloud/UpoCloud.ts
  30. 60
      packages/nocodb/src/plugins/vultr/Vultr.ts
  31. 77
      packages/nocodb/src/services/attachments.service.ts
  32. 64
      packages/nocodb/src/services/public-datas.service.ts

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

@ -265,7 +265,7 @@ const initEmblaApi = (val: any) => {
class="nc-attachment-img-wrapper h-12" class="nc-attachment-img-wrapper h-12"
object-fit="contain" object-fit="contain"
:alt="item.title" :alt="item.title"
:srcs="getPossibleAttachmentSrc(item)" :srcs="getPossibleAttachmentSrc(item, 'tiny')"
/> />
<div <div
v-else-if="isVideo(item.title, item.mimeType)" v-else-if="isVideo(item.title, item.mimeType)"

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

@ -34,7 +34,7 @@ const { isOverDropZone } = useDropZone(dropZoneRef, onDrop)
const { isSharedForm } = useSmartsheetStoreOrThrow() const { isSharedForm } = useSmartsheetStoreOrThrow()
const { getPossibleAttachmentSrc, openAttachment } = useAttachment() const { getPossibleAttachmentSrc } = useAttachment()
onKeyDown('Escape', () => { onKeyDown('Escape', () => {
modalVisible.value = false modalVisible.value = false
@ -152,7 +152,7 @@ const handleFileDelete = (i: number) => {
> >
<LazyCellAttachmentPreviewImage <LazyCellAttachmentPreviewImage
v-if="isImage(item.title, item.mimetype)" v-if="isImage(item.title, item.mimetype)"
:srcs="getPossibleAttachmentSrc(item)" :srcs="getPossibleAttachmentSrc(item, 'card_cover')"
object-fit="cover" object-fit="cover"
class="!w-full object-cover !m-0 rounded-t-[5px] justify-center" class="!w-full object-cover !m-0 rounded-t-[5px] justify-center"
@click.stop="onClick(item)" @click.stop="onClick(item)"

15
packages/nc-gui/components/cell/attachment/Preview/Image.vue

@ -13,17 +13,20 @@ const onError = () => index.value++
</script> </script>
<template> <template>
<LazyNuxtImg <!-- Replacing with Image component as nuxt-image is not triggering @error when the image doesn't load. Will fix later
v-if="index < props.srcs.length" TODO: @DarkPhoenix2704 Fix this later
-->
<img
v-if="index < props.srcs?.length"
:src="props.srcs[index]"
quality="75"
:placeholder="props.alt"
:class="{ :class="{
'!object-contain': props.objectFit === 'contain', '!object-contain': props.objectFit === 'contain',
}" }"
class="m-auto h-full max-h-full w-auto object-cover nc-attachment-image"
:src="props.srcs[index]"
loading="lazy" loading="lazy"
:alt="props?.alt || ''" :alt="props?.alt || ''"
placeholder class="m-auto h-full max-h-full w-auto nc-attachment-image object-cover"
quality="75"
@error="onError" @error="onError"
/> />
<component :is="iconMap.imagePlaceholder" v-else /> <component :is="iconMap.imagePlaceholder" v-else />

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

@ -166,7 +166,6 @@ const onFileClick = (item: any) => {
if (isMobileMode.value && !isExpandedForm.value) return if (isMobileMode.value && !isExpandedForm.value) return
if (!isMobileMode.value && (isGallery.value || isKanban.value) && !isExpandedForm.value) return if (!isMobileMode.value && (isGallery.value || isKanban.value) && !isExpandedForm.value) return
selectedFile.value = item selectedFile.value = item
} }
@ -202,6 +201,25 @@ const handleFileDelete = (i: number) => {
filetoDelete.i = 0 filetoDelete.i = 0
filetoDelete.title = '' filetoDelete.title = ''
} }
const attachmentSize = computed(() => {
if (isForm.value || isExpandedForm.value) {
return 'small'
}
switch (rowHeight.value) {
case 1:
return 'tiny'
case 2:
return 'tiny'
case 4:
return 'small'
case 6:
return 'small'
default:
return 'tiny'
}
})
</script> </script>
<template> <template>
@ -214,6 +232,7 @@ const handleFileDelete = (i: number) => {
</span> </span>
</div> </div>
</NcButton> </NcButton>
<LazyCellAttachmentCarousel v-if="selectedFile" />
<div v-if="visibleItems.length > 0" class="grid mt-2 gap-2 grid-cols-2"> <div v-if="visibleItems.length > 0" class="grid mt-2 gap-2 grid-cols-2">
<div <div
@ -227,10 +246,10 @@ const handleFileDelete = (i: number) => {
> >
<LazyCellAttachmentPreviewImage <LazyCellAttachmentPreviewImage
v-if="isImage(item.title, item.mimetype)" v-if="isImage(item.title, item.mimetype)"
:srcs="getPossibleAttachmentSrc(item)" :srcs="getPossibleAttachmentSrc(item, 'small')"
object-fit="cover" object-fit="cover"
class="!w-full !h-42 object-cover !m-0 rounded-t-[5px] justify-center" class="!w-full !h-42 object-cover !m-0 rounded-t-[5px] justify-center"
@click="selectedFile = item" @click="onFileClick(item)"
/> />
<component :is="FileIcon(item.icon)" v-else-if="item.icon" :height="45" :width="45" @click="selectedFile = item" /> <component :is="FileIcon(item.icon)" v-else-if="item.icon" :height="45" :width="45" @click="selectedFile = item" />
@ -382,7 +401,7 @@ const handleFileDelete = (i: number) => {
'h-16.8': rowHeight === 4, 'h-16.8': rowHeight === 4,
'h-20.8': rowHeight === 6, 'h-20.8': rowHeight === 6,
}" }"
:srcs="getPossibleAttachmentSrc(item)" :srcs="getPossibleAttachmentSrc(item, attachmentSize)"
/> />
</div> </div>
</div> </div>

2
packages/nc-gui/components/smartsheet/Gallery.vue

@ -304,7 +304,7 @@ watch(
:key="`carousel-${record.row.id}-${index}`" :key="`carousel-${record.row.id}-${index}`"
class="h-52" class="h-52"
:class="[`${coverImageObjectFitClass}`]" :class="[`${coverImageObjectFitClass}`]"
:srcs="getPossibleAttachmentSrc(attachment)" :srcs="getPossibleAttachmentSrc(attachment, 'card_cover')"
@click="expandFormClick($event, record)" @click="expandFormClick($event, record)"
/> />
</template> </template>

2
packages/nc-gui/components/smartsheet/Kanban.vue

@ -790,7 +790,7 @@ const handleSubmitRenameOrNewStack = async (loadMeta: boolean, stack?: any, stac
:key="attachment.path" :key="attachment.path"
class="h-52" class="h-52"
:class="[`${coverImageObjectFitClass}`]" :class="[`${coverImageObjectFitClass}`]"
:srcs="getPossibleAttachmentSrc(attachment)" :srcs="getPossibleAttachmentSrc(attachment, 'card_cover')"
/> />
</template> </template>
</a-carousel> </a-carousel>

2
packages/nc-gui/components/smartsheet/calendar/SideMenu.vue

@ -586,7 +586,7 @@ onClickOutside(searchRef, toggleSearch)
v-if="isImage(attachment.title, attachment.mimetype ?? attachment.type)" v-if="isImage(attachment.title, attachment.mimetype ?? attachment.type)"
:key="`carousel-${record.row.id}-${index}`" :key="`carousel-${record.row.id}-${index}`"
class="h-10 !w-10 !object-contain" class="h-10 !w-10 !object-contain"
:srcs="getPossibleAttachmentSrc(attachment)" :srcs="getPossibleAttachmentSrc(attachment, 'tiny')"
/> />
</template> </template>
</a-carousel> </a-carousel>

2
packages/nc-gui/components/virtual-cell/components/ListItem.vue

@ -92,7 +92,7 @@ const displayValue = computed(() => {
v-if="isImage(attachmentObj.title, attachmentObj.mimetype ?? attachmentObj.type)" v-if="isImage(attachmentObj.title, attachmentObj.mimetype ?? attachmentObj.type)"
:key="`carousel-${attachmentObj.title}-${index}`" :key="`carousel-${attachmentObj.title}-${index}`"
class="!w-11 !h-11 !max-h-11 !max-w-11object-cover !rounded-l-xl" class="!w-11 !h-11 !max-h-11 !max-w-11object-cover !rounded-l-xl"
:srcs="getPossibleAttachmentSrc(attachmentObj)" :srcs="getPossibleAttachmentSrc(attachmentObj, 'tiny')"
/> />
</template> </template>
</a-carousel> </a-carousel>

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

@ -1,8 +1,12 @@
const useAttachment = () => { const useAttachment = () => {
const { appInfo } = useGlobal() const { appInfo } = useGlobal()
const getPossibleAttachmentSrc = (item: Record<string, any>) => { const getPossibleAttachmentSrc = (item: Record<string, any>, thumbnail?: 'card_cover' | 'tiny' | 'small') => {
const res: string[] = [] const res: string[] = []
if (thumbnail && item?.thumbnails && item.thumbnails[thumbnail]) {
res.push(getPossibleAttachmentSrc(item.thumbnails[thumbnail])[0])
}
if (item?.data) res.push(item.data) if (item?.data) res.push(item.data)
if (item?.file) res.push(window.URL.createObjectURL(item.file)) if (item?.file) res.push(window.URL.createObjectURL(item.file))
if (item?.signedPath) res.push(`${appInfo.value.ncSiteUrl}/${encodeURI(item.signedPath)}`) if (item?.signedPath) res.push(`${appInfo.value.ncSiteUrl}/${encodeURI(item.signedPath)}`)

1
packages/nocodb/.gitignore vendored

@ -1,6 +1,7 @@
# compiled output # compiled output
/dist /dist
/node_modules /node_modules
nc
# Logs # Logs
logs logs

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

@ -1,4 +1,5 @@
import path from 'path'; import path from 'path';
import fs from 'fs';
import { import {
Body, Body,
Controller, Controller,
@ -96,10 +97,16 @@ export class AttachmentsSecureController {
); );
} }
const filePath = param.split('/')[2] === 'thumbnails' ? '' : 'uploads';
const file = await this.attachmentsService.getFile({ const file = await this.attachmentsService.getFile({
path: path.join('nc', 'uploads', fpath), path: path.join('nc', filePath, fpath),
}); });
if (!fs.existsSync(file.path)) {
return res.status(404).send('File not found');
}
if (queryResponseContentType) { if (queryResponseContentType) {
res.setHeader('Content-Type', queryResponseContentType); res.setHeader('Content-Type', queryResponseContentType);
} }

21
packages/nocodb/src/controllers/attachments.controller.spec.ts

@ -1,21 +0,0 @@
import { Test } from '@nestjs/testing';
import { AttachmentsService } from '../services/attachments.service';
import { AttachmentsController } from './attachments.controller';
import type { TestingModule } from '@nestjs/testing';
describe('AttachmentsController', () => {
let controller: AttachmentsController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AttachmentsController],
providers: [AttachmentsService],
}).compile();
controller = module.get<AttachmentsController>(AttachmentsController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

17
packages/nocodb/src/controllers/attachments.controller.ts

@ -1,4 +1,5 @@
import path from 'path'; import path from 'path';
import fs from 'fs';
import { import {
Body, Body,
Controller, Controller,
@ -83,6 +84,10 @@ export class AttachmentsController {
path: path.join('nc', 'uploads', filename), path: path.join('nc', 'uploads', filename),
}); });
if (!fs.existsSync(file.path)) {
return res.status(404).send('File not found');
}
if (isPreviewAllowed({ mimetype: file.type, path: file.path })) { if (isPreviewAllowed({ mimetype: file.type, path: file.path })) {
if (queryFilename) { if (queryFilename) {
res.setHeader( res.setHeader(
@ -120,6 +125,10 @@ export class AttachmentsController {
), ),
}); });
if (!fs.existsSync(file.path)) {
return res.status(404).send('File not found');
}
if (isPreviewAllowed({ mimetype: file.type, path: file.path })) { if (isPreviewAllowed({ mimetype: file.type, path: file.path })) {
if (queryFilename) { if (queryFilename) {
res.setHeader( res.setHeader(
@ -156,10 +165,16 @@ export class AttachmentsController {
); );
} }
const filePath = param.split('/')[2] === 'thumbnails' ? '' : 'uploads';
const file = await this.attachmentsService.getFile({ const file = await this.attachmentsService.getFile({
path: path.join('nc', 'uploads', fpath), path: path.join('nc', filePath, fpath),
}); });
if (!fs.existsSync(file.path)) {
return res.status(404).send('File not found');
}
if (queryResponseContentType) { if (queryResponseContentType) {
res.setHeader('Content-Type', queryResponseContentType); res.setHeader('Content-Type', queryResponseContentType);
} }

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

@ -7916,47 +7916,228 @@ class BaseModelSqlv2 {
if (Array.isArray(attachment)) { if (Array.isArray(attachment)) {
for (const lookedUpAttachment of attachment) { for (const lookedUpAttachment of attachment) {
if (lookedUpAttachment?.path) { if (lookedUpAttachment?.path) {
let relativePath = lookedUpAttachment.path.replace(
/^download\//,
'',
);
promises.push( promises.push(
PresignedUrl.getSignedUrl({ PresignedUrl.getSignedUrl({
pathOrUrl: lookedUpAttachment.path.replace( pathOrUrl: relativePath,
/^download\//,
'',
),
preview: true, preview: true,
mimetype: lookedUpAttachment.mimetype, mimetype: lookedUpAttachment.mimetype,
filename: lookedUpAttachment.title, filename: lookedUpAttachment.title,
}).then((r) => (lookedUpAttachment.signedPath = r)), }).then((r) => (lookedUpAttachment.signedPath = r)),
); );
if (!lookedUpAttachment.mimetype?.startsWith('image/')) {
continue;
}
lookedUpAttachment.thumbnails = {
tiny: {},
small: {},
card_cover: {},
};
relativePath = `thumbnails/${relativePath}`;
promises.push(
PresignedUrl.getSignedUrl({
pathOrUrl: `${relativePath}/tiny.jpg`,
preview: true,
mimetype: 'image/jpeg',
filename: lookedUpAttachment.title,
}).then(
(r) =>
(lookedUpAttachment.thumbnails.tiny.signedPath = r),
),
);
promises.push(
PresignedUrl.getSignedUrl({
pathOrUrl: `${relativePath}/small.jpg`,
preview: true,
mimetype: 'image/jpeg',
filename: lookedUpAttachment.title,
}).then(
(r) =>
(lookedUpAttachment.thumbnails.small.signedPath = r),
),
);
promises.push(
PresignedUrl.getSignedUrl({
pathOrUrl: `${relativePath}/card_cover.jpg`,
preview: true,
mimetype: 'image/jpeg',
filename: lookedUpAttachment.title,
}).then(
(r) =>
(lookedUpAttachment.thumbnails.card_cover.signedPath =
r),
),
);
} else if (lookedUpAttachment?.url) { } else if (lookedUpAttachment?.url) {
let relativePath = lookedUpAttachment.url;
promises.push( promises.push(
PresignedUrl.getSignedUrl({ PresignedUrl.getSignedUrl({
pathOrUrl: lookedUpAttachment.url, pathOrUrl: relativePath,
preview: true, preview: true,
mimetype: lookedUpAttachment.mimetype, mimetype: lookedUpAttachment.mimetype,
filename: lookedUpAttachment.title, filename: lookedUpAttachment.title,
}).then((r) => (lookedUpAttachment.signedUrl = r)), }).then((r) => (lookedUpAttachment.signedUrl = r)),
); );
if (!lookedUpAttachment.mimetype?.startsWith('image/')) {
continue;
}
relativePath = relativePath.replace(
'nc/uploads',
'nc/thumbnails',
);
lookedUpAttachment.thumbnails = {
tiny: {},
small: {},
card_cover: {},
};
promises.push(
PresignedUrl.getSignedUrl({
pathOrUrl: `${relativePath}/tiny.jpg`,
preview: true,
mimetype: 'image/jpeg',
filename: lookedUpAttachment.title,
}).then(
(r) =>
(lookedUpAttachment.thumbnails.tiny.signedUrl = r),
),
);
promises.push(
PresignedUrl.getSignedUrl({
pathOrUrl: `${relativePath}/small.jpg`,
preview: true,
mimetype: 'image/jpeg',
filename: lookedUpAttachment.title,
}).then(
(r) =>
(lookedUpAttachment.thumbnails.small.signedUrl = r),
),
);
promises.push(
PresignedUrl.getSignedUrl({
pathOrUrl: `${relativePath}/card_cover.jpg`,
preview: true,
mimetype: 'image/jpeg',
filename: lookedUpAttachment.title,
}).then(
(r) =>
(lookedUpAttachment.thumbnails.card_cover.signedUrl =
r),
),
);
} }
} }
} else { } else {
if (attachment?.path) { if (attachment?.path) {
let relativePath = attachment.path.replace(/^download\//, '');
promises.push( promises.push(
PresignedUrl.getSignedUrl({ PresignedUrl.getSignedUrl({
pathOrUrl: attachment.path.replace(/^download\//, ''), pathOrUrl: relativePath,
preview: true, preview: true,
mimetype: attachment.mimetype, mimetype: attachment.mimetype,
filename: attachment.title, filename: attachment.title,
}).then((r) => (attachment.signedPath = r)), }).then((r) => (attachment.signedPath = r)),
); );
if (!attachment.mimetype?.startsWith('image/')) {
continue;
}
relativePath = `thumbnails/${relativePath}`;
attachment.thumbnails = {
tiny: {},
small: {},
card_cover: {},
};
promises.push(
PresignedUrl.getSignedUrl({
pathOrUrl: `${relativePath}/tiny.jpg`,
preview: true,
mimetype: 'image/jpeg',
filename: attachment.title,
}).then((r) => (attachment.thumbnails.tiny.signedPath = r)),
);
promises.push(
PresignedUrl.getSignedUrl({
pathOrUrl: `${relativePath}/small.jpg`,
preview: true,
mimetype: 'image/jpeg',
filename: attachment.title,
}).then(
(r) => (attachment.thumbnails.small.signedPath = r),
),
);
promises.push(
PresignedUrl.getSignedUrl({
pathOrUrl: `${relativePath}/card_cover.jpg`,
preview: true,
mimetype: 'image/jpeg',
filename: attachment.title,
}).then(
(r) => (attachment.thumbnails.card_cover.signedPath = r),
),
);
} else if (attachment?.url) { } else if (attachment?.url) {
let relativePath = attachment.url;
promises.push( promises.push(
PresignedUrl.getSignedUrl({ PresignedUrl.getSignedUrl({
pathOrUrl: attachment.url, pathOrUrl: relativePath,
preview: true, preview: true,
mimetype: attachment.mimetype, mimetype: attachment.mimetype,
filename: attachment.title, filename: attachment.title,
}).then((r) => (attachment.signedUrl = r)), }).then((r) => (attachment.signedUrl = r)),
); );
relativePath = relativePath.replace(
'nc/uploads',
'nc/thumbnails',
);
attachment.thumbnails = {
tiny: {},
small: {},
card_cover: {},
};
promises.push(
PresignedUrl.getSignedUrl({
pathOrUrl: `${relativePath}/tiny.jpg`,
preview: true,
mimetype: 'image/jpeg',
filename: attachment.title,
}).then((r) => (attachment.thumbnails.tiny.signedUrl = r)),
);
promises.push(
PresignedUrl.getSignedUrl({
pathOrUrl: `${relativePath}/small.jpg`,
preview: true,
mimetype: 'image/jpeg',
filename: attachment.title,
}).then((r) => (attachment.thumbnails.small.signedUrl = r)),
);
promises.push(
PresignedUrl.getSignedUrl({
pathOrUrl: `${relativePath}/card_cover.jpg`,
preview: true,
mimetype: 'image/jpeg',
filename: attachment.title,
}).then(
(r) => (attachment.thumbnails.card_cover.signedUrl = r),
),
);
} }
} }
} }
@ -9097,6 +9278,8 @@ class BaseModelSqlv2 {
'mimetype', 'mimetype',
'size', 'size',
'icon', 'icon',
'width',
'height',
]), ]),
); );
} }

7
packages/nocodb/src/interface/Jobs.ts

@ -1,4 +1,4 @@
import type { UserType } from 'nocodb-sdk'; import type { AttachmentResType, UserType } from 'nocodb-sdk';
import type { NcContext, NcRequest } from '~/interface/config'; import type { NcContext, NcRequest } from '~/interface/config';
export const JOBS_QUEUE = 'jobs'; export const JOBS_QUEUE = 'jobs';
@ -17,6 +17,7 @@ export enum JobTypes {
HandleWebhook = 'handle-webhook', HandleWebhook = 'handle-webhook',
CleanUp = 'clean-up', CleanUp = 'clean-up',
DataExport = 'data-export', DataExport = 'data-export',
ThumbnailGenerator = 'thumbnail-generator',
} }
export enum JobStatus { export enum JobStatus {
@ -121,3 +122,7 @@ export interface DataExportJobData extends JobData {
exportAs: 'csv' | 'json' | 'xlsx'; exportAs: 'csv' | 'json' | 'xlsx';
ncSiteUrl: string; ncSiteUrl: string;
} }
export interface ThumbnailGeneratorJobData extends JobData {
attachments: AttachmentResType[];
}

6
packages/nocodb/src/modules/jobs/fallback/fallback-queue.service.ts

@ -10,6 +10,7 @@ import { WebhookHandlerProcessor } from '~/modules/jobs/jobs/webhook-handler/web
import { DataExportProcessor } from '~/modules/jobs/jobs/data-export/data-export.processor'; import { DataExportProcessor } from '~/modules/jobs/jobs/data-export/data-export.processor';
import { JobsEventService } from '~/modules/jobs/jobs-event.service'; import { JobsEventService } from '~/modules/jobs/jobs-event.service';
import { JobStatus, JobTypes } from '~/interface/Jobs'; import { JobStatus, JobTypes } from '~/interface/Jobs';
import { ThumbnailGeneratorProcessor } from '~/modules/jobs/jobs/thumbnail-generator/thumbnail-generator.processor';
export interface Job { export interface Job {
id: string; id: string;
@ -35,6 +36,7 @@ export class QueueService {
protected readonly sourceDeleteProcessor: SourceDeleteProcessor, protected readonly sourceDeleteProcessor: SourceDeleteProcessor,
protected readonly webhookHandlerProcessor: WebhookHandlerProcessor, protected readonly webhookHandlerProcessor: WebhookHandlerProcessor,
protected readonly dataExportProcessor: DataExportProcessor, protected readonly dataExportProcessor: DataExportProcessor,
protected readonly thumbnailGeneratorProcessor: ThumbnailGeneratorProcessor,
) { ) {
this.emitter.on(JobStatus.ACTIVE, (data: { job: Job }) => { this.emitter.on(JobStatus.ACTIVE, (data: { job: Job }) => {
const job = this.queueMemory.find((job) => job.id === data.job.id); const job = this.queueMemory.find((job) => job.id === data.job.id);
@ -100,6 +102,10 @@ export class QueueService {
this: this.dataExportProcessor, this: this.dataExportProcessor,
fn: this.dataExportProcessor.job, fn: this.dataExportProcessor.job,
}, },
[JobTypes.ThumbnailGenerator]: {
this: this.thumbnailGeneratorProcessor,
fn: this.thumbnailGeneratorProcessor.job,
},
}; };
async jobWrapper(job: Job) { async jobWrapper(job: Job) {

2
packages/nocodb/src/modules/jobs/jobs.module.ts

@ -18,6 +18,7 @@ import { SourceDeleteProcessor } from '~/modules/jobs/jobs/source-delete/source-
import { WebhookHandlerProcessor } from '~/modules/jobs/jobs/webhook-handler/webhook-handler.processor'; import { WebhookHandlerProcessor } from '~/modules/jobs/jobs/webhook-handler/webhook-handler.processor';
import { DataExportProcessor } from '~/modules/jobs/jobs/data-export/data-export.processor'; import { DataExportProcessor } from '~/modules/jobs/jobs/data-export/data-export.processor';
import { DataExportController } from '~/modules/jobs/jobs/data-export/data-export.controller'; import { DataExportController } from '~/modules/jobs/jobs/data-export/data-export.controller';
import { ThumbnailGeneratorProcessor } from '~/modules/jobs/jobs/thumbnail-generator/thumbnail-generator.processor';
// Jobs Module Related // Jobs Module Related
import { JobsLogService } from '~/modules/jobs/jobs/jobs-log.service'; import { JobsLogService } from '~/modules/jobs/jobs/jobs-log.service';
@ -78,6 +79,7 @@ export const JobsModuleMetadata = {
SourceDeleteProcessor, SourceDeleteProcessor,
WebhookHandlerProcessor, WebhookHandlerProcessor,
DataExportProcessor, DataExportProcessor,
ThumbnailGeneratorProcessor,
], ],
exports: ['JobsService'], exports: ['JobsService'],
}; };

152
packages/nocodb/src/modules/jobs/jobs/thumbnail-generator/thumbnail-generator.processor.ts

@ -0,0 +1,152 @@
import path from 'path';
import { Readable } from 'stream';
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
import { Logger } from '@nestjs/common';
import sharp from 'sharp';
import axios from 'axios';
import type { AttachmentResType } from 'nocodb-sdk';
import type { ThumbnailGeneratorJobData } from '~/interface/Jobs';
import { JOBS_QUEUE, JobTypes } from '~/interface/Jobs';
import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2';
import { PresignedUrl } from '~/models';
import { AttachmentsService } from '~/services/attachments.service';
const attachmentPreviews = ['image/'];
@Processor(JOBS_QUEUE)
export class ThumbnailGeneratorProcessor {
constructor(private readonly attachmentsService: AttachmentsService) {}
private logger = new Logger(ThumbnailGeneratorProcessor.name);
@Process(JobTypes.ThumbnailGenerator)
async job(job: Job<ThumbnailGeneratorJobData>) {
try {
const { attachments } = job.data;
const thumbnailPromises = attachments
.filter((attachment) =>
attachmentPreviews.some((type) =>
attachment.mimetype.startsWith(type),
),
)
.map(async (attachment) => {
const thumbnail = await this.generateThumbnail(attachment);
return {
path: attachment.path ?? attachment.url,
card_cover: thumbnail?.card_cover,
small: thumbnail?.small,
tiny: thumbnail?.tiny,
};
});
return await Promise.all(thumbnailPromises);
} catch (error) {
this.logger.error('Failed to generate thumbnails', error);
}
}
private async generateThumbnail(
attachment: AttachmentResType,
): Promise<{ [key: string]: string }> {
const { file, relativePath } = await this.getFileData(attachment);
const thumbnailPaths = {
card_cover: path.join('nc', 'thumbnails', relativePath, 'card_cover.jpg'),
small: path.join('nc', 'thumbnails', relativePath, 'small.jpg'),
tiny: path.join('nc', 'thumbnails', relativePath, 'tiny.jpg'),
};
try {
const storageAdapter = await NcPluginMgrv2.storageAdapter();
await Promise.all(
Object.entries(thumbnailPaths).map(async ([size, thumbnailPath]) => {
let height;
switch (size) {
case 'card_cover':
height = 512;
break;
case 'small':
height = 128;
break;
case 'tiny':
height = 64;
break;
default:
height = 32;
break;
}
const resizedImage = await sharp(file, {
limitInputPixels: false,
})
.resize(undefined, height, {
fit: sharp.fit.cover,
kernel: 'lanczos3',
})
.toBuffer();
await (storageAdapter as any).fileCreateByStream(
thumbnailPath,
Readable.from(resizedImage),
{
mimetype: 'image/jpeg',
},
);
}),
);
return thumbnailPaths;
} catch (error) {
this.logger.error(
`Failed to generate thumbnails for ${attachment.path}`,
error,
);
}
}
private async getFileData(
attachment: AttachmentResType,
): Promise<{ file: Buffer; relativePath: string }> {
let url, signedUrl, file;
let relativePath;
if (attachment.path) {
relativePath = attachment.path.replace(/^download\//, '');
url = await PresignedUrl.getSignedUrl({
pathOrUrl: relativePath,
preview: false,
filename: attachment.title,
mimetype: attachment.mimetype,
});
const fullPath = await PresignedUrl.getPath(`${url}`);
const [fpath] = fullPath.split('?');
const tempPath = await this.attachmentsService.getFile({
path: path.join('nc', 'uploads', fpath),
});
relativePath = fpath;
file = tempPath.path;
} else if (attachment.url) {
relativePath = decodeURI(new URL(attachment.url).pathname);
signedUrl = await PresignedUrl.getSignedUrl({
pathOrUrl: relativePath,
preview: false,
filename: attachment.title,
mimetype: attachment.mimetype,
});
file = (await axios({ url: signedUrl, responseType: 'arraybuffer' }))
.data as Buffer;
}
if (relativePath.startsWith('/nc/uploads/')) {
relativePath = relativePath.replace('/nc/uploads/', '');
}
return { file, relativePath };
}
}

5
packages/nocodb/src/modules/noco.module.ts

@ -1,8 +1,8 @@
import multer from 'multer';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
/* Modules */ /* Modules */
import { MulterModule } from '@nestjs/platform-express';
import multer from 'multer';
import { EventEmitterModule } from '~/modules/event-emitter/event-emitter.module'; import { EventEmitterModule } from '~/modules/event-emitter/event-emitter.module';
import { JobsModule } from '~/modules/jobs/jobs.module'; import { JobsModule } from '~/modules/jobs/jobs.module';
@ -181,7 +181,6 @@ export const nocoModuleMetadata = {
CommandPaletteController, CommandPaletteController,
ExtensionsController, ExtensionsController,
JobsMetaController, JobsMetaController,
/* Datas */ /* Datas */
DataTableController, DataTableController,
DatasController, DatasController,

60
packages/nocodb/src/plugins/backblaze/Backblaze.ts

@ -16,31 +16,10 @@ export default class Backblaze implements IStorageAdapterV2 {
} }
async fileCreate(key: string, file: XcFile): Promise<any> { async fileCreate(key: string, file: XcFile): Promise<any> {
const uploadParams: any = { const fileStream = fs.createReadStream(file.path);
ACL: 'public-read',
ContentType: file.mimetype,
};
return new Promise((resolve, reject) => {
// Configure the file stream and obtain the upload parameters
const fileStream = fs.createReadStream(file.path);
fileStream.on('error', (err) => {
console.log('File Error', err);
reject(err);
});
uploadParams.Body = fileStream; return this.fileCreateByStream(key, fileStream, {
uploadParams.Key = key; mimetype: file?.mimetype,
// call S3 to retrieve upload file to specified bucket
this.s3Client.upload(uploadParams, (err, data) => {
if (err) {
console.log('Error', err);
reject(err);
}
if (data) {
resolve(data.Location);
}
});
}); });
} }
@ -69,7 +48,10 @@ export default class Backblaze implements IStorageAdapterV2 {
reject(err1); reject(err1);
} }
if (data) { if (data) {
resolve(data.Location); resolve({
url: data.Location,
data: response.data,
});
} }
}); });
}) })
@ -79,9 +61,31 @@ export default class Backblaze implements IStorageAdapterV2 {
}); });
} }
// TODO - implement async fileCreateByStream(
fileCreateByStream(_key: string, _stream: Readable): Promise<void> { key: string,
return Promise.resolve(undefined); stream: Readable,
options?: {
mimetype?: string;
},
): Promise<void> {
const uploadParams: any = {
ACL: 'public-read',
Body: stream,
Key: key,
ContentType: options?.mimetype || 'application/octet-stream',
};
return new Promise((resolve, reject) => {
// call S3 to retrieve upload file to specified bucket
this.s3Client.upload(uploadParams, (err, data) => {
if (err) {
console.log('Error', err);
reject(err);
}
if (data) {
resolve(data.Location);
}
});
});
} }
// TODO - implement // TODO - implement

31
packages/nocodb/src/plugins/gcs/Gcs.ts

@ -22,6 +22,7 @@ export default class Gcs implements IStorageAdapterV2 {
.bucket(this.bucketName) .bucket(this.bucketName)
.upload(file.path, { .upload(file.path, {
destination: key, destination: key,
contentType: file?.mimetype || 'application/octet-stream',
// Support for HTTP requests made with `Accept-Encoding: gzip` // Support for HTTP requests made with `Accept-Encoding: gzip`
gzip: true, gzip: true,
// By setting the option `destination`, you can change the name of the // By setting the option `destination`, you can change the name of the
@ -118,7 +119,7 @@ export default class Gcs implements IStorageAdapterV2 {
.bucket(this.bucketName) .bucket(this.bucketName)
.file(destPath) .file(destPath)
.save(response.data) .save(response.data)
.then((res) => resolve(res)) .then((res) => resolve({ url: res, data: response.data }))
.catch(reject); .catch(reject);
}) })
.catch((error) => { .catch((error) => {
@ -127,9 +128,31 @@ export default class Gcs implements IStorageAdapterV2 {
}); });
} }
// TODO - implement async fileCreateByStream(
fileCreateByStream(_key: string, _stream: Readable): Promise<void> { key: string,
return Promise.resolve(undefined); stream: Readable,
options?: {
mimetype?: string;
},
): Promise<void> {
const uploadResponse = await this.storageClient
.bucket(this.bucketName)
.file(key)
.save(stream, {
// Support for HTTP requests made with `Accept-Encoding: gzip`
gzip: true,
// By setting the option `destination`, you can change the name of the
// object you are uploading to a bucket.
metadata: {
contentType: options.mimetype || 'application/octet-stream',
// Enable long-lived HTTP caching headers
// Use only if the contents of the file will never change
// (If the contents will change, use cacheControl: 'no-cache')
cacheControl: 'public, max-age=31536000',
},
});
return uploadResponse[0].publicUrl();
} }
// TODO - implement // TODO - implement

60
packages/nocodb/src/plugins/linode/LinodeObjectStorage.ts

@ -16,31 +16,10 @@ export default class LinodeObjectStorage implements IStorageAdapterV2 {
} }
async fileCreate(key: string, file: XcFile): Promise<any> { async fileCreate(key: string, file: XcFile): Promise<any> {
const uploadParams: any = { const fileStream = fs.createReadStream(file.path);
ACL: 'public-read',
ContentType: file.mimetype,
};
return new Promise((resolve, reject) => {
// Configure the file stream and obtain the upload parameters
const fileStream = fs.createReadStream(file.path);
fileStream.on('error', (err) => {
console.log('File Error', err);
reject(err);
});
uploadParams.Body = fileStream; return this.fileCreateByStream(key, fileStream, {
uploadParams.Key = key; mimetype: file?.mimetype,
// call S3 to retrieve upload file to specified bucket
this.s3Client.upload(uploadParams, (err, data) => {
if (err) {
console.log('Error', err);
reject(err);
}
if (data) {
resolve(data.Location);
}
});
}); });
} }
@ -68,7 +47,10 @@ export default class LinodeObjectStorage implements IStorageAdapterV2 {
reject(err1); reject(err1);
} }
if (data) { if (data) {
resolve(data.Location); resolve({
url: data.Location,
data: response.data,
});
} }
}); });
}) })
@ -78,9 +60,31 @@ export default class LinodeObjectStorage implements IStorageAdapterV2 {
}); });
} }
// TODO - implement public async fileCreateByStream(
fileCreateByStream(_key: string, _stream: Readable): Promise<void> { key: string,
return Promise.resolve(undefined); stream: Readable,
options?: {
mimetype?: string;
},
): Promise<any> {
const uploadParams: any = {
ACL: 'public-read',
Body: stream,
Key: key,
ContentType: options?.mimetype || 'application/octet-stream',
};
return new Promise((resolve, reject) => {
// call S3 to retrieve upload file to specified bucket
this.s3Client.upload(uploadParams, (err, data) => {
if (err) {
console.log('Error', err);
reject(err);
}
if (data) {
resolve(data.Location);
}
});
});
} }
// TODO - implement // TODO - implement

70
packages/nocodb/src/plugins/mino/Minio.ts

@ -16,32 +16,9 @@ export default class Minio implements IStorageAdapterV2 {
} }
async fileCreate(key: string, file: XcFile): Promise<any> { async fileCreate(key: string, file: XcFile): Promise<any> {
return new Promise((resolve, reject) => { const fileStream = fs.createReadStream(file.path);
// Configure the file stream and obtain the upload parameters return this.fileCreateByStream(key, fileStream, {
const fileStream = fs.createReadStream(file.path); mimetype: file?.mimetype,
fileStream.on('error', (err) => {
console.log('File Error', err);
reject(err);
});
// uploadParams.Body = fileStream;
// uploadParams.Key = key;
const metaData = {
'Content-Type': file.mimetype,
// 'X-Amz-Meta-Testing': 1234,
// 'run': 5678
};
// call S3 to retrieve upload file to specified bucket
this.minioClient
.putObject(this.input?.bucket, key, fileStream, metaData)
.then(() => {
resolve(
`http${this.input.useSSL ? 's' : ''}://${this.input.endPoint}:${
this.input.port
}/${this.input.bucket}/${key}`,
);
})
.catch(reject);
}); });
} }
@ -117,11 +94,12 @@ export default class Minio implements IStorageAdapterV2 {
this.minioClient this.minioClient
.putObject(this.input?.bucket, key, response.data, metaData) .putObject(this.input?.bucket, key, response.data, metaData)
.then(() => { .then(() => {
resolve( resolve({
`http${this.input.useSSL ? 's' : ''}://${this.input.endPoint}:${ url: `http${this.input.useSSL ? 's' : ''}://${
this.input.port this.input.endPoint
}/${this.input.bucket}/${key}`, }:${this.input.port}/${this.input.bucket}/${key}`,
); data: response.data,
});
}) })
.catch(reject); .catch(reject);
}) })
@ -131,9 +109,33 @@ export default class Minio implements IStorageAdapterV2 {
}); });
} }
// TODO - implement async fileCreateByStream(
fileCreateByStream(_key: string, _stream: Readable): Promise<void> { key: string,
return Promise.resolve(undefined); stream: Readable,
options?: {
mimetype?: string;
},
): Promise<any> {
return new Promise((resolve, reject) => {
// uploadParams.Body = fileStream;
// uploadParams.Key = key;
const metaData = {
'Content-Type': options?.mimetype,
// 'X-Amz-Meta-Testing': 1234,
// 'run': 5678
};
// call S3 to retrieve upload file to specified bucket
this.minioClient
.putObject(this.input?.bucket, key, stream, metaData)
.then(() => {
resolve(
`http${this.input.useSSL ? 's' : ''}://${this.input.endPoint}:${
this.input.port
}/${this.input.bucket}/${key}`,
);
})
.catch(reject);
});
} }
// TODO - implement // TODO - implement

65
packages/nocodb/src/plugins/ovhCloud/OvhCloud.ts

@ -16,31 +16,10 @@ export default class OvhCloud implements IStorageAdapterV2 {
} }
async fileCreate(key: string, file: XcFile): Promise<any> { async fileCreate(key: string, file: XcFile): Promise<any> {
const uploadParams: any = { const fileStream = fs.createReadStream(file.path);
ACL: 'public-read',
ContentType: file.mimetype,
};
return new Promise((resolve, reject) => {
// Configure the file stream and obtain the upload parameters
const fileStream = fs.createReadStream(file.path);
fileStream.on('error', (err) => {
console.log('File Error', err);
reject(err);
});
uploadParams.Body = fileStream;
uploadParams.Key = key;
// call S3 to retrieve upload file to specified bucket return this.fileCreateByStream(key, fileStream, {
this.s3Client.upload(uploadParams, (err, data) => { mimetype: file?.mimetype,
if (err) {
console.log('Error', err);
reject(err);
}
if (data) {
resolve(data.Location);
}
});
}); });
} }
@ -68,7 +47,10 @@ export default class OvhCloud implements IStorageAdapterV2 {
reject(err1); reject(err1);
} }
if (data) { if (data) {
resolve(data.Location); resolve({
url: data.Location,
data: response.data,
});
} }
}); });
}) })
@ -78,9 +60,36 @@ export default class OvhCloud implements IStorageAdapterV2 {
}); });
} }
// TODO - implement async fileCreateByStream(
fileCreateByStream(_key: string, _stream: Readable): Promise<void> { key: string,
return Promise.resolve(undefined); stream: Readable,
options?: {
mimetype?: string;
},
): Promise<void> {
const uploadParams: any = {
ACL: 'public-read',
// ContentType: file.mimetype,
};
return new Promise((resolve, reject) => {
// Configure the file stream and obtain the upload parameters
uploadParams.Body = stream;
uploadParams.Key = key;
uploadParams.ContentType =
options?.mimetype || 'application/octet-stream';
// call S3 to retrieve upload file to specified bucket
this.s3Client.upload(uploadParams, (err, data) => {
if (err) {
console.log('Error', err);
reject(err);
}
if (data) {
resolve(data.Location);
}
});
});
} }
// TODO - implement // TODO - implement

19
packages/nocodb/src/plugins/s3/S3.ts

@ -29,7 +29,9 @@ export default class S3 implements IStorageAdapterV2 {
// create file stream // create file stream
const fileStream = fs.createReadStream(file.path); const fileStream = fs.createReadStream(file.path);
// upload using stream // upload using stream
return this.fileCreateByStream(key, fileStream); return this.fileCreateByStream(key, fileStream, {
mimetype: file?.mimetype,
});
} }
async fileCreateByUrl(key: string, url: string): Promise<any> { async fileCreateByUrl(key: string, url: string): Promise<any> {
@ -49,13 +51,22 @@ export default class S3 implements IStorageAdapterV2 {
uploadParams.ContentType = response.headers['content-type']; uploadParams.ContentType = response.headers['content-type'];
const data = await this.upload(uploadParams); const data = await this.upload(uploadParams);
return data; return {
url: data,
data: response.data,
};
} catch (error) { } catch (error) {
throw error; throw error;
} }
} }
fileCreateByStream(key: string, stream: Readable): Promise<void> { fileCreateByStream(
key: string,
stream: Readable,
options?: {
mimetype?: string;
},
): Promise<void> {
const uploadParams: any = { const uploadParams: any = {
...this.defaultParams, ...this.defaultParams,
}; };
@ -67,6 +78,8 @@ export default class S3 implements IStorageAdapterV2 {
uploadParams.Body = stream; uploadParams.Body = stream;
uploadParams.Key = key; uploadParams.Key = key;
uploadParams.ContentType =
options?.mimetype || 'application/octet-stream';
// call S3 to upload file to specified bucket // call S3 to upload file to specified bucket
this.upload(uploadParams) this.upload(uploadParams)

60
packages/nocodb/src/plugins/scaleway/ScalewayObjectStorage.ts

@ -66,31 +66,10 @@ export default class ScalewayObjectStorage implements IStorageAdapterV2 {
} }
async fileCreate(key: string, file: XcFile): Promise<any> { async fileCreate(key: string, file: XcFile): Promise<any> {
const uploadParams: any = { const fileStream = fs.createReadStream(file.path);
ACL: 'public-read',
ContentType: file.mimetype,
};
return new Promise((resolve, reject) => {
// Configure the file stream and obtain the upload parameters
const fileStream = fs.createReadStream(file.path);
fileStream.on('error', (err) => {
console.log('File Error', err);
reject(err);
});
uploadParams.Body = fileStream; return this.fileCreateByStream(key, fileStream, {
uploadParams.Key = key; mimetype: file?.mimetype,
// call S3 to retrieve upload file to specified bucket
this.s3Client.upload(uploadParams, (err, data) => {
if (err) {
console.log('Error', err);
reject(err);
}
if (data) {
resolve(data.Location);
}
});
}); });
} }
@ -118,7 +97,10 @@ export default class ScalewayObjectStorage implements IStorageAdapterV2 {
reject(err1); reject(err1);
} }
if (data) { if (data) {
resolve(data.Location); resolve({
url: data.Location,
data: response.data,
});
} }
}); });
}) })
@ -128,9 +110,31 @@ export default class ScalewayObjectStorage implements IStorageAdapterV2 {
}); });
} }
// TODO - implement async fileCreateByStream(
fileCreateByStream(_key: string, _stream: Readable): Promise<void> { key: string,
return Promise.resolve(undefined); stream: Readable,
options?: {
mimetype?: string;
},
): Promise<any> {
const uploadParams: any = {
ACL: 'public-read',
Body: stream,
Key: key,
ContentType: options?.mimetype || 'application/octet-stream',
};
return new Promise((resolve, reject) => {
// call S3 to retrieve upload file to specified bucket
this.s3Client.upload(uploadParams, (err, data) => {
if (err) {
console.log('Error', err);
reject(err);
}
if (data) {
resolve(data.Location);
}
});
});
} }
// TODO - implement // TODO - implement

60
packages/nocodb/src/plugins/spaces/Spaces.ts

@ -16,31 +16,10 @@ export default class Spaces implements IStorageAdapterV2 {
} }
async fileCreate(key: string, file: XcFile): Promise<any> { async fileCreate(key: string, file: XcFile): Promise<any> {
const uploadParams: any = { const fileStream = fs.createReadStream(file.path);
ACL: 'public-read',
ContentType: file.mimetype,
};
return new Promise((resolve, reject) => {
// Configure the file stream and obtain the upload parameters
const fileStream = fs.createReadStream(file.path);
fileStream.on('error', (err) => {
console.log('File Error', err);
reject(err);
});
uploadParams.Body = fileStream; return this.fileCreateByStream(key, fileStream, {
uploadParams.Key = key; mimetype: file?.mimetype,
// call S3 to retrieve upload file to specified bucket
this.s3Client.upload(uploadParams, (err, data) => {
if (err) {
console.log('Error', err);
reject(err);
}
if (data) {
resolve(data.Location);
}
});
}); });
} }
@ -68,7 +47,10 @@ export default class Spaces implements IStorageAdapterV2 {
reject(err1); reject(err1);
} }
if (data) { if (data) {
resolve(data.Location); resolve({
url: data.Location,
data: response.data,
});
} }
}); });
}) })
@ -78,9 +60,31 @@ export default class Spaces implements IStorageAdapterV2 {
}); });
} }
// TODO - implement async fileCreateByStream(
fileCreateByStream(_key: string, _stream: Readable): Promise<void> { key: string,
return Promise.resolve(undefined); stream: Readable,
options?: {
mimetype?: string;
},
): Promise<void> {
const uploadParams: any = {
ACL: 'public-read',
Body: stream,
Key: key,
ContentType: options?.mimetype || 'application/octet-stream',
};
return new Promise((resolve, reject) => {
// call S3 to retrieve upload file to specified bucket
this.s3Client.upload(uploadParams, (err, data) => {
if (err) {
console.log('Error', err);
reject(err);
}
if (data) {
resolve(data.Location);
}
});
});
} }
// TODO - implement // TODO - implement

25
packages/nocodb/src/plugins/storage/Local.ts

@ -30,7 +30,7 @@ export default class Local implements IStorageAdapterV2 {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios axios
.get(url, { .get(url, {
responseType: 'stream', responseType: 'arraybuffer',
headers: { headers: {
accept: accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
@ -46,23 +46,16 @@ export default class Local implements IStorageAdapterV2 {
}) })
.then(async (response) => { .then(async (response) => {
await mkdirp(path.dirname(destPath)); await mkdirp(path.dirname(destPath));
const file = fs.createWriteStream(destPath);
// close() is async, call cb after close completes
file.on('finish', () => {
file.close((err) => {
if (err) {
return reject(err);
}
resolve(null);
});
});
file.on('error', (err) => { fs.writeFile(destPath, response.data, (err) => {
// Handle errors if (err) {
fs.unlink(destPath, () => reject(err.message)); // delete the (partial) file and then return the error return reject(err);
}
resolve({
url: null,
data: response.data,
});
}); });
response.data.pipe(file);
}) })
.catch((err) => { .catch((err) => {
reject(err.message); reject(err.message);

60
packages/nocodb/src/plugins/upcloud/UpoCloud.ts

@ -16,31 +16,10 @@ export default class UpoCloud implements IStorageAdapterV2 {
} }
async fileCreate(key: string, file: XcFile): Promise<any> { async fileCreate(key: string, file: XcFile): Promise<any> {
const uploadParams: any = { const fileStream = fs.createReadStream(file.path);
ACL: 'public-read',
ContentType: file.mimetype,
};
return new Promise((resolve, reject) => {
// Configure the file stream and obtain the upload parameters
const fileStream = fs.createReadStream(file.path);
fileStream.on('error', (err) => {
console.log('File Error', err);
reject(err);
});
uploadParams.Body = fileStream; return this.fileCreateByStream(key, fileStream, {
uploadParams.Key = key; mimetype: file?.mimetype,
// call S3 to retrieve upload file to specified bucket
this.s3Client.upload(uploadParams, (err, data) => {
if (err) {
console.log('Error', err);
reject(err);
}
if (data) {
resolve(data.Location);
}
});
}); });
} }
@ -68,7 +47,10 @@ export default class UpoCloud implements IStorageAdapterV2 {
reject(err1); reject(err1);
} }
if (data) { if (data) {
resolve(data.Location); resolve({
url: data.Location,
data: response.data,
});
} }
}); });
}) })
@ -78,9 +60,31 @@ export default class UpoCloud implements IStorageAdapterV2 {
}); });
} }
// TODO - implement async fileCreateByStream(
fileCreateByStream(_key: string, _stream: Readable): Promise<void> { key: string,
return Promise.resolve(undefined); stream: Readable,
options?: {
mimetype?: string;
},
): Promise<void> {
const uploadParams: any = {
ACL: 'public-read',
Key: key,
Body: stream,
ContentType: options?.mimetype || 'application/octet-stream',
};
return new Promise((resolve, reject) => {
// call S3 to retrieve upload file to specified bucket
this.s3Client.upload(uploadParams, (err, data) => {
if (err) {
console.log('Error', err);
reject(err);
}
if (data) {
resolve(data.Location);
}
});
});
} }
// TODO - implement // TODO - implement

60
packages/nocodb/src/plugins/vultr/Vultr.ts

@ -16,31 +16,10 @@ export default class Vultr implements IStorageAdapterV2 {
} }
async fileCreate(key: string, file: XcFile): Promise<any> { async fileCreate(key: string, file: XcFile): Promise<any> {
const uploadParams: any = { const fileStream = fs.createReadStream(file.path);
ACL: 'public-read',
ContentType: file.mimetype,
};
return new Promise((resolve, reject) => {
// Configure the file stream and obtain the upload parameters
const fileStream = fs.createReadStream(file.path);
fileStream.on('error', (err) => {
console.log('File Error', err);
reject(err);
});
uploadParams.Body = fileStream; return this.fileCreateByStream(key, fileStream, {
uploadParams.Key = key; mimetype: file?.mimetype,
// call S3 to retrieve upload file to specified bucket
this.s3Client.upload(uploadParams, (err, data) => {
if (err) {
console.log('Error', err);
reject(err);
}
if (data) {
resolve(data.Location);
}
});
}); });
} }
@ -68,7 +47,10 @@ export default class Vultr implements IStorageAdapterV2 {
reject(err1); reject(err1);
} }
if (data) { if (data) {
resolve(data.Location); resolve({
url: data.Location,
data: response.data,
});
} }
}); });
}) })
@ -78,9 +60,31 @@ export default class Vultr implements IStorageAdapterV2 {
}); });
} }
// TODO - implement async fileCreateByStream(
fileCreateByStream(_key: string, _stream: Readable): Promise<void> { key: string,
return Promise.resolve(undefined); stream: Readable,
options?: {
mimetype: string;
},
): Promise<void> {
const uploadParams: any = {
ACL: 'public-read',
Body: stream,
Key: key,
ContentType: options?.mimetype || 'application/octet-stream',
};
return new Promise((resolve, reject) => {
// call S3 to retrieve upload file to specified bucket
this.s3Client.upload(uploadParams, (err, data) => {
if (err) {
console.log('Error', err);
reject(err);
}
if (data) {
resolve(data.Location);
}
});
});
} }
// TODO - implement // TODO - implement

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

@ -1,11 +1,12 @@
import path from 'path'; import path from 'path';
import Url from 'url'; import Url from 'url';
import { AppEvents } from 'nocodb-sdk'; import { AppEvents } from 'nocodb-sdk';
import { Injectable, Logger } from '@nestjs/common'; import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import slash from 'slash'; import slash from 'slash';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import axios from 'axios'; import axios from 'axios';
import sharp from 'sharp';
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 { AppHooksService } from '~/services/app-hooks/app-hooks.service'; import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
@ -15,6 +16,9 @@ import mimetypes, { mimeIcons } from '~/utils/mimeTypes';
import { PresignedUrl } from '~/models'; import { PresignedUrl } from '~/models';
import { utf8ify } from '~/helpers/stringHelpers'; import { utf8ify } from '~/helpers/stringHelpers';
import { NcError } from '~/helpers/catchError'; import { NcError } from '~/helpers/catchError';
import { IJobsService } from '~/modules/jobs/jobs-service.interface';
import { JobTypes } from '~/interface/Jobs';
import { RootScopes } from '~/utils/globals';
interface AttachmentObject { interface AttachmentObject {
url?: string; url?: string;
@ -31,7 +35,11 @@ interface AttachmentObject {
export class AttachmentsService { export class AttachmentsService {
protected logger = new Logger(AttachmentsService.name); protected logger = new Logger(AttachmentsService.name);
constructor(private readonly appHooksService: AppHooksService) {} constructor(
private readonly appHooksService: AppHooksService,
@Inject(forwardRef(() => 'JobsService'))
private readonly jobsService: IJobsService,
) {}
async upload(param: { path?: string; files: FileType[]; req: NcRequest }) { async upload(param: { path?: string; files: FileType[]; req: NcRequest }) {
// TODO: add getAjvValidatorMw // TODO: add getAjvValidatorMw
@ -60,6 +68,26 @@ export class AttachmentsService {
5, 5,
)}${path.extname(originalName)}`; )}${path.extname(originalName)}`;
const tempMetadata: {
width?: number;
height?: number;
} = {};
if (file.mimetype.includes('image')) {
try {
const metadata = await sharp(file.path, {
limitInputPixels: false,
}).metadata();
if (metadata.width && metadata.height) {
tempMetadata.width = metadata.width;
tempMetadata.height = metadata.height;
}
} catch (e) {
// Might be invalid image - ignore
}
}
const url = await storageAdapter.fileCreate( const url = await storageAdapter.fileCreate(
slash(path.join(destPath, fileName)), slash(path.join(destPath, fileName)),
file, file,
@ -75,6 +103,7 @@ export class AttachmentsService {
mimetype: file.mimetype, mimetype: file.mimetype,
size: file.size, size: file.size,
icon: mimeIcons[path.extname(originalName).slice(1)] || undefined, icon: mimeIcons[path.extname(originalName).slice(1)] || undefined,
...tempMetadata,
}; };
await this.signAttachment({ attachment }); await this.signAttachment({ attachment });
@ -95,6 +124,14 @@ export class AttachmentsService {
throw errors[0]; throw errors[0];
} }
await this.jobsService.add(JobTypes.ThumbnailGenerator, {
context: {
base_id: RootScopes.ROOT,
workspace_id: RootScopes.ROOT,
},
attachments,
});
this.appHooksService.emit(AppEvents.ATTACHMENT_UPLOAD, { this.appHooksService.emit(AppEvents.ATTACHMENT_UPLOAD, {
type: 'file', type: 'file',
req: param.req, req: param.req,
@ -140,10 +177,29 @@ export class AttachmentsService {
5, 5,
)}${path.extname(fileNameWithExt)}`; )}${path.extname(fileNameWithExt)}`;
const attachmentUrl = await storageAdapter.fileCreateByUrl( const { url: attachmentUrl, data: file } =
slash(path.join(destPath, fileName)), await storageAdapter.fileCreateByUrl(
finalUrl, slash(path.join(destPath, fileName)),
); finalUrl,
);
const tempMetadata: {
width?: number;
height?: number;
} = {};
try {
const metadata = await sharp(file, {
limitInputPixels: true,
}).metadata();
if (metadata.width && metadata.height) {
tempMetadata.width = metadata.width;
tempMetadata.height = metadata.height;
}
} catch (e) {
// Might be invalid image - ignore
}
let mimeType = response.headers['content-type']?.split(';')[0]; let mimeType = response.headers['content-type']?.split(';')[0];
const size = response.headers['content-length']; const size = response.headers['content-length'];
@ -163,6 +219,7 @@ export class AttachmentsService {
size: size ? parseInt(size) : urlMeta.size, size: size ? parseInt(size) : urlMeta.size,
icon: icon:
mimeIcons[path.extname(fileNameWithExt).slice(1)] || undefined, mimeIcons[path.extname(fileNameWithExt).slice(1)] || undefined,
...tempMetadata,
}; };
await this.signAttachment({ attachment }); await this.signAttachment({ attachment });
@ -181,6 +238,14 @@ export class AttachmentsService {
throw errors[0]; throw errors[0];
} }
await this.jobsService.add(JobTypes.ThumbnailGenerator, {
context: {
base_id: RootScopes.ROOT,
workspace_id: RootScopes.ROOT,
},
attachments,
});
this.appHooksService.emit(AppEvents.ATTACHMENT_UPLOAD, { this.appHooksService.emit(AppEvents.ATTACHMENT_UPLOAD, {
type: 'url', type: 'url',
req: param.req, req: param.req,

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

@ -1,10 +1,11 @@
import path from 'path'; import path from 'path';
import { Injectable } from '@nestjs/common'; import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { populateUniqueFileName, UITypes, ViewTypes } from 'nocodb-sdk'; import { populateUniqueFileName, UITypes, ViewTypes } from 'nocodb-sdk';
import slash from 'slash'; import slash from 'slash';
import { nocoExecute } from 'nc-help'; import { nocoExecute } from 'nc-help';
import sharp from 'sharp';
import type { LinkToAnotherRecordColumn } from '~/models'; import type { LinkToAnotherRecordColumn } from '~/models';
import type { NcContext } from '~/interface/config'; import type { NcContext } from '~/interface/config';
import { Column, Model, Source, View } from '~/models'; import { Column, Model, Source, View } from '~/models';
@ -18,6 +19,9 @@ import { mimeIcons } from '~/utils/mimeTypes';
import { utf8ify } from '~/helpers/stringHelpers'; import { utf8ify } from '~/helpers/stringHelpers';
import { replaceDynamicFieldWithValue } from '~/db/BaseModelSqlv2'; import { replaceDynamicFieldWithValue } from '~/db/BaseModelSqlv2';
import { Filter } from '~/models'; import { Filter } from '~/models';
import { IJobsService } from '~/modules/jobs/jobs-service.interface';
import { JobTypes } from '~/interface/Jobs';
import { RootScopes } from '~/utils/globals';
import { DatasService } from '~/services/datas.service'; import { DatasService } from '~/services/datas.service';
// todo: move to utils // todo: move to utils
@ -27,7 +31,12 @@ export function sanitizeUrlPath(paths) {
@Injectable() @Injectable()
export class PublicDatasService { export class PublicDatasService {
constructor(protected datasService: DatasService) {} constructor(
protected datasService: DatasService,
@Inject(forwardRef(() => 'JobsService'))
protected readonly jobsService: IJobsService,
) {}
async dataList( async dataList(
context: NcContext, context: NcContext,
param: { param: {
@ -407,6 +416,23 @@ export class PublicDatasService {
attachments[fieldName].map((att) => att?.title), attachments[fieldName].map((att) => att?.title),
); );
const tempMeta: {
width?: number;
height?: number;
} = {};
if (file.mimetype.startsWith('image')) {
try {
const meta = await sharp(file.path, {
limitInputPixels: false,
}).metadata();
tempMeta.width = meta.width;
tempMeta.height = meta.height;
} catch (e) {
// Ignore-if file is not image
}
}
const fileName = `${nanoid(18)}${path.extname(originalName)}`; const fileName = `${nanoid(18)}${path.extname(originalName)}`;
const url = await storageAdapter.fileCreate( const url = await storageAdapter.fileCreate(
@ -429,6 +455,7 @@ export class PublicDatasService {
mimetype: file.mimetype, mimetype: file.mimetype,
size: file.size, size: file.size,
icon: mimeIcons[path.extname(originalName).slice(1)] || undefined, icon: mimeIcons[path.extname(originalName).slice(1)] || undefined,
...tempMeta,
}); });
} }
} }
@ -463,15 +490,33 @@ export class PublicDatasService {
file?.fileName || file.url.split('/').pop(), file?.fileName || file.url.split('/').pop(),
)}`; )}`;
const attachmentUrl: string | null = await storageAdapter.fileCreateByUrl( const { url, data } = await storageAdapter.fileCreateByUrl(
slash(path.join('nc', 'uploads', ...filePath, fileName)), slash(path.join('nc', 'uploads', ...filePath, fileName)),
file.url, file.url,
); );
const tempMetadata: {
width?: number;
height?: number;
} = {};
try {
const metadata = await sharp(data, {
limitInputPixels: true,
}).metadata();
if (metadata.width && metadata.height) {
tempMetadata.width = metadata.width;
tempMetadata.height = metadata.height;
}
} catch (e) {
// Might be invalid image - ignore
}
let attachmentPath: string | undefined; let attachmentPath: string | undefined;
// if `attachmentUrl` is null, then it is local attachment // if `attachmentUrl` is null, then it is local attachment
if (!attachmentUrl) { if (!url) {
// then store the attachment path only // then store the attachment path only
// url will be constructed in `useAttachmentCell` // url will be constructed in `useAttachmentCell`
attachmentPath = `download/${filePath.join('/')}/${fileName}`; attachmentPath = `download/${filePath.join('/')}/${fileName}`;
@ -482,18 +527,27 @@ export class PublicDatasService {
file.uploadIndex ?? attachments[file.fieldName].length, file.uploadIndex ?? attachments[file.fieldName].length,
0, 0,
{ {
...(attachmentUrl ? { url: attachmentUrl } : {}), ...(url ? { url: url } : {}),
...(attachmentPath ? { path: attachmentPath } : {}), ...(attachmentPath ? { path: attachmentPath } : {}),
title: file.fileName, title: file.fileName,
mimetype: file.mimetype, mimetype: file.mimetype,
size: file.size, size: file.size,
icon: mimeIcons[path.extname(fileName).slice(1)] || undefined, icon: mimeIcons[path.extname(fileName).slice(1)] || undefined,
...tempMetadata,
}, },
); );
} }
for (const [column, data] of Object.entries(attachments)) { for (const [column, data] of Object.entries(attachments)) {
insertObject[column] = JSON.stringify(data); insertObject[column] = JSON.stringify(data);
await this.jobsService.add(JobTypes.ThumbnailGenerator, {
attachments: data,
context: {
base_id: RootScopes.ROOT,
workspace_id: RootScopes.ROOT,
},
});
} }
return await baseModel.nestedInsert(insertObject, null); return await baseModel.nestedInsert(insertObject, null);

Loading…
Cancel
Save