From 4f54084f8c2dc534e6e7b04b9bb2a2fd3271e3c8 Mon Sep 17 00:00:00 2001 From: mertmit Date: Wed, 4 Oct 2023 14:57:19 +0000 Subject: [PATCH] feat: signed attachments --- .../components/cell/attachment/utils.ts | 2 +- packages/nc-gui/composables/useAttachment.ts | 2 + .../attachments-secure.controller.ts | 71 ++++++++ .../src/controllers/attachments.controller.ts | 16 ++ .../public-datas-export.controller.ts | 70 +------- packages/nocodb/src/db/BaseModelSqlv2.ts | 94 +++++++++-- packages/nocodb/src/models/TemporaryUrl.ts | 152 ++++++++++++++++++ packages/nocodb/src/models/index.ts | 1 + packages/nocodb/src/modules/datas/helpers.ts | 4 +- .../nocodb/src/modules/metas/metas.module.ts | 5 +- packages/nocodb/src/plugins/s3/S3.ts | 83 ++++++---- packages/nocodb/src/plugins/storage/Local.ts | 2 +- .../src/services/attachments.service.ts | 53 ++++-- packages/nocodb/src/utils/globals.ts | 1 + .../db/columns/columnAttachments.spec.ts | 2 +- 15 files changed, 431 insertions(+), 127 deletions(-) create mode 100644 packages/nocodb/src/controllers/attachments-secure.controller.ts create mode 100644 packages/nocodb/src/models/TemporaryUrl.ts diff --git a/packages/nc-gui/components/cell/attachment/utils.ts b/packages/nc-gui/components/cell/attachment/utils.ts index 02a6f5fbe2..ce2117816b 100644 --- a/packages/nc-gui/components/cell/attachment/utils.ts +++ b/packages/nc-gui/components/cell/attachment/utils.ts @@ -191,7 +191,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( try { const data = await api.storage.upload( { - path: [NOCO, base.value.title, meta.value?.title, column.value?.title].join('/'), + path: [NOCO, base.value.id, meta.value?.id, column.value?.id].join('/'), }, { files, diff --git a/packages/nc-gui/composables/useAttachment.ts b/packages/nc-gui/composables/useAttachment.ts index b5ccab8f0a..31ccd19eeb 100644 --- a/packages/nc-gui/composables/useAttachment.ts +++ b/packages/nc-gui/composables/useAttachment.ts @@ -6,6 +6,8 @@ const useAttachment = () => { const getPossibleAttachmentSrc = (item: Record) => { const res: string[] = [] if (item?.data) res.push(item.data) + if (item?.signedPath) res.push(`${appInfo.value.ncSiteUrl}/${item.signedPath}`) + if (item?.signedUrl) res.push(item.signedUrl) if (item?.path) res.push(`${appInfo.value.ncSiteUrl}/${item.path}`) if (item?.url) res.push(item.url) return res diff --git a/packages/nocodb/src/controllers/attachments-secure.controller.ts b/packages/nocodb/src/controllers/attachments-secure.controller.ts new file mode 100644 index 0000000000..7a6e48b211 --- /dev/null +++ b/packages/nocodb/src/controllers/attachments-secure.controller.ts @@ -0,0 +1,71 @@ +import path from 'path'; +import { + Body, + Controller, + Get, + HttpCode, + Param, + Post, + Request, + Response, + UploadedFiles, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import hash from 'object-hash'; +import moment from 'moment'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { GlobalGuard } from '~/guards/global/global.guard'; +import { AttachmentsService } from '~/services/attachments.service'; +import { TemporaryUrl } from '~/models'; +import { UploadAllowedInterceptor } from '~/interceptors/is-upload-allowed/is-upload-allowed.interceptor'; + +@Controller() +export class AttachmentsSecureController { + constructor(private readonly attachmentsService: AttachmentsService) {} + + @UseGuards(GlobalGuard) + @Post(['/api/v1/db/storage/upload', '/api/v1/storage/upload']) + @HttpCode(200) + @UseInterceptors(UploadAllowedInterceptor, AnyFilesInterceptor()) + async upload(@UploadedFiles() files: Array, @Request() req) { + const path = `${moment().format('YYYY/MM/DD')}/${hash(req.user.id)}`; + + const attachments = await this.attachmentsService.upload({ + files: files, + path: path, + }); + + return attachments; + } + + @Post(['/api/v1/db/storage/upload-by-url', '/api/v1/storage/upload-by-url']) + @HttpCode(200) + @UseInterceptors(UploadAllowedInterceptor) + @UseGuards(GlobalGuard) + async uploadViaURL(@Body() body: any, @Request() req) { + const path = `${moment().format('YYYY/MM/DD')}/${hash(req.user.id)}`; + + const attachments = await this.attachmentsService.uploadViaURL({ + urls: body, + path, + }); + + return attachments; + } + + @Get('/dltemp/:param(*)') + async fileReadv3(@Param('param') param: string, @Response() res) { + try { + const fpath = await TemporaryUrl.getPath(`dltemp/${param}`); + + const { img } = await this.attachmentsService.fileRead({ + path: path.join('nc', 'uploads', fpath), + }); + + res.sendFile(img); + } catch (e) { + res.status(404).send('Not found'); + } + } +} diff --git a/packages/nocodb/src/controllers/attachments.controller.ts b/packages/nocodb/src/controllers/attachments.controller.ts index 6eee7fad11..fd82065a64 100644 --- a/packages/nocodb/src/controllers/attachments.controller.ts +++ b/packages/nocodb/src/controllers/attachments.controller.ts @@ -17,6 +17,7 @@ import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { UploadAllowedInterceptor } from '~/interceptors/is-upload-allowed/is-upload-allowed.interceptor'; import { GlobalGuard } from '~/guards/global/global.guard'; import { AttachmentsService } from '~/services/attachments.service'; +import { TemporaryUrl } from '~/models'; @Controller() export class AttachmentsController { @@ -96,4 +97,19 @@ export class AttachmentsController { res.status(404).send('Not found'); } } + + @Get('/dltemp/:param(*)') + async fileReadv3(@Param('param') param: string, @Response() res) { + try { + const fpath = await TemporaryUrl.getPath(`dltemp/${param}`); + + const { img } = await this.attachmentsService.fileRead({ + path: path.join('nc', 'uploads', fpath), + }); + + res.sendFile(img); + } catch (e) { + res.status(404).send('Not found'); + } + } } diff --git a/packages/nocodb/src/controllers/public-datas-export.controller.ts b/packages/nocodb/src/controllers/public-datas-export.controller.ts index b3a126ec0e..1cdf0e84e3 100644 --- a/packages/nocodb/src/controllers/public-datas-export.controller.ts +++ b/packages/nocodb/src/controllers/public-datas-export.controller.ts @@ -1,9 +1,8 @@ import { Controller, Get, Param, Request, Response } from '@nestjs/common'; -import { ErrorMessages, isSystemColumn, UITypes, ViewTypes } from 'nocodb-sdk'; +import { ErrorMessages, isSystemColumn, ViewTypes } from 'nocodb-sdk'; import * as XLSX from 'xlsx'; import { nocoExecute } from 'nc-help'; import papaparse from 'papaparse'; -import type { LinkToAnotherRecordColumn, LookupColumn } from '~/models'; import { NcError } from '~/helpers/catchError'; import getAst from '~/helpers/getAst'; import { serializeCellValue } from '~/modules/datas/helpers'; @@ -210,71 +209,4 @@ export class PublicDatasExportController { } return { offset, dbRows, elapsed }; } - - async serializeCellValue({ - value, - column, - ncSiteUrl, - }: { - column?: Column; - value: any; - ncSiteUrl?: string; - }) { - if (!column) { - return value; - } - - if (!value) return value; - - switch (column?.uidt) { - case UITypes.Attachment: { - let data = value; - try { - if (typeof value === 'string') { - data = JSON.parse(value); - } - } catch {} - - return (data || []).map( - (attachment) => - `${encodeURI(attachment.title)}(${encodeURI(attachment.url)})`, - ); - } - case UITypes.Lookup: - { - const colOptions = await column.getColOptions(); - const lookupColumn = await colOptions.getLookupColumn(); - return ( - await Promise.all( - [...(Array.isArray(value) ? value : [value])].map(async (v) => - serializeCellValue({ - value: v, - column: lookupColumn, - siteUrl: ncSiteUrl, - }), - ), - ) - ).join(', '); - } - break; - case UITypes.LinkToAnotherRecord: - { - const colOptions = - await column.getColOptions(); - const relatedModel = await colOptions.getRelatedTable(); - await relatedModel.getColumns(); - return [...(Array.isArray(value) ? value : [value])] - .map((v) => { - return v[relatedModel.displayValue?.title]; - }) - .join(', '); - } - break; - default: - if (value && typeof value === 'object') { - return JSON.stringify(value); - } - return value; - } - } } diff --git a/packages/nocodb/src/db/BaseModelSqlv2.ts b/packages/nocodb/src/db/BaseModelSqlv2.ts index 8430bb778d..4f1d83427b 100644 --- a/packages/nocodb/src/db/BaseModelSqlv2.ts +++ b/packages/nocodb/src/db/BaseModelSqlv2.ts @@ -45,7 +45,16 @@ import { customValidators } from '~/db/util/customValidators'; import { extractLimitAndOffset } from '~/helpers'; import { NcError } from '~/helpers/catchError'; import getAst from '~/helpers/getAst'; -import { Audit, Column, Filter, Model, Sort, Source, View } from '~/models'; +import { + Audit, + Column, + Filter, + Model, + Sort, + Source, + TemporaryUrl, + View, +} from '~/models'; import { sanitize, unsanitize } from '~/helpers/sqlSanitize'; import Noco from '~/Noco'; import { HANDLE_WEBHOOK } from '~/services/hook-handler.service'; @@ -54,6 +63,7 @@ import { COMPARISON_SUB_OPS, IS_WITHIN_COMPARISON_SUB_OPS, } from '~/utils/globals'; +import { extractProps } from '~/helpers/extractProps'; dayjs.extend(utc); @@ -2162,6 +2172,9 @@ class BaseModelSqlv2 { } await this.model.getColumns(); + + await this.prepareAttachmentData(insertObj); + let response; // const driver = trx ? trx : this.dbDriver; @@ -2387,6 +2400,8 @@ class BaseModelSqlv2 { await this.beforeUpdate(data, trx, cookie); + await this.prepareAttachmentData(updateObj); + const prevData = await this.readByPk( id, false, @@ -2783,6 +2798,8 @@ class BaseModelSqlv2 { } } + await this.prepareAttachmentData(insertObj); + insertDatas.push(insertObj); } } @@ -2897,6 +2914,8 @@ class BaseModelSqlv2 { continue; } if (!raw) { + await this.prepareAttachmentData(d); + const oldRecord = await this.readByPk(pkValues); if (!oldRecord) { // throw or skip if no record found @@ -4032,17 +4051,42 @@ class BaseModelSqlv2 { return data; } - protected _convertAttachmentType( + protected async _convertAttachmentType( attachmentColumns: Record[], d: Record, ) { try { if (d) { - attachmentColumns.forEach((col) => { + const promises = []; + for (const col of attachmentColumns) { if (d[col.title] && typeof d[col.title] === 'string') { d[col.title] = JSON.parse(d[col.title]); } - }); + + if (d[col.title]?.length) { + for (const attachment of d[col.title]) { + if (attachment?.path) { + promises.push( + TemporaryUrl.getTemporaryUrl({ + path: attachment.path.replace(/^download\//, ''), + }).then((r) => (attachment.signedPath = r)), + ); + } else if (attachment?.url) { + if (attachment.url.includes('.amazonaws.com/')) { + const relativePath = + attachment.url.split('.amazonaws.com/')[1]; + promises.push( + TemporaryUrl.getTemporaryUrl({ + path: relativePath, + s3: true, + }).then((r) => (attachment.signedUrl = r)), + ); + } + } + } + } + } + await Promise.all(promises); } } catch {} return d; @@ -4052,25 +4096,25 @@ class BaseModelSqlv2 { data: Record, childTable?: Model, ) { - if (childTable && !childTable?.columns) { - await childTable.getColumns(); - } else if (!this.model?.columns) { - await this.model.getColumns(); - } - // attachment is stored in text and parse in UI // convertAttachmentType is used to convert the response in string to array of object in API response if (data) { + if (childTable && !childTable?.columns) { + await childTable.getColumns(); + } else if (!this.model?.columns) { + await this.model.getColumns(); + } + const attachmentColumns = ( childTable ? childTable.columns : this.model.columns ).filter((c) => c.uidt === UITypes.Attachment); if (attachmentColumns.length) { if (Array.isArray(data)) { - data = data.map((d) => - this._convertAttachmentType(attachmentColumns, d), + data = await Promise.all( + data.map((d) => this._convertAttachmentType(attachmentColumns, d)), ); } else { - data = this._convertAttachmentType(attachmentColumns, data); + data = await this._convertAttachmentType(attachmentColumns, data); } } } @@ -4680,6 +4724,29 @@ class BaseModelSqlv2 { throw e; } } + + prepareAttachmentData(data) { + if (this.model.columns.some((c) => c.uidt === UITypes.Attachment)) { + for (const column of this.model.columns) { + if (column.uidt === UITypes.Attachment) { + if (data[column.column_name]) { + if (Array.isArray(data[column.column_name])) { + for (let attachment of data[column.column_name]) { + attachment = extractProps(attachment, [ + 'url', + 'path', + 'title', + 'mimetype', + 'size', + 'icon', + ]); + } + } + } + } + } + } + } } export function extractSortsObject( @@ -4868,7 +4935,6 @@ export function _wherePk(primaryKeys: Column[], id: unknown | unknown[]) { [primaryKeys[i].column_name, ids[i]], ); }; - continue; } // Cast the id to string. diff --git a/packages/nocodb/src/models/TemporaryUrl.ts b/packages/nocodb/src/models/TemporaryUrl.ts new file mode 100644 index 0000000000..940a96fd0b --- /dev/null +++ b/packages/nocodb/src/models/TemporaryUrl.ts @@ -0,0 +1,152 @@ +import NcPluginMgrv2 from 'src/helpers/NcPluginMgrv2'; +import Noco from '~/Noco'; +import NocoCache from '~/cache/NocoCache'; +import { CacheGetType, CacheScope } from '~/utils/globals'; + +function roundExpiry(date) { + const msInHour = 10 * 60 * 1000; + return new Date(Math.ceil(date.getTime() / msInHour) * msInHour); +} + +const DEFAULT_EXPIRE_SECONDS = isNaN( + parseInt(process.env.NC_ATTACHMENT_EXPIRE_SECONDS), +) + ? 2 * 60 * 60 + : parseInt(process.env.NC_ATTACHMENT_EXPIRE_SECONDS); + +export default class TemporaryUrl { + path: string; + url: string; + expires_at: string; + + constructor(data: Partial) { + Object.assign(this, data); + } + + private static async add(param: { + path: string; + url: string; + expires_at: Date; + expiresInSeconds?: number; + }) { + const { + path, + url, + expires_at, + expiresInSeconds = DEFAULT_EXPIRE_SECONDS, + } = param; + await NocoCache.setExpiring( + `${CacheScope.TEMPORARY_URL}:path:${path}`, + { + path, + url, + expires_at, + }, + expiresInSeconds, + ); + await NocoCache.setExpiring( + `${CacheScope.TEMPORARY_URL}:url:${decodeURIComponent(url)}`, + { + path, + url, + expires_at, + }, + expiresInSeconds, + ); + } + + private static async delete(param: { path: string; url: string }) { + const { path, url } = param; + await NocoCache.del(`${CacheScope.TEMPORARY_URL}:path:${path}`); + await NocoCache.del(`${CacheScope.TEMPORARY_URL}:url:${url}`); + } + + public static async getPath(url: string, _ncMeta = Noco.ncMeta) { + const urlData = + url && + (await NocoCache.get( + `${CacheScope.TEMPORARY_URL}:url:${url}`, + CacheGetType.TYPE_OBJECT, + )); + if (!urlData) { + return null; + } + + // if present, check if the expiry date is greater than now + if ( + urlData && + new Date(urlData.expires_at).getTime() < new Date().getTime() + ) { + // if not, delete the url + await this.delete({ path: urlData.path, url: urlData.url }); + return null; + } + + return urlData?.path; + } + + public static async getTemporaryUrl( + param: { + path: string; + expireSeconds?: number; + s3?: boolean; + }, + _ncMeta = Noco.ncMeta, + ) { + const { path, expireSeconds = DEFAULT_EXPIRE_SECONDS, s3 = false } = param; + const expireAt = roundExpiry( + new Date(new Date().getTime() + expireSeconds * 1000), + ); // at least expireSeconds from now + + // calculate the expiry time in seconds considering rounding + const expiresInSeconds = Math.ceil( + (expireAt.getTime() - new Date().getTime()) / 1000, + ); + + let tempUrl; + + const url = await NocoCache.get( + `${CacheScope.TEMPORARY_URL}:path:${path}`, + CacheGetType.TYPE_OBJECT, + ); + + if (url) { + // if present, check if the expiry date is greater than now + if (new Date(url.expires_at).getTime() > new Date().getTime()) { + // if greater, return the url + return url.url; + } else { + // if not, delete the url + await this.delete({ path: url.path, url: url.url }); + } + } + + if (s3) { + // if not present, create a new url + const storageAdapter = await NcPluginMgrv2.storageAdapter(); + + tempUrl = await (storageAdapter as any).getSignedUrl( + path, + expiresInSeconds, + ); + await this.add({ + path: path, + url: tempUrl, + expires_at: expireAt, + expiresInSeconds, + }); + } else { + // if not present, create a new url + tempUrl = `dltemp/${expireAt.getTime()}/${path}`; + await this.add({ + path: path, + url: tempUrl, + expires_at: expireAt, + expiresInSeconds, + }); + } + + // return the url + return tempUrl; + } +} diff --git a/packages/nocodb/src/models/index.ts b/packages/nocodb/src/models/index.ts index 0629ae88c9..948abfaf7f 100644 --- a/packages/nocodb/src/models/index.ts +++ b/packages/nocodb/src/models/index.ts @@ -36,3 +36,4 @@ export { default as User } from './User'; export { default as View } from './View'; export { default as LinksColumn } from './LinksColumn'; export { default as Notification } from './Notification'; +export { default as TemporaryUrl } from './TemporaryUrl'; diff --git a/packages/nocodb/src/modules/datas/helpers.ts b/packages/nocodb/src/modules/datas/helpers.ts index c38db88f3d..2a72a7466b 100644 --- a/packages/nocodb/src/modules/datas/helpers.ts +++ b/packages/nocodb/src/modules/datas/helpers.ts @@ -163,7 +163,9 @@ export async function serializeCellValue({ return (data || []).map( (attachment) => `${encodeURI(attachment.title)}(${encodeURI( - attachment.path ? `${siteUrl}/${attachment.path}` : attachment.url, + attachment.signedPath + ? `${siteUrl}/${attachment.signedPath}` + : attachment.signedUrl, )})`, ); } diff --git a/packages/nocodb/src/modules/metas/metas.module.ts b/packages/nocodb/src/modules/metas/metas.module.ts index 9e96e02542..31db12c9d1 100644 --- a/packages/nocodb/src/modules/metas/metas.module.ts +++ b/packages/nocodb/src/modules/metas/metas.module.ts @@ -6,6 +6,7 @@ import { NC_ATTACHMENT_FIELD_SIZE } from '~/constants'; import { ApiDocsController } from '~/controllers/api-docs/api-docs.controller'; import { ApiTokensController } from '~/controllers/api-tokens.controller'; import { AttachmentsController } from '~/controllers/attachments.controller'; +import { AttachmentsSecureController } from '~/controllers/attachments-secure.controller'; import { AuditsController } from '~/controllers/audits.controller'; import { SourcesController } from '~/controllers/sources.controller'; import { CachesController } from '~/controllers/caches.controller'; @@ -88,7 +89,9 @@ export const metaModuleMetadata = { ? [ ApiDocsController, ApiTokensController, - AttachmentsController, + ...(process.env.NC_SECURE_ATTACHMENTS === 'true' + ? [AttachmentsSecureController] + : [AttachmentsController]), AuditsController, SourcesController, CachesController, diff --git a/packages/nocodb/src/plugins/s3/S3.ts b/packages/nocodb/src/plugins/s3/S3.ts index f87409fab7..53f29ec20b 100644 --- a/packages/nocodb/src/plugins/s3/S3.ts +++ b/packages/nocodb/src/plugins/s3/S3.ts @@ -1,23 +1,31 @@ import fs from 'fs'; import { promisify } from 'util'; -import AWS from 'aws-sdk'; +import { GetObjectCommand, S3 as S3Client } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import axios from 'axios'; import type { IStorageAdapterV2, XcFile } from 'nc-plugin'; import type { Readable } from 'stream'; import { generateTempFilePath, waitForStreamClose } from '~/utils/pluginUtils'; export default class S3 implements IStorageAdapterV2 { - private s3Client: AWS.S3; + private s3Client: S3Client; private input: any; constructor(input: any) { this.input = input; } + get defaultParams() { + return { + ACL: 'private', + Bucket: this.input.bucket, + }; + } + async fileCreate(key: string, file: XcFile): Promise { const uploadParams: any = { - ACL: 'public-read', - ContentType: file.mimetype, + ...this.defaultParams, + // ContentType: file.mimetype, }; return new Promise((resolve, reject) => { // Configure the file stream and obtain the upload parameters @@ -31,20 +39,20 @@ export default class S3 implements IStorageAdapterV2 { uploadParams.Key = key; // call S3 to retrieve upload file to specified bucket - this.s3Client.upload(uploadParams, (err, data) => { - if (err) { - console.log('Error', err); + // call S3 to retrieve upload file to specified bucket + this.upload(uploadParams) + .then((data) => { + resolve(data); + }) + .catch((err) => { reject(err); - } - if (data) { - resolve(data.Location); - } - }); + }); }); } + async fileCreateByUrl(key: string, url: string): Promise { const uploadParams: any = { - ACL: 'public-read', + ...this.defaultParams, }; return new Promise((resolve, reject) => { axios @@ -55,14 +63,8 @@ export default class S3 implements IStorageAdapterV2 { uploadParams.ContentType = response.headers['content-type']; // call S3 to retrieve upload file to specified bucket - this.s3Client.upload(uploadParams, (err1, data) => { - if (err1) { - console.log('Error', err1); - reject(err1); - } - if (data) { - resolve(data.Location); - } + this.upload(uploadParams).then((data) => { + resolve(data); }); }) .catch((error) => { @@ -104,6 +106,14 @@ export default class S3 implements IStorageAdapterV2 { }); } + public async getSignedUrl(key, expires = 7200) { + const command = new GetObjectCommand({ + Key: key, + Bucket: this.input.bucket, + }); + return getSignedUrl(this.s3Client, command, { expiresIn: expires }); + } + public async init(): Promise { // const s3Options: any = { // params: {Bucket: process.env.NC_S3_BUCKET}, @@ -113,15 +123,15 @@ export default class S3 implements IStorageAdapterV2 { // s3Options.accessKeyId = process.env.NC_S3_KEY; // s3Options.secretAccessKey = process.env.NC_S3_SECRET; - const s3Options: any = { - params: { Bucket: this.input.bucket }, + const s3Options = { region: this.input.region, + credentials: { + accessKeyId: this.input.access_key, + secretAccessKey: this.input.access_secret, + }, }; - s3Options.accessKeyId = this.input.access_key; - s3Options.secretAccessKey = this.input.access_secret; - - this.s3Client = new AWS.S3(s3Options); + this.s3Client = new S3Client(s3Options); } public async test(): Promise { @@ -141,4 +151,23 @@ export default class S3 implements IStorageAdapterV2 { throw e; } } + + private async upload(uploadParams): Promise { + return new Promise((resolve, reject) => { + // call S3 to retrieve upload file to specified bucket + this.s3Client + .putObject({ ...this.defaultParams, ...uploadParams }) + .then((data) => { + if (data) { + resolve( + `https://${this.input.bucket}.s3.${this.input.region}.amazonaws.com/${uploadParams.Key}`, + ); + } + }) + .catch((err) => { + console.error(err); + reject(err); + }); + }); + } } diff --git a/packages/nocodb/src/plugins/storage/Local.ts b/packages/nocodb/src/plugins/storage/Local.ts index 6d415395ea..05b6b2dab4 100644 --- a/packages/nocodb/src/plugins/storage/Local.ts +++ b/packages/nocodb/src/plugins/storage/Local.ts @@ -121,7 +121,7 @@ export default class Local implements IStorageAdapterV2 { } // method for validate/normalise the path for avoid path traversal attack - protected validateAndNormalisePath( + public validateAndNormalisePath( fileOrFolderPath: string, throw404 = false, ): string { diff --git a/packages/nocodb/src/services/attachments.service.ts b/packages/nocodb/src/services/attachments.service.ts index b6d1194df9..a41a0b539a 100644 --- a/packages/nocodb/src/services/attachments.service.ts +++ b/packages/nocodb/src/services/attachments.service.ts @@ -7,6 +7,7 @@ import { AppHooksService } from '~/services/app-hooks/app-hooks.service'; import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2'; import Local from '~/plugins/storage/Local'; import mimetypes, { mimeIcons } from '~/utils/mimeTypes'; +import { TemporaryUrl } from '~/models'; @Injectable() export class AttachmentsService { @@ -34,24 +35,49 @@ export class AttachmentsService { file, ); - let attachmentPath; - - // if `url` is null, then it is local attachment - if (!url) { - // then store the attachment path only - // url will be constructed in `useAttachmentCell` - attachmentPath = `download/${filePath.join('/')}/${fileName}`; - } - - return { + const attachment: { + url?: string; + path?: string; + title: string; + mimetype: string; + size: number; + icon?: string; + signedPath?: string; + signedUrl?: string; + } = { ...(url ? { url } : {}), - ...(attachmentPath ? { path: attachmentPath } : {}), title: file.originalname, mimetype: file.mimetype, size: file.size, icon: mimeIcons[path.extname(file.originalname).slice(1)] || undefined, }; + + const promises = []; + // if `url` is null, then it is local attachment + if (!url) { + // then store the attachment path only + // url will be constructed in `useAttachmentCell` + attachment.path = `download/${filePath.join('/')}/${fileName}`; + + promises.push( + TemporaryUrl.getTemporaryUrl({ + path: attachment.path.replace(/^download\//, ''), + }).then((r) => (attachment.signedPath = r)), + ); + } else { + if (attachment.url.includes('.amazonaws.com/')) { + const relativePath = attachment.url.split('.amazonaws.com/')[1]; + promises.push( + TemporaryUrl.getTemporaryUrl({ + path: relativePath, + s3: true, + }).then((r) => (attachment.signedUrl = r)), + ); + } + } + + return Promise.all(promises).then(() => attachment); }), ); @@ -123,7 +149,10 @@ export class AttachmentsService { mimetypes[path.extname(param.path).split('/').pop().slice(1)] || 'text/plain'; - const img = await storageAdapter.fileRead(slash(param.path)); + const img = await storageAdapter.validateAndNormalisePath( + slash(param.path), + true, + ); return { img, type }; } diff --git a/packages/nocodb/src/utils/globals.ts b/packages/nocodb/src/utils/globals.ts index cd543a4f5e..f7813eee5d 100644 --- a/packages/nocodb/src/utils/globals.ts +++ b/packages/nocodb/src/utils/globals.ts @@ -157,6 +157,7 @@ export enum CacheScope { DASHBOARD_PROJECT_DB_PROJECT_LINKING = 'dashboardProjectDBProjectLinking', SINGLE_QUERY = 'singleQuery', JOBS = 'nc_jobs', + TEMPORARY_URL = 'temporaryUrl', } export enum CacheGetType { diff --git a/tests/playwright/tests/db/columns/columnAttachments.spec.ts b/tests/playwright/tests/db/columns/columnAttachments.spec.ts index 020a3ea3f1..fac8ef6c4b 100644 --- a/tests/playwright/tests/db/columns/columnAttachments.spec.ts +++ b/tests/playwright/tests/db/columns/columnAttachments.spec.ts @@ -109,6 +109,6 @@ test.describe('Attachment column', () => { // PR8504 // await expect(cells[1]).toBe('al-Manama'); expect(cells[1]).toBe('1'); - expect(cells[2].includes('5.json(http://localhost:8080/download/')).toBe(true); + expect(cells[2].includes('5.json(http://localhost:8080/dltemp/')).toBe(true); }); });