From f78d0df47bd41be9c0de81f5d6abfa9778827532 Mon Sep 17 00:00:00 2001 From: Pranav C Date: Mon, 10 Apr 2023 00:29:09 +0530 Subject: [PATCH] feat: public shared data apis (WIP) Signed-off-by: Pranav C --- packages/nocodb-nest/src/app.module.ts | 3 +- .../public-datas.controller.spec.ts | 20 + .../public-datas/public-datas.controller.ts | 113 +++++ .../public-datas/public-datas.module.ts | 9 + .../public-datas/public-datas.service.spec.ts | 18 + .../public-datas/public-datas.service.ts | 476 ++++++++++++++++++ 6 files changed, 638 insertions(+), 1 deletion(-) create mode 100644 packages/nocodb-nest/src/modules/public-datas/public-datas.controller.spec.ts create mode 100644 packages/nocodb-nest/src/modules/public-datas/public-datas.controller.ts create mode 100644 packages/nocodb-nest/src/modules/public-datas/public-datas.module.ts create mode 100644 packages/nocodb-nest/src/modules/public-datas/public-datas.service.spec.ts create mode 100644 packages/nocodb-nest/src/modules/public-datas/public-datas.service.ts diff --git a/packages/nocodb-nest/src/app.module.ts b/packages/nocodb-nest/src/app.module.ts index ff8ea9eff7..3cbcbc9174 100644 --- a/packages/nocodb-nest/src/app.module.ts +++ b/packages/nocodb-nest/src/app.module.ts @@ -39,9 +39,10 @@ import { DatasModule } from './modules/datas/datas.module'; import { DataAliasModule } from './modules/data-alias/data-alias.module'; import { ApiDocsModule } from './modules/api-docs/api-docs.module'; import { PublicMetasModule } from './modules/public-metas/public-metas.module'; +import { PublicDatasModule } from './modules/public-datas/public-datas.module'; @Module({ - imports: [AuthModule, UsersModule, UtilsModule, ProjectsModule, TablesModule, ViewsModule, FiltersModule, SortsModule, ColumnsModule, ViewColumnsModule, BasesModule, HooksModule, SharedBasesModule, FormsModule, GridsModule, KanbansModule, GalleriesModule, FormColumnsModule, GridColumnsModule, MapsModule, ProjectUsersModule, ModelVisibilitiesModule, HookFiltersModule, ApiTokensModule, AttachmentsModule, OrgLcenseModule, OrgTokensModule, OrgUsersModule, MetaDiffsModule, AuditsModule, DatasModule, DataAliasModule, ApiDocsModule, PublicMetasModule], + imports: [AuthModule, UsersModule, UtilsModule, ProjectsModule, TablesModule, ViewsModule, FiltersModule, SortsModule, ColumnsModule, ViewColumnsModule, BasesModule, HooksModule, SharedBasesModule, FormsModule, GridsModule, KanbansModule, GalleriesModule, FormColumnsModule, GridColumnsModule, MapsModule, ProjectUsersModule, ModelVisibilitiesModule, HookFiltersModule, ApiTokensModule, AttachmentsModule, OrgLcenseModule, OrgTokensModule, OrgUsersModule, MetaDiffsModule, AuditsModule, DatasModule, DataAliasModule, ApiDocsModule, PublicMetasModule, PublicDatasModule], controllers: [], providers: [Connection, MetaService, JwtStrategy, ExtractProjectIdMiddleware], exports: [Connection, MetaService], diff --git a/packages/nocodb-nest/src/modules/public-datas/public-datas.controller.spec.ts b/packages/nocodb-nest/src/modules/public-datas/public-datas.controller.spec.ts new file mode 100644 index 0000000000..6bd3ab6dfb --- /dev/null +++ b/packages/nocodb-nest/src/modules/public-datas/public-datas.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PublicDatasController } from './public-datas.controller'; +import { PublicDatasService } from './public-datas.service'; + +describe('PublicDatasController', () => { + let controller: PublicDatasController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [PublicDatasController], + providers: [PublicDatasService], + }).compile(); + + controller = module.get(PublicDatasController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/packages/nocodb-nest/src/modules/public-datas/public-datas.controller.ts b/packages/nocodb-nest/src/modules/public-datas/public-datas.controller.ts new file mode 100644 index 0000000000..064e908d80 --- /dev/null +++ b/packages/nocodb-nest/src/modules/public-datas/public-datas.controller.ts @@ -0,0 +1,113 @@ +import { Controller, Get, Param, Post, Request } from '@nestjs/common'; +import { PublicDatasService } from './public-datas.service'; + +@Controller('public-datas') +export class PublicDatasController { + constructor(private readonly publicDatasService: PublicDatasService) {} + + @Get('/api/v1/db/public/shared-view/:sharedViewUuid/rows') + async dataList( + @Request() req, + @Param('sharedViewUuid') sharedViewUuid: string, + ) { + const pagedResponse = await this.publicDatasService.dataList({ + query: req.query, + password: req.headers?.['xc-password'] as string, + sharedViewUuid, + }); + return pagedResponse; + } + + // todo: Handle the error case where view doesnt belong to model + @Get('/api/v1/db/public/shared-view/:sharedViewUuid/group/:columnId') + async groupedDataList( + @Request() req, + @Param('sharedViewUuid') sharedViewUuid: string, + @Param('columnId') columnId: string, + ) { + const groupedData = await this.publicDatasService.groupedDataList({ + query: req.query, + password: req.headers?.['xc-password'] as string, + sharedViewUuid: sharedViewUuid, + groupColumnId: columnId, + }); + return groupedData; + } + + // todo: multer + // router.post( + // '/api/v1/db/public/shared-view/:sharedViewUuid/rows', + // multer({ + // storage: multer.diskStorage({}), + // limits: { + // fieldSize: NC_ATTACHMENT_FIELD_SIZE, + // }, + // }).any(), + // catchError(dataInsert) + // ); + @Post('/api/v1/db/public/shared-view/:sharedViewUuid/rows') + async dataInsert( + @Request() req, + @Param('sharedViewUuid') sharedViewUuid: string, + ) { + const insertResult = await this.publicDatasService.dataInsert({ + sharedViewUuid: sharedViewUuid, + password: req.headers?.['xc-password'] as string, + body: req.body?.data, + siteUrl: (req as any).ncSiteUrl, + files: req.files, + }); + + return insertResult; + } + + @Get('/api/v1/db/public/shared-view/:sharedViewUuid/nested/:columnId') + async relDataList( + @Request() req, + @Param('sharedViewUuid') sharedViewUuid: string, + @Param('columnId') columnId: string, + ) { + const pagedResponse = await this.publicDatasService.relDataList({ + query: req.query, + password: req.headers?.['xc-password'] as string, + sharedViewUuid: sharedViewUuid, + columnId: columnId, + }); + + return pagedResponse; + } + + @Get('/api/v1/db/public/shared-view/:sharedViewUuid/rows/:rowId/mm/:colId') + async publicMmList( + @Request() req, + @Param('sharedViewUuid') sharedViewUuid: string, + @Param('rowId') rowId: string, + @Param('colId') colId: string, + ) { + const paginatedResponse = await this.publicDatasService.publicMmList({ + query: req.query, + password: req.headers?.['xc-password'] as string, + sharedViewUuid: sharedViewUuid, + columnId: colId, + rowId: rowId, + }); + return paginatedResponse; + } + + @Get('/api/v1/db/public/shared-view/:sharedViewUuid/rows/:rowId/hm/:colId') + async publicHmList( + @Request() req, + @Param('sharedViewUuid') sharedViewUuid: string, + @Param('rowId') rowId: string, + @Param('colId') colId: string, + ) { + const paginatedResponse = await this.publicDatasService.publicHmList({ + query: req.query, + password: req.headers?.['xc-password'] as string, + sharedViewUuid: sharedViewUuid, + columnId: colId, + rowId: rowId, + }); + return paginatedResponse; + } +} diff --git a/packages/nocodb-nest/src/modules/public-datas/public-datas.module.ts b/packages/nocodb-nest/src/modules/public-datas/public-datas.module.ts new file mode 100644 index 0000000000..439ee221a0 --- /dev/null +++ b/packages/nocodb-nest/src/modules/public-datas/public-datas.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PublicDatasService } from './public-datas.service'; +import { PublicDatasController } from './public-datas.controller'; + +@Module({ + controllers: [PublicDatasController], + providers: [PublicDatasService] +}) +export class PublicDatasModule {} diff --git a/packages/nocodb-nest/src/modules/public-datas/public-datas.service.spec.ts b/packages/nocodb-nest/src/modules/public-datas/public-datas.service.spec.ts new file mode 100644 index 0000000000..645d7275f6 --- /dev/null +++ b/packages/nocodb-nest/src/modules/public-datas/public-datas.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PublicDatasService } from './public-datas.service'; + +describe('PublicDatasService', () => { + let service: PublicDatasService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PublicDatasService], + }).compile(); + + service = module.get(PublicDatasService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/nocodb-nest/src/modules/public-datas/public-datas.service.ts b/packages/nocodb-nest/src/modules/public-datas/public-datas.service.ts new file mode 100644 index 0000000000..11f04f137b --- /dev/null +++ b/packages/nocodb-nest/src/modules/public-datas/public-datas.service.ts @@ -0,0 +1,476 @@ +import { Injectable } from '@nestjs/common'; +import { nanoid } from 'nanoid'; +import { ErrorMessages, UITypes, ViewTypes } from 'nocodb-sdk'; +import path from 'path'; +import slash from 'slash'; +import { + Base, + Column, + LinkToAnotherRecordColumn, + Model, + View, +} from 'src/models'; +import { sanitizeUrlPath } from '../../../../nocodb/src/lib/services/attachment.svc'; +import { NcError } from '../../helpers/catchError'; +import getAst from '../../helpers/getAst'; +import NcPluginMgrv2 from '../../helpers/NcPluginMgrv2'; +import { PagedResponseImpl } from '../../helpers/PagedResponse'; +import NcConnectionMgrv2 from '../../utils/common/NcConnectionMgrv2'; +import { nocoExecute } from 'nc-help'; +import { mimeIcons } from '../../utils/mimeTypes'; +import { getColumnByIdOrName } from '../datas/helpers'; + +@Injectable() +export class PublicDatasService { + async dataList(param: { + sharedViewUuid: string; + password?: string; + query: any; + }) { + const view = await View.getByUUID(param.sharedViewUuid); + + if (!view) NcError.notFound('Not found'); + if ( + view.type !== ViewTypes.GRID && + view.type !== ViewTypes.KANBAN && + view.type !== ViewTypes.GALLERY && + view.type !== ViewTypes.MAP + ) { + NcError.notFound('Not found'); + } + + if (view.password && view.password !== param.password) { + return NcError.forbidden(ErrorMessages.INVALID_SHARED_VIEW_PASSWORD); + } + + const model = await Model.getByIdOrName({ + id: view?.fk_model_id, + }); + + const base = await Base.get(model.base_id); + + const baseModel = await Model.getBaseModelSQL({ + id: model.id, + viewId: view?.id, + dbDriver: await NcConnectionMgrv2.get(base), + }); + + const listArgs: any = { ...param.query }; + try { + listArgs.filterArr = JSON.parse(listArgs.filterArrJson); + } catch (e) {} + try { + listArgs.sortArr = JSON.parse(listArgs.sortArrJson); + } catch (e) {} + + let data = []; + let count = 0; + + try { + const { ast } = await getAst({ + query: param.query, + model, + view, + }); + + data = await nocoExecute( + ast, + await baseModel.list(listArgs), + {}, + listArgs, + ); + count = await baseModel.count(listArgs); + } catch (e) { + console.log(e); + // show empty result instead of throwing error here + // e.g. search some text in a numeric field + + NcError.internalServerError('Please try after some time'); + } + + return new PagedResponseImpl(data, { ...param.query, count }); + } + + // todo: Handle the error case where view doesnt belong to model + async groupedDataList(param: { + sharedViewUuid: string; + password?: string; + query: any; + groupColumnId: string; + }) { + const view = await View.getByUUID(param.sharedViewUuid); + + if (!view) NcError.notFound('Not found'); + + if ( + view.type !== ViewTypes.GRID && + view.type !== ViewTypes.KANBAN && + view.type !== ViewTypes.GALLERY + ) { + NcError.notFound('Not found'); + } + + if (view.password && view.password !== param.password) { + return NcError.forbidden(ErrorMessages.INVALID_SHARED_VIEW_PASSWORD); + } + + const model = await Model.getByIdOrName({ + id: view?.fk_model_id, + }); + + return await this.getGroupedDataList({ + model, + view, + query: param.query, + groupColumnId: param.groupColumnId, + }); + } + + async getGroupedDataList(param: { + model: Model; + view: View; + query: any; + groupColumnId: string; + }) { + const { model, view, query = {}, groupColumnId } = param; + const base = await Base.get(param.model.base_id); + + const baseModel = await Model.getBaseModelSQL({ + id: model.id, + viewId: view?.id, + dbDriver: await NcConnectionMgrv2.get(base), + }); + + const { ast } = await getAst({ model, query: param.query, view }); + + const listArgs: any = { ...query }; + try { + listArgs.filterArr = JSON.parse(listArgs.filterArrJson); + } catch (e) {} + try { + listArgs.sortArr = JSON.parse(listArgs.sortArrJson); + } catch (e) {} + try { + listArgs.options = JSON.parse(listArgs.optionsArrJson); + } catch (e) {} + + let data = []; + + try { + const groupedData = await baseModel.groupedList({ + ...listArgs, + groupColumnId, + }); + data = await nocoExecute( + { key: 1, value: ast }, + groupedData, + {}, + listArgs, + ); + const countArr = await baseModel.groupedListCount({ + ...listArgs, + groupColumnId, + }); + data = data.map((item) => { + // todo: use map to avoid loop + const count = + countArr.find((countItem: any) => countItem.key === item.key) + ?.count ?? 0; + + item.value = new PagedResponseImpl(item.value, { + ...query, + count: count, + }); + return item; + }); + } catch (e) { + console.log(e); + NcError.internalServerError('Internal Server Error'); + } + return data; + } + + async dataInsert(param: { + sharedViewUuid: string; + password?: string; + body: any; + files: any[]; + siteUrl: string; + }) { + const view = await View.getByUUID(param.sharedViewUuid); + + if (!view) NcError.notFound(); + if (view.type !== ViewTypes.FORM) NcError.notFound(); + + if (view.password && view.password !== param.password) { + return NcError.forbidden(ErrorMessages.INVALID_SHARED_VIEW_PASSWORD); + } + + const model = await Model.getByIdOrName({ + id: view?.fk_model_id, + }); + + const base = await Base.get(model.base_id); + const project = await base.getProject(); + + const baseModel = await Model.getBaseModelSQL({ + id: model.id, + viewId: view?.id, + dbDriver: await NcConnectionMgrv2.get(base), + }); + + await view.getViewWithInfo(); + await view.getColumns(); + await view.getModelWithInfo(); + await view.model.getColumns(); + + const fields = (view.model.columns = view.columns + .filter((c) => c.show) + .reduce((o, c) => { + o[view.model.columnsById[c.fk_column_id].title] = new Column({ + ...c, + ...view.model.columnsById[c.fk_column_id], + } as any); + return o; + }, {}) as any); + + let body = param?.body; + + if (typeof body === 'string') body = JSON.parse(body); + + const insertObject = Object.entries(body).reduce((obj, [key, val]) => { + if (key in fields) { + obj[key] = val; + } + return obj; + }, {}); + + const attachments = {}; + const storageAdapter = await NcPluginMgrv2.storageAdapter(); + + for (const file of param.files || []) { + // remove `_` prefix and `[]` suffix + const fieldName = file?.fieldname?.replace(/^_|\[\d*]$/g, ''); + + const filePath = sanitizeUrlPath([ + 'v1', + project.title, + model.title, + fieldName, + ]); + + if ( + fieldName in fields && + fields[fieldName].uidt === UITypes.Attachment + ) { + attachments[fieldName] = attachments[fieldName] || []; + const fileName = `${nanoid(6)}_${file.originalname}`; + let url = await storageAdapter.fileCreate( + slash(path.join('nc', 'uploads', ...filePath, fileName)), + file, + ); + + if (!url) { + url = `${param.siteUrl}/download/${filePath.join('/')}/${fileName}`; + } + + attachments[fieldName].push({ + url, + title: file.originalname, + mimetype: file.mimetype, + size: file.size, + icon: + mimeIcons[path.extname(file.originalname).slice(1)] || undefined, + }); + } + } + + for (const [column, data] of Object.entries(attachments)) { + insertObject[column] = JSON.stringify(data); + } + + return await baseModel.nestedInsert(insertObject, null); + } + + async relDataList(param: { + query: any; + sharedViewUuid: string; + password?: string; + columnId: string; + }) { + const view = await View.getByUUID(param.sharedViewUuid); + + if (!view) NcError.notFound('Not found'); + + if (view.type !== ViewTypes.FORM) NcError.notFound('Not found'); + + if (view.password && view.password !== param.password) { + NcError.forbidden(ErrorMessages.INVALID_SHARED_VIEW_PASSWORD); + } + + const column = await Column.get({ colId: param.columnId }); + const colOptions = await column.getColOptions(); + + const model = await colOptions.getRelatedTable(); + + const base = await Base.get(model.base_id); + + const baseModel = await Model.getBaseModelSQL({ + id: model.id, + viewId: view?.id, + dbDriver: await NcConnectionMgrv2.get(base), + }); + + const { ast } = await getAst({ + query: param.query, + model, + extractOnlyPrimaries: true, + }); + + let data = []; + let count = 0; + try { + data = data = await nocoExecute( + ast, + await baseModel.list(param.query), + {}, + param.query, + ); + count = await baseModel.count(param.query); + } catch (e) { + // show empty result instead of throwing error here + // e.g. search some text in a numeric field + } + + return new PagedResponseImpl(data, { ...param.query, count }); + } + + async publicMmList(param: { + query: any; + sharedViewUuid: string; + password?: string; + columnId: string; + rowId: string; + }) { + const view = await View.getByUUID(param.sharedViewUuid); + + if (!view) NcError.notFound('Not found'); + if (view.type !== ViewTypes.GRID) NcError.notFound('Not found'); + + if (view.password && view.password !== param.password) { + NcError.forbidden(ErrorMessages.INVALID_SHARED_VIEW_PASSWORD); + } + + const column = await getColumnByIdOrName( + param.columnId, + await view.getModel(), + ); + + if (column.fk_model_id !== view.fk_model_id) + NcError.badRequest("Column doesn't belongs to the model"); + + const base = await Base.get(view.base_id); + + const baseModel = await Model.getBaseModelSQL({ + id: view.fk_model_id, + viewId: view?.id, + dbDriver: await NcConnectionMgrv2.get(base), + }); + + const key = `List`; + const requestObj: any = { + [key]: 1, + }; + + const data = ( + await nocoExecute( + requestObj, + { + [key]: async (args) => { + return await baseModel.mmList( + { + colId: param.columnId, + parentId: param.rowId, + }, + args, + ); + }, + }, + {}, + + { nested: { [key]: param.query } }, + ) + )?.[key]; + + const count: any = await baseModel.mmListCount({ + colId: param.columnId, + parentId: param.rowId, + }); + + return new PagedResponseImpl(data, { ...param.query, count }); + } + + async publicHmList(param: { + query: any; + rowId: string; + sharedViewUuid: string; + password?: string; + columnId: string; + }) { + const view = await View.getByUUID(param.sharedViewUuid); + + if (!view) NcError.notFound('Not found'); + if (view.type !== ViewTypes.GRID) NcError.notFound('Not found'); + + if (view.password && view.password !== param.password) { + NcError.forbidden(ErrorMessages.INVALID_SHARED_VIEW_PASSWORD); + } + + const column = await getColumnByIdOrName( + param.columnId, + await view.getModel(), + ); + + if (column.fk_model_id !== view.fk_model_id) + NcError.badRequest("Column doesn't belongs to the model"); + + const base = await Base.get(view.base_id); + + const baseModel = await Model.getBaseModelSQL({ + id: view.fk_model_id, + viewId: view?.id, + dbDriver: await NcConnectionMgrv2.get(base), + }); + + const key = `List`; + const requestObj: any = { + [key]: 1, + }; + + const data = ( + await nocoExecute( + requestObj, + { + [key]: async (args) => { + return await baseModel.hmList( + { + colId: param.columnId, + id: param.rowId, + }, + args, + ); + }, + }, + {}, + { nested: { [key]: param.query } }, + ) + )?.[key]; + + const count = await baseModel.hmListCount({ + colId: param.columnId, + id: param.rowId, + }); + + return new PagedResponseImpl(data, { ...param.query, count }); + } +}