diff --git a/packages/nocodb-nest/noco.db b/packages/nocodb-nest/noco.db index 6709b38131..40043ba3b0 100644 Binary files a/packages/nocodb-nest/noco.db and b/packages/nocodb-nest/noco.db differ diff --git a/packages/nocodb-nest/src/Noco.ts b/packages/nocodb-nest/src/Noco.ts index e7a4665529..bf88194d58 100644 --- a/packages/nocodb-nest/src/Noco.ts +++ b/packages/nocodb-nest/src/Noco.ts @@ -39,7 +39,7 @@ export default class Noco { return Noco._this.init(args, server, app); }*/ - private static config: any; + public static config: any; public readonly router: express.Router; public readonly projectRouter: express.Router; public static _ncMeta: any; diff --git a/packages/nocodb-nest/src/app.module.ts b/packages/nocodb-nest/src/app.module.ts index bec3df5009..0355a30eb4 100644 --- a/packages/nocodb-nest/src/app.module.ts +++ b/packages/nocodb-nest/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { Connection } from './connection/connection'; import { AuthModule } from './auth/auth.module'; +import { ExtractProjectIdMiddleware } from './middlewares/extract-project-id/extract-project-id.middleware' import { UsersModule } from './users/users.module'; import { MetaService } from './meta/meta.service'; import { LocalStrategy } from './strategies/local.strategy'; @@ -11,7 +12,7 @@ import { JwtStrategy } from './strategies/jwt.strategy'; @Module({ imports: [AuthModule, UsersModule, UtilsModule, ProjectsModule], controllers: [], - providers: [Connection, MetaService, JwtStrategy], + providers: [Connection, MetaService, JwtStrategy, ExtractProjectIdMiddleware], exports: [Connection, MetaService], }) export class AppModule {} diff --git a/packages/nocodb-nest/src/auth/auth.module.ts b/packages/nocodb-nest/src/auth/auth.module.ts index 652560c6a0..92a1f71b22 100644 --- a/packages/nocodb-nest/src/auth/auth.module.ts +++ b/packages/nocodb-nest/src/auth/auth.module.ts @@ -15,7 +15,7 @@ import { jwtConstants } from './constants'; PassportModule, JwtModule.register({ secret: jwtConstants.secret, - signOptions: { expiresIn: '60s' }, + signOptions: { expiresIn: '10h' }, }), ], providers: [AuthService, LocalStrategy], diff --git a/packages/nocodb-nest/src/auth/auth.service.ts b/packages/nocodb-nest/src/auth/auth.service.ts index 36755a3ccb..765f25b2d9 100644 --- a/packages/nocodb-nest/src/auth/auth.service.ts +++ b/packages/nocodb-nest/src/auth/auth.service.ts @@ -31,7 +31,9 @@ export class AuthService { } async login(user: any) { - const payload = { username: user.username, sub: user.userId }; + delete user.password; + delete user.salt; + const payload = user; return { token: this.jwtService.sign(payload), }; diff --git a/packages/nocodb-nest/src/cache/RedisMockCacheMgr.ts b/packages/nocodb-nest/src/cache/RedisMockCacheMgr.ts index 165597cbcd..a06ddd9fe5 100644 --- a/packages/nocodb-nest/src/cache/RedisMockCacheMgr.ts +++ b/packages/nocodb-nest/src/cache/RedisMockCacheMgr.ts @@ -1,5 +1,5 @@ import debug from 'debug'; -import * as Redis from 'ioredis-mock'; +import Redis from 'ioredis-mock'; import { CacheDelDirection, CacheGetType, CacheScope } from '../utils/globals'; import CacheMgr from './CacheMgr'; const log = debug('nc:cache'); diff --git a/packages/nocodb-nest/src/connection/connection.ts b/packages/nocodb-nest/src/connection/connection.ts index f02df10154..42d5e15888 100644 --- a/packages/nocodb-nest/src/connection/connection.ts +++ b/packages/nocodb-nest/src/connection/connection.ts @@ -7,15 +7,22 @@ import NcConfigFactory from '../utils/NcConfigFactory'; @Injectable() export class Connection implements OnModuleInit { private knex: knex.Knex; - private dbConfig: any; + private _config: any; get knexInstance(): knex.Knex { return this.knex; } + get config(): knex.Knex { + return this._config; + } + // init metadb connection async onModuleInit(): Promise { - this.dbConfig = await NcConfigFactory.make(); - this.knex = knex.default({ ...this.dbConfig.meta.db, useNullAsDefault: true }); + this._config = await NcConfigFactory.make(); + this.knex = knex.default({ + ...this._config.meta.db, + useNullAsDefault: true, + }); } } diff --git a/packages/nocodb-nest/src/db/util/emit.ts b/packages/nocodb-nest/src/db/util/emit.ts index 241349f3e4..b5642c3b03 100644 --- a/packages/nocodb-nest/src/db/util/emit.ts +++ b/packages/nocodb-nest/src/db/util/emit.ts @@ -1,4 +1,4 @@ -import * as Emittery from 'emittery'; +import Emittery from 'emittery'; let emitSingleton = null; diff --git a/packages/nocodb-nest/src/helpers/extractProjectIdAndAuthenticate.ts b/packages/nocodb-nest/src/helpers/extractProjectIdAndAuthenticate.ts index 88cb6687c6..998779cadd 100644 --- a/packages/nocodb-nest/src/helpers/extractProjectIdAndAuthenticate.ts +++ b/packages/nocodb-nest/src/helpers/extractProjectIdAndAuthenticate.ts @@ -1,3 +1,4 @@ + import { promisify } from 'util'; import passport from 'passport'; diff --git a/packages/nocodb-nest/src/main.ts b/packages/nocodb-nest/src/main.ts index a348b422be..e09e427ab5 100644 --- a/packages/nocodb-nest/src/main.ts +++ b/packages/nocodb-nest/src/main.ts @@ -1,6 +1,6 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; -import * as cors from 'cors'; +import cors from 'cors'; async function bootstrap() { const app = await NestFactory.create(AppModule); diff --git a/packages/nocodb-nest/src/meta/meta.service.ts b/packages/nocodb-nest/src/meta/meta.service.ts index cddcd660ba..719f805c47 100644 --- a/packages/nocodb-nest/src/meta/meta.service.ts +++ b/packages/nocodb-nest/src/meta/meta.service.ts @@ -352,6 +352,7 @@ export class MetaService implements OnApplicationBootstrap { // todo: tobe fixed - temporary workaround Noco._ncMeta = this; + Noco.config = this.metaConnection.config; } diff --git a/packages/nocodb-nest/src/middlewares/catchError.ts b/packages/nocodb-nest/src/middlewares/catchError.ts new file mode 100644 index 0000000000..b81b80295e --- /dev/null +++ b/packages/nocodb-nest/src/middlewares/catchError.ts @@ -0,0 +1,471 @@ +import type { ErrorObject } from 'ajv'; + +enum DBError { + TABLE_EXIST = 'TABLE_EXIST', + TABLE_NOT_EXIST = 'TABLE_NOT_EXIST', + COLUMN_EXIST = 'COLUMN_EXIST', + COLUMN_NOT_EXIST = 'COLUMN_NOT_EXIST', + CONSTRAINT_EXIST = 'CONSTRAINT_EXIST', + CONSTRAINT_NOT_EXIST = 'CONSTRAINT_NOT_EXIST', + COLUMN_NOT_NULL = 'COLUMN_NOT_NULL', +} + +// extract db errors using database error code +function extractDBError(error): { + type: DBError; + message: string; + info: any; + extra?: Record; +} | void { + if (!error.code) return; + + let message: string; + let extra: Record; + let type: DBError; + + // todo: handle not null constraint error for all databases + switch (error.code) { + // sqlite errors + case 'SQLITE_BUSY': + message = 'The database is locked by another process or transaction.'; + break; + case 'SQLITE_CONSTRAINT': + { + const constraint = /FOREIGN KEY|UNIQUE/.test(error.message) + ? error.message.match(/FOREIGN KEY|UNIQUE/gi)?.join(' ') + : 'constraint'; + message = `A ${constraint} constraint was violated: ${error.message}`; + extra = { + constraint, + }; + } + break; + case 'SQLITE_CORRUPT': + message = 'The database file is corrupt.'; + break; + case 'SQLITE_ERROR': + message = 'A SQL error occurred.'; + + if (error.message) { + const noSuchTableMatch = error.message.match(/no such table: (\w+)/); + const tableAlreadyExistsMatch = error.message.match( + /SQLITE_ERROR: table `?(\w+)`? already exists/ + ); + + const duplicateColumnExistsMatch = error.message.match( + /SQLITE_ERROR: duplicate column name: (\w+)/ + ); + const unrecognizedTokenMatch = error.message.match( + /SQLITE_ERROR: unrecognized token: "(\w+)"/ + ); + const columnDoesNotExistMatch = error.message.match( + /SQLITE_ERROR: no such column: (\w+)/ + ); + const constraintFailedMatch = error.message.match( + /SQLITE_ERROR: constraint failed: (\w+)/ + ); + + if (noSuchTableMatch && noSuchTableMatch[1]) { + message = `The table '${noSuchTableMatch[1]}' does not exist.`; + type = DBError.TABLE_NOT_EXIST; + extra = { + table: noSuchTableMatch[1], + }; + } else if (tableAlreadyExistsMatch && tableAlreadyExistsMatch[1]) { + message = `The table '${tableAlreadyExistsMatch[1]}' already exists.`; + type = DBError.TABLE_EXIST; + extra = { + table: tableAlreadyExistsMatch[1], + }; + } else if (unrecognizedTokenMatch && unrecognizedTokenMatch[1]) { + message = `Unrecognized token: ${unrecognizedTokenMatch[1]}`; + extra = { + token: unrecognizedTokenMatch[1], + }; + } else if (columnDoesNotExistMatch && columnDoesNotExistMatch[1]) { + message = `The column ${columnDoesNotExistMatch[1]} does not exist.`; + type = DBError.COLUMN_NOT_EXIST; + extra = { + column: columnDoesNotExistMatch[1], + }; + } else if (constraintFailedMatch && constraintFailedMatch[1]) { + message = `A constraint failed: ${constraintFailedMatch[1]}`; + } else if ( + duplicateColumnExistsMatch && + duplicateColumnExistsMatch[1] + ) { + message = `The column '${duplicateColumnExistsMatch[1]}' already exists.`; + type = DBError.COLUMN_EXIST; + extra = { + column: duplicateColumnExistsMatch[1], + }; + } else { + const match = error.message.match(/SQLITE_ERROR:\s*(\w+)/); + if (match && match[1]) { + message = match[1]; + } + } + } + break; + case 'SQLITE_RANGE': + message = 'A column index is out of range.'; + break; + case 'SQLITE_SCHEMA': + message = 'The database schema has changed.'; + break; + + // mysql errors + case 'ER_TABLE_EXISTS_ERROR': + message = 'The table already exists.'; + + if (error.message) { + const extractTableNameMatch = error.message.match( + / Table '?(\w+)'? already exists/i + ); + if (extractTableNameMatch && extractTableNameMatch[1]) { + message = `The table '${extractTableNameMatch[1]}' already exists.`; + type = DBError.TABLE_EXIST; + extra = { + table: extractTableNameMatch[1], + }; + } + } + break; + case 'ER_DUP_FIELDNAME': + message = 'The column already exists.'; + + if (error.message) { + const extractColumnNameMatch = error.message.match( + / Duplicate column name '(\w+)'/i + ); + if (extractColumnNameMatch && extractColumnNameMatch[1]) { + message = `The column '${extractColumnNameMatch[1]}' already exists.`; + type = DBError.COLUMN_EXIST; + extra = { + column: extractColumnNameMatch[1], + }; + } + } + + break; + case 'ER_NO_SUCH_TABLE': + message = 'The table does not exist.'; + + if (error.message) { + const missingTableMatch = error.message.match( + / Table '(?:\w+\.)?(\w+)' doesn't exist/i + ); + if (missingTableMatch && missingTableMatch[1]) { + message = `The table '${missingTableMatch[1]}' does not exist`; + type = DBError.TABLE_NOT_EXIST; + extra = { + table: missingTableMatch[1], + }; + } + } + + break; + case 'ER_DUP_ENTRY': + message = 'This record already exists.'; + break; + case 'ER_PARSE_ERROR': + message = 'There was a syntax error in your SQL query.'; + break; + case 'ER_NO_DEFAULT_FOR_FIELD': + message = 'A value is required for this field.'; + break; + case 'ER_BAD_NULL_ERROR': + message = 'A null value is not allowed for this field.'; + { + const extractColNameMatch = error.message.match( + /Column '(\w+)' cannot be null/i + ); + if (extractColNameMatch && extractColNameMatch[1]) { + message = `The column '${extractColNameMatch[1]}' cannot be null.`; + type = DBError.COLUMN_NOT_NULL; + extra = { + column: extractColNameMatch[1], + }; + } + } + + break; + case 'ER_DATA_TOO_LONG': + message = 'The data entered is too long for this field.'; + break; + case 'ER_BAD_FIELD_ERROR': + { + message = 'The field you are trying to access does not exist.'; + const extractColNameMatch = error.message.match( + / Unknown column '(\w+)' in 'field list'/i + ); + if (extractColNameMatch && extractColNameMatch[1]) { + message = `The column '${extractColNameMatch[1]}' does not exist.`; + type = DBError.COLUMN_NOT_EXIST; + extra = { + column: extractColNameMatch[1], + }; + } + } + break; + case 'ER_ACCESS_DENIED_ERROR': + message = 'You do not have permission to perform this action.'; + break; + case 'ER_LOCK_WAIT_TIMEOUT': + message = 'A timeout occurred while waiting for a table lock.'; + break; + case 'ER_NO_REFERENCED_ROW': + message = 'The referenced row does not exist.'; + break; + case 'ER_ROW_IS_REFERENCED': + message = 'This record is being referenced by other records.'; + break; + + // postgres errors + case '23505': + message = 'This record already exists.'; + break; + case '42601': + message = 'There was a syntax error in your SQL query.'; + break; + case '23502': + message = 'A value is required for this field.'; + break; + case '23503': + message = 'The referenced row does not exist.'; + break; + case '23514': + message = 'A null value is not allowed for this field.'; + break; + case '22001': + message = 'The data entered is too long for this field.'; + break; + case '28000': + message = 'You do not have permission to perform this action.'; + break; + case '40P01': + message = 'A timeout occurred while waiting for a table lock.'; + break; + case '23506': + message = 'This record is being referenced by other records.'; + break; + case '42P07': + message = 'The table already exists.'; + if (error.message) { + const extractTableNameMatch = error.message.match( + / relation "?(\w+)"? already exists/i + ); + if (extractTableNameMatch && extractTableNameMatch[1]) { + message = `The table '${extractTableNameMatch[1]}' already exists.`; + type = DBError.TABLE_EXIST; + extra = { + table: extractTableNameMatch[1], + }; + } + } + break; + case '42701': + message = 'The column already exists.'; + if (error.message) { + const extractTableNameMatch = error.message.match( + / column "(\w+)" of relation "(\w+)" already exists/i + ); + if (extractTableNameMatch && extractTableNameMatch[1]) { + message = `The column '${extractTableNameMatch[1]}' already exists.`; + type = DBError.COLUMN_EXIST; + extra = { + column: extractTableNameMatch[1], + }; + } + } + break; + case '42P01': + message = 'The table does not exist.'; + if (error.message) { + const extractTableNameMatch = error.message.match( + / relation "(\w+)" does not exist/i + ); + if (extractTableNameMatch && extractTableNameMatch[1]) { + message = `The table '${extractTableNameMatch[1]}' does not exist.`; + type = DBError.TABLE_NOT_EXIST; + extra = { + table: extractTableNameMatch[1], + }; + } + } + break; + case '42703': + message = 'The column does not exist.'; + if (error.message) { + const extractTableNameMatch = error.message.match( + / column "(\w+)" does not exist/i + ); + if (extractTableNameMatch && extractTableNameMatch[1]) { + message = `The column '${extractTableNameMatch[1]}' does not exist.`; + type = DBError.COLUMN_NOT_EXIST; + extra = { + column: extractTableNameMatch[1], + }; + } + } + break; + // mssql errors + case 'EREQUEST': + message = 'There was a syntax error in your SQL query.'; + if (error.message) { + const extractTableNameMatch = error.message.match( + / There is already an object named '(\w+)' in the database/i + ); + const extractDupColMatch = error.message.match( + / Column name '(\w+)' in table '(\w+)' is specified more than once/i + ); + const extractMissingTableMatch = error.message.match( + / Invalid object name '(\w+)'./i + ); + const extractMissingColMatch = error.message.match( + / Invalid column name '(\w+)'./i + ); + + if (extractTableNameMatch && extractTableNameMatch[1]) { + message = `The table '${extractTableNameMatch[1]}' already exists.`; + type = DBError.TABLE_EXIST; + extra = { + table: extractTableNameMatch[1], + }; + } else if (extractDupColMatch && extractDupColMatch[1]) { + message = `The column '${extractDupColMatch[1]}' already exists.`; + type = DBError.COLUMN_EXIST; + extra = { + column: extractDupColMatch[1], + }; + } else if (extractMissingTableMatch && extractMissingTableMatch[1]) { + message = `The table '${extractMissingTableMatch[1]}' does not exist`; + type = DBError.TABLE_NOT_EXIST; + extra = { + table: extractMissingTableMatch[1], + }; + } else if (extractMissingColMatch && extractMissingColMatch[1]) { + message = `The column '${extractMissingColMatch[1]}' does not exist`; + type = DBError.COLUMN_NOT_EXIST; + extra = { + column: extractMissingColMatch[1], + }; + } + } + break; + case 'ELOGIN': + message = 'You do not have permission to perform this action.'; + break; + case 'ETIMEOUT': + message = 'A timeout occurred while waiting for a table lock.'; + break; + case 'ECONNRESET': + message = 'The connection was reset.'; + break; + case 'ECONNREFUSED': + message = 'The connection was refused.'; + break; + case 'EHOSTUNREACH': + message = 'The host is unreachable.'; + break; + case 'EHOSTDOWN': + message = 'The host is down.'; + break; + } + + if (message) { + return { + message, + type, + extra, + info: { message: error.message, code: error.code }, + }; + } +} + +export default function ( + requestHandler: (req: any, res: any, next?: any) => any +) { + return async function (req: any, res: any, next: any) { + try { + return await requestHandler(req, res, next); + } catch (e) { + // todo: error log + console.log(requestHandler.name ? `${requestHandler.name} ::` : '', e); + + const dbError = extractDBError(e); + + if (dbError) { + return res.status(400).json(dbError); + } + + if (e instanceof BadRequest) { + return res.status(400).json({ msg: e.message }); + } else if (e instanceof Unauthorized) { + return res.status(401).json({ msg: e.message }); + } else if (e instanceof Forbidden) { + return res.status(403).json({ msg: e.message }); + } else if (e instanceof NotFound) { + return res.status(404).json({ msg: e.message }); + } else if (e instanceof InternalServerError) { + return res.status(500).json({ msg: e.message }); + } else if (e instanceof NotImplemented) { + return res.status(501).json({ msg: e.message }); + } else if (e instanceof AjvError) { + return res.status(400).json({ msg: e.message, errors: e.errors }); + } + next(e); + } + }; +} + +class BadRequest extends Error {} + +class Unauthorized extends Error {} + +class Forbidden extends Error {} + +class NotFound extends Error {} + +class InternalServerError extends Error {} + +class NotImplemented extends Error {} + +class AjvError extends Error { + constructor(param: { message: string; errors: ErrorObject[] }) { + super(param.message); + this.errors = param.errors; + } + + errors: ErrorObject[]; +} + +export class NcError { + static notFound(message = 'Not found') { + throw new NotFound(message); + } + + static badRequest(message) { + throw new BadRequest(message); + } + + static unauthorized(message) { + throw new Unauthorized(message); + } + + static forbidden(message) { + throw new Forbidden(message); + } + + static internalServerError(message = 'Internal server error') { + throw new InternalServerError(message); + } + + static notImplemented(message = 'Not implemented') { + throw new NotImplemented(message); + } + + static ajvValidationError(param: { message: string; errors: ErrorObject[] }) { + throw new AjvError(param); + } +} diff --git a/packages/nocodb-nest/src/middlewares/extract-project-id/extract-project-id.middleware.ts b/packages/nocodb-nest/src/middlewares/extract-project-id/extract-project-id.middleware.ts new file mode 100644 index 0000000000..c24a517479 --- /dev/null +++ b/packages/nocodb-nest/src/middlewares/extract-project-id/extract-project-id.middleware.ts @@ -0,0 +1,306 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, + NestMiddleware, + SetMetadata, + UseInterceptors, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { NextFunction, Request, Response } from 'express'; +import { OrgUserRoles } from 'nocodb-sdk'; +import passport from 'passport'; +import { map, Observable, throwError } from 'rxjs'; +import { promisify } from 'util'; +import { + Column, + Filter, + FormViewColumn, + GalleryViewColumn, + GridViewColumn, + Hook, + Model, + Project, + Sort, + View, +} from '../../models'; +import projectAcl from '../../utils/projectAcl'; +import catchError, { NcError } from '../catchError'; +import extractProjectIdAndAuthenticate from '../extractProjectIdAndAuthenticate'; + +@Injectable() +export class ExtractProjectIdMiddleware implements NestInterceptor { + constructor(private reflector: Reflector) {} + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const req = context.switchToHttp().getRequest(); + const res = context.switchToHttp().getResponse(); + req.customProperty = 'This is a custom property'; + + try { + const { params } = req; + + // extract project id based on request path params + if (params.projectName) { + const project = await Project.getByTitleOrId(params.projectName); + req.ncProjectId = project.id; + res.locals.project = project; + } + if (params.projectId) { + req.ncProjectId = params.projectId; + } else if (req.query.project_id) { + req.ncProjectId = req.query.project_id; + } else if ( + params.tableId || + req.query.fk_model_id || + req.body?.fk_model_id + ) { + const model = await Model.getByIdOrName({ + id: params.tableId || req.query?.fk_model_id || req.body?.fk_model_id, + }); + req.ncProjectId = model?.project_id; + } else if (params.viewId) { + const view = + (await View.get(params.viewId)) || (await Model.get(params.viewId)); + req.ncProjectId = view?.project_id; + } else if ( + params.formViewId || + params.gridViewId || + params.kanbanViewId || + params.galleryViewId + ) { + const view = await View.get( + params.formViewId || + params.gridViewId || + params.kanbanViewId || + params.galleryViewId, + ); + req.ncProjectId = view?.project_id; + } else if (params.publicDataUuid) { + const view = await View.getByUUID(req.params.publicDataUuid); + req.ncProjectId = view?.project_id; + } else if (params.hookId) { + const hook = await Hook.get(params.hookId); + req.ncProjectId = hook?.project_id; + } else if (params.gridViewColumnId) { + const gridViewColumn = await GridViewColumn.get( + params.gridViewColumnId, + ); + req.ncProjectId = gridViewColumn?.project_id; + } else if (params.formViewColumnId) { + const formViewColumn = await FormViewColumn.get( + params.formViewColumnId, + ); + req.ncProjectId = formViewColumn?.project_id; + } else if (params.galleryViewColumnId) { + const galleryViewColumn = await GalleryViewColumn.get( + params.galleryViewColumnId, + ); + req.ncProjectId = galleryViewColumn?.project_id; + } else if (params.columnId) { + const column = await Column.get({ colId: params.columnId }); + req.ncProjectId = column?.project_id; + } else if (params.filterId) { + const filter = await Filter.get(params.filterId); + req.ncProjectId = filter?.project_id; + } else if (params.filterParentId) { + const filter = await Filter.get(params.filterParentId); + req.ncProjectId = filter?.project_id; + } else if (params.sortId) { + const sort = await Sort.get(params.sortId); + req.ncProjectId = sort?.project_id; + } + + // const user = await new Promise((resolve, _reject) => { + // passport.authenticate( + // 'jwt', + // { session: false }, + // (_err, user, _info) => { + // if (user && !req.headers['xc-shared-base-id']) { + // if ( + // req.path.indexOf('/user/me') === -1 && + // req.header('xc-preview') && + // /(?:^|,)(?:owner|creator)(?:$|,)/.test(user.roles) + // ) { + // return resolve({ + // ...user, + // isAuthorized: true, + // roles: req.header('xc-preview'), + // }); + // } + // + // return resolve({ ...user, isAuthorized: true }); + // } + // + // if (req.headers['xc-token']) { + // passport.authenticate( + // 'authtoken', + // { + // session: false, + // optional: false, + // } as any, + // (_err, user, _info) => { + // // if (_err) return reject(_err); + // if (user) { + // return resolve({ + // ...user, + // isAuthorized: true, + // roles: + // user.roles === 'owner' ? 'owner,creator' : user.roles, + // }); + // } else { + // resolve({ roles: 'guest' }); + // } + // }, + // )(req, res, next); + // } else if (req.headers['xc-shared-base-id']) { + // passport.authenticate('baseView', {}, (_err, user, _info) => { + // // if (_err) return reject(_err); + // if (user) { + // return resolve({ + // ...user, + // isAuthorized: true, + // isPublicBase: true, + // }); + // } else { + // resolve({ roles: 'guest' }); + // } + // })(req, res, next); + // } else { + // resolve({ roles: 'guest' }); + // } + // }, + // )(req, res, next); + // }); + // + // await promisify((req as any).login.bind(req))(user); + } catch (e) { + console.log(e); + return throwError(new Error('Internal error')); + } + + return next.handle().pipe( + map((data) => { + return data; + }), + ); + } + + // async use(req: any, res: any, next: () => void) { + // const customValue = this.reflector.get( + // 'customValue', + // req.route?.stack[0].handle, + // ); + // + // + // } +} + +@Injectable() +export class AclMiddleware implements NestInterceptor { + constructor(private reflector: Reflector) {} + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const permissionName = this.reflector.get( + 'permission', + context.getHandler(), + ); + const allowedRoles = this.reflector.get<(OrgUserRoles | string)[]>( + 'allowedRoles', + context.getHandler(), + ); + const blockApiTokenAccess = this.reflector.get( + 'blockApiTokenAccess', + context.getHandler(), + ); + + const req = context.switchToHttp().getRequest(); + const res = context.switchToHttp().getResponse(); + req.customProperty = 'This is a custom property'; + + const roles = req.user.roles.split(',').reduce((acc, role) => { + acc[role] = true; + return acc; + }, {}); + + if (req?.session?.passport?.user?.is_api_token && blockApiTokenAccess) { + NcError.forbidden('Not allowed with API token'); + } + if ( + (!allowedRoles || allowedRoles.some((role) => roles?.[role])) && + !( + roles?.creator || + roles?.owner || + roles?.editor || + roles?.viewer || + roles?.commenter || + roles?.[OrgUserRoles.SUPER_ADMIN] || + roles?.[OrgUserRoles.CREATOR] || + roles?.[OrgUserRoles.VIEWER] + ) + ) { + NcError.unauthorized('Unauthorized access'); + } + // todo : verify user have access to project or not + + const isAllowed = + roles && + Object.entries(roles).some(([name, hasRole]) => { + return ( + hasRole && + projectAcl[name] && + (projectAcl[name] === '*' || + (projectAcl[name].exclude && + !projectAcl[name].exclude[permissionName]) || + (projectAcl[name].include && + projectAcl[name].include[permissionName])) + ); + }); + if (!isAllowed) { + NcError.forbidden( + `${permissionName} - ${Object.keys(roles).filter( + (k) => roles[k], + )} : Not allowed`, + ); + } + + return next.handle().pipe( + map((data) => { + return data; + }), + ); + } +} + +export const UseProjectIdMiddleware = () => (target: any, key?: string, descriptor?: PropertyDescriptor) => { + UseInterceptors(ExtractProjectIdMiddleware)(target, key, descriptor); + }; + +export const UseAclMiddleware = + ({ + permissionName, + allowedRoles, + blockApiTokenAccess, + }: { + permissionName: string; + allowedRoles?: (OrgUserRoles | string)[]; + blockApiTokenAccess?: boolean; + }) => + (target: any, key?: string, descriptor?: PropertyDescriptor) => { + SetMetadata('permission', permissionName)(target, key, descriptor); + SetMetadata('allowedRoles', allowedRoles)(target, key, descriptor); + SetMetadata('blockApiTokenAccess', blockApiTokenAccess)( + target, + key, + descriptor, + ); + UseInterceptors(ExtractProjectIdMiddleware)(target, key, descriptor); + UseInterceptors(AclMiddleware)(target, key, descriptor); + }; diff --git a/packages/nocodb-nest/src/middlewares/extractProjectIdAndAuthenticate.ts b/packages/nocodb-nest/src/middlewares/extractProjectIdAndAuthenticate.ts new file mode 100644 index 0000000000..998779cadd --- /dev/null +++ b/packages/nocodb-nest/src/middlewares/extractProjectIdAndAuthenticate.ts @@ -0,0 +1,152 @@ + +import { promisify } from 'util'; +import passport from 'passport'; + +import { + Model, + View, + Hook, + GridViewColumn, + FormViewColumn, + GalleryViewColumn, + Project, + Column, + Filter, + Sort, +} from '../models'; + +export default async (req, res, next) => { + try { + const { params } = req; + + // extract project id based on request path params + if (params.projectName) { + const project = await Project.getByTitleOrId(params.projectName); + req.ncProjectId = project.id; + res.locals.project = project; + } + if (params.projectId) { + req.ncProjectId = params.projectId; + } else if (req.query.project_id) { + req.ncProjectId = req.query.project_id; + } else if ( + params.tableId || + req.query.fk_model_id || + req.body?.fk_model_id + ) { + const model = await Model.getByIdOrName({ + id: params.tableId || req.query?.fk_model_id || req.body?.fk_model_id, + }); + req.ncProjectId = model?.project_id; + } else if (params.viewId) { + const view = + (await View.get(params.viewId)) || (await Model.get(params.viewId)); + req.ncProjectId = view?.project_id; + } else if ( + params.formViewId || + params.gridViewId || + params.kanbanViewId || + params.galleryViewId + ) { + const view = await View.get( + params.formViewId || + params.gridViewId || + params.kanbanViewId || + params.galleryViewId, + ); + req.ncProjectId = view?.project_id; + } else if (params.publicDataUuid) { + const view = await View.getByUUID(req.params.publicDataUuid); + req.ncProjectId = view?.project_id; + } else if (params.hookId) { + const hook = await Hook.get(params.hookId); + req.ncProjectId = hook?.project_id; + } else if (params.gridViewColumnId) { + const gridViewColumn = await GridViewColumn.get(params.gridViewColumnId); + req.ncProjectId = gridViewColumn?.project_id; + } else if (params.formViewColumnId) { + const formViewColumn = await FormViewColumn.get(params.formViewColumnId); + req.ncProjectId = formViewColumn?.project_id; + } else if (params.galleryViewColumnId) { + const galleryViewColumn = await GalleryViewColumn.get( + params.galleryViewColumnId, + ); + req.ncProjectId = galleryViewColumn?.project_id; + } else if (params.columnId) { + const column = await Column.get({ colId: params.columnId }); + req.ncProjectId = column?.project_id; + } else if (params.filterId) { + const filter = await Filter.get(params.filterId); + req.ncProjectId = filter?.project_id; + } else if (params.filterParentId) { + const filter = await Filter.get(params.filterParentId); + req.ncProjectId = filter?.project_id; + } else if (params.sortId) { + const sort = await Sort.get(params.sortId); + req.ncProjectId = sort?.project_id; + } + + const user = await new Promise((resolve, _reject) => { + passport.authenticate('jwt', { session: false }, (_err, user, _info) => { + if (user && !req.headers['xc-shared-base-id']) { + if ( + req.path.indexOf('/user/me') === -1 && + req.header('xc-preview') && + /(?:^|,)(?:owner|creator)(?:$|,)/.test(user.roles) + ) { + return resolve({ + ...user, + isAuthorized: true, + roles: req.header('xc-preview'), + }); + } + + return resolve({ ...user, isAuthorized: true }); + } + + if (req.headers['xc-token']) { + passport.authenticate( + 'authtoken', + { + session: false, + optional: false, + } as any, + (_err, user, _info) => { + // if (_err) return reject(_err); + if (user) { + return resolve({ + ...user, + isAuthorized: true, + roles: user.roles === 'owner' ? 'owner,creator' : user.roles, + }); + } else { + resolve({ roles: 'guest' }); + } + }, + )(req, res, next); + } else if (req.headers['xc-shared-base-id']) { + passport.authenticate('baseView', {}, (_err, user, _info) => { + // if (_err) return reject(_err); + if (user) { + return resolve({ + ...user, + isAuthorized: true, + isPublicBase: true, + }); + } else { + resolve({ roles: 'guest' }); + } + })(req, res, next); + } else { + resolve({ roles: 'guest' }); + } + })(req, res, next); + }); + + await promisify((req as any).login.bind(req))(user); + next(); + } catch (e) { + console.log(e); + next(new Error('Internal error')); + } +}; diff --git a/packages/nocodb-nest/src/middlewares/ncMetaAclMw.ts b/packages/nocodb-nest/src/middlewares/ncMetaAclMw.ts new file mode 100644 index 0000000000..d2db45acfc --- /dev/null +++ b/packages/nocodb-nest/src/middlewares/ncMetaAclMw.ts @@ -0,0 +1,96 @@ +import { OrgUserRoles } from 'nocodb-sdk'; +import projectAcl from '../utils/projectAcl'; +import catchError, { NcError } from './catchError'; +import extractProjectIdAndAuthenticate from './extractProjectIdAndAuthenticate'; +import type { NextFunction, Request, Response } from 'express'; + +export default function ( + handlerFn, + permissionName, + { + allowedRoles, + blockApiTokenAccess, + }: { + allowedRoles?: (OrgUserRoles | string)[]; + blockApiTokenAccess?: boolean; + } = {} +) { + return [ + extractProjectIdAndAuthenticate, + catchError(async function authMiddleware(req, _res, next) { + const roles = req?.session?.passport?.user?.roles; + if (req?.session?.passport?.user?.is_api_token && blockApiTokenAccess) { + NcError.forbidden('Not allowed with API token'); + } + if ( + (!allowedRoles || allowedRoles.some((role) => roles?.[role])) && + !( + roles?.creator || + roles?.owner || + roles?.editor || + roles?.viewer || + roles?.commenter || + roles?.[OrgUserRoles.SUPER_ADMIN] || + roles?.[OrgUserRoles.CREATOR] || + roles?.[OrgUserRoles.VIEWER] + ) + ) { + NcError.unauthorized('Unauthorized access'); + } + next(); + }), + // @ts-ignore + catchError(async function projectAclMiddleware( + req: Request, + _res: Response, + next: NextFunction + ) { + // if (req['files'] && req.body.json) { + // req.body = JSON.parse(req.body.json); + // } + // if (req['session']?.passport?.user?.isAuthorized) { + // if ( + // req?.body?.project_id && + // !req['session']?.passport?.user?.isPublicBase && + // !(await this.xcMeta.isUserHaveAccessToProject( + // req?.body?.project_id, + // req['session']?.passport?.user?.id + // )) + // ) { + // return res + // .status(403) + // .json({ msg: "User doesn't have project access" }); + // } + // + // if (req?.body?.api) { + + // todo : verify user have access to project or not + + const roles = req['session']?.passport?.user?.roles; + const isAllowed = + roles && + Object.entries(roles).some(([name, hasRole]) => { + return ( + hasRole && + projectAcl[name] && + (projectAcl[name] === '*' || + (projectAcl[name].exclude && + !projectAcl[name].exclude[permissionName]) || + (projectAcl[name].include && + projectAcl[name].include[permissionName])) + ); + }); + if (!isAllowed) { + NcError.forbidden( + `${permissionName} - ${Object.keys(roles).filter( + (k) => roles[k] + )} : Not allowed` + ); + } + // } + // } + next(); + }), + catchError(handlerFn), + ]; +} diff --git a/packages/nocodb-nest/src/models/Base.ts b/packages/nocodb-nest/src/models/Base.ts index 4a474f9d3e..c5c783d6f4 100644 --- a/packages/nocodb-nest/src/models/Base.ts +++ b/packages/nocodb-nest/src/models/Base.ts @@ -57,9 +57,13 @@ export default class Base implements BaseType { 'order', 'enabled', ]); + + const secret = + Noco.getConfig()?.auth?.jwt?.secret; + insertObj.config = CryptoJS.AES.encrypt( JSON.stringify(base.config), - Noco.getConfig()?.auth?.jwt?.secret + secret ).toString(); const { id } = await ncMeta.metaInsert2( diff --git a/packages/nocodb-nest/src/nocobuild.ts b/packages/nocodb-nest/src/nocobuild.ts index f080726f07..bedb29833d 100644 --- a/packages/nocodb-nest/src/nocobuild.ts +++ b/packages/nocodb-nest/src/nocobuild.ts @@ -1,6 +1,6 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; -import * as express from 'express'; +import express from 'express'; export default async function(app = express()) { const nestApp = await NestFactory.create(AppModule); diff --git a/packages/nocodb-nest/src/projects/projects.controller.ts b/packages/nocodb-nest/src/projects/projects.controller.ts index 34aad490fb..86c9d5333d 100644 --- a/packages/nocodb-nest/src/projects/projects.controller.ts +++ b/packages/nocodb-nest/src/projects/projects.controller.ts @@ -13,6 +13,12 @@ import { import { AuthGuard } from '@nestjs/passport'; import isDocker from 'is-docker'; import { ProjectReqType } from 'nocodb-sdk'; +import { PagedResponseImpl } from 'src/helpers/PagedResponse'; +import { ProjectType } from '../../../nocodb-sdk' +import { + UseAclMiddleware, + UseProjectIdMiddleware, +} from '../middlewares/extract-project-id/extract-project-id.middleware' import Noco from '../Noco'; import { packageVersion } from '../utils/packageVersion'; import { ProjectsService } from './projects.service'; @@ -22,12 +28,19 @@ import { ProjectsService } from './projects.service'; export class ProjectsController { constructor(private readonly projectsService: ProjectsService) {} + @UseAclMiddleware({ + permissionName: 'projectList' + }) @Get('/api/v1/db/meta/projects/') - list(@Query() queryParams: Record, @Request() req) { - return this.projectsService.list({ + async list(@Query() queryParams: Record, @Request() req) { + const projects = await this.projectsService.list({ user: req.user, query: queryParams, }); + return new PagedResponseImpl(projects as ProjectType[], { + count: projects.length, + limit: projects.length, + }) } @Get('/api/v1/db/meta/projects/:projectId/info') diff --git a/packages/nocodb-nest/src/projects/projects.module.ts b/packages/nocodb-nest/src/projects/projects.module.ts index 705a964ad1..8e6e382ed5 100644 --- a/packages/nocodb-nest/src/projects/projects.module.ts +++ b/packages/nocodb-nest/src/projects/projects.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; +import { ExtractProjectIdMiddleware } from '../middlewares/extract-project-id/extract-project-id.middleware' import { ProjectsService } from './projects.service'; import { ProjectsController } from './projects.controller'; @Module({ controllers: [ProjectsController], - providers: [ProjectsService] + providers: [ProjectsService, ExtractProjectIdMiddleware] }) export class ProjectsModule {} diff --git a/packages/nocodb-nest/src/projects/projects.service.ts b/packages/nocodb-nest/src/projects/projects.service.ts index ccfe831932..1b38e2d1cf 100644 --- a/packages/nocodb-nest/src/projects/projects.service.ts +++ b/packages/nocodb-nest/src/projects/projects.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import DOMPurify from 'isomorphic-dompurify'; +import * as DOMPurify from 'isomorphic-dompurify'; import { customAlphabet } from 'nanoid'; import { ProjectReqType } from 'nocodb-sdk'; import { promisify } from 'util'; diff --git a/packages/nocodb-nest/src/strategies/jwt.strategy.ts b/packages/nocodb-nest/src/strategies/jwt.strategy.ts index b2466aa78b..4b617d0b16 100644 --- a/packages/nocodb-nest/src/strategies/jwt.strategy.ts +++ b/packages/nocodb-nest/src/strategies/jwt.strategy.ts @@ -1,15 +1,18 @@ import { Injectable, UnauthorizedException } from '@nestjs/common' import { PassportStrategy } from '@nestjs/passport'; import { Strategy, ExtractJwt } from 'passport-jwt'; +import { jwtConstants } from '../auth/constants' import { UsersService } from '../users/users.service' @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor(private userService: UsersService) { super({ - ignoreExpiration: false, + // ignoreExpiration: false, jwtFromRequest: ExtractJwt.fromHeader('xc-auth'), - }); + secretOrKey: jwtConstants.secret, + expiresIn: '10h' + }) } async validate(payload: any) { diff --git a/packages/nocodb-nest/src/users/users.controller.ts b/packages/nocodb-nest/src/users/users.controller.ts index 527b7a7504..033c1fcbf8 100644 --- a/packages/nocodb-nest/src/users/users.controller.ts +++ b/packages/nocodb-nest/src/users/users.controller.ts @@ -1,7 +1,13 @@ -import { Controller } from '@nestjs/common'; +import { Controller, Get } from '@nestjs/common'; import { UsersService } from './users.service'; -@Controller('users') +@Controller() export class UsersController { constructor(private readonly usersService: UsersService) {} + +@Get('/api/v1/auth/user/me') + async me() { + // return this.usersService.me(); + } + } diff --git a/packages/nocodb-nest/src/utils/NcConfigFactory.ts b/packages/nocodb-nest/src/utils/NcConfigFactory.ts index 56d326da6a..da9d12d768 100644 --- a/packages/nocodb-nest/src/utils/NcConfigFactory.ts +++ b/packages/nocodb-nest/src/utils/NcConfigFactory.ts @@ -100,7 +100,7 @@ export default class NcConfigFactory { ncConfig.auth = { jwt: { - secret: process.env.NC_AUTH_JWT_SECRET, + secret: process.env.NC_AUTH_JWT_SECRET ?? 'temporary-key', }, }; @@ -421,7 +421,7 @@ export default class NcConfigFactory { if (process.env.NC_AUTH_ADMIN_SECRET) { config.auth = { masterKey: { - secret: process.env.NC_AUTH_ADMIN_SECRET, + secret: process.env.NC_AUTH_ADMIN_SECRET ?? 'temporary-key', }, }; } else if (process.env.NC_NO_AUTH) { @@ -436,7 +436,7 @@ export default class NcConfigFactory { dbAlias: process.env.NC_AUTH_JWT_DB_ALIAS || config.envs['_noco'].db[0].meta.dbAlias, - secret: process.env.NC_AUTH_JWT_SECRET, + secret: process.env.NC_AUTH_JWT_SECRET ?? 'temporary-key', }, }; } @@ -536,7 +536,7 @@ export default class NcConfigFactory { if (process.env.NC_AUTH_ADMIN_SECRET) { config.auth = { masterKey: { - secret: process.env.NC_AUTH_ADMIN_SECRET, + secret: process.env.NC_AUTH_ADMIN_SECRET ?? 'temporary-key', }, }; } else if (process.env.NC_NO_AUTH) { @@ -551,7 +551,7 @@ export default class NcConfigFactory { dbAlias: process.env.NC_AUTH_JWT_DB_ALIAS || config.envs['_noco'].db[0].meta.dbAlias, - secret: process.env.NC_AUTH_JWT_SECRET, + secret: process.env.NC_AUTH_JWT_SECRET ?? 'temporary-key', }, }; } diff --git a/packages/nocodb-nest/tsconfig.json b/packages/nocodb-nest/tsconfig.json index 7a64609354..13e35e61ea 100644 --- a/packages/nocodb-nest/tsconfig.json +++ b/packages/nocodb-nest/tsconfig.json @@ -17,6 +17,7 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, - "resolveJsonModule": true + "resolveJsonModule": true, + "esModuleInterop": true } }