From 24287968ab73d2bbd07fc1ec79bba6877f634b6d Mon Sep 17 00:00:00 2001 From: Andrew Slack Date: Sun, 1 Sep 2024 13:37:40 +0200 Subject: [PATCH] prep work --- .github/workflows/pr.yml | 52 +++++++++++++++++++ .github/workflows/release.yml | 4 ++ .vscode/tasks.json | 21 ++++++++ README.md | 59 +++++++++++++++------- docker-compose.yml | 1 - package.json | 1 + src/app.controller.get.ts | 62 ++++++++++++++++------- src/app.module.ts | 14 ++++-- src/app.service.find.ts | 6 +-- src/config/auth.config.ts | 4 +- src/config/hosts.config.ts | 9 ++++ src/databases/mysql.database.ts | 88 ++++++++++++++++----------------- src/helpers/Authentication.ts | 54 +++++++++++++++++--- src/helpers/Logger.ts | 8 +-- src/helpers/Pagination.ts | 6 +-- src/helpers/Query.ts | 18 +++---- src/helpers/Schema.ts | 54 +++++++++----------- src/main.ts | 3 +- src/middleware/HostCheck.ts | 37 ++++++++++++++ src/types/auth.types.ts | 5 +- src/types/database.types.ts | 9 ++-- src/types/restrictions.types.ts | 1 - src/types/schema.types.ts | 22 ++++++--- 23 files changed, 372 insertions(+), 166 deletions(-) create mode 100644 .github/workflows/pr.yml create mode 100644 .vscode/tasks.json create mode 100644 src/config/hosts.config.ts create mode 100644 src/middleware/HostCheck.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..837cc29 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,52 @@ +# +# GitHub Actions workflow. +# +# Perfoms the following actions on a pull request: +# * Checkout the code +# * Install Node.js +# * Prepare the environment +# * Install dependencies +# * Lint the code +# * Run the tests +# * Confirm the build runs +# + +name: 'PR Checks: Llana' + +on: + pull_request: + branches: + - main + workflow_dispatch: + workflow_call: + +jobs: + pr_checks: + name: 'Pull Request Package: Llana' + runs-on: ubuntu-latest + steps: + + - name: 'Checkout' + uses: actions/checkout@v4 + with: + token: ${{ secrets.GH_CI_CD_RELEASE }} + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 18.18.2 + + - name: Prepare + run: echo -e "shamefully-hoist=true\\n@fortawesome:registry=https://npm.fontawesome.com/\n//npm.fontawesome.com/:_authToken=${{ secrets.FONTAWESOME_AUTH_TOKEN }}" > .npmrc + + - name: Install dependencies + run: npm install + + - name: Lint + run: npm run lint + + - name: Build Docker Image + run: npm run start:docker + + - name: Test + run: npm run test \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 60e3e13..1e58742 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,10 @@ # GitHub Actions workflow. # # Releases the package to npm when a push into main is detected. +# * Checkout the code +# * Install Node.js +# * Install dependencies +# * Pull the latest changes # * Bump version number # * Release to NPM # diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..201cd15 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,21 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Start Docker", + "type": "shell", + "command": "npm run start:docker", + "windows": { + "command": "npm run start:docker" + }, + "group": "none", + "presentation": { + "reveal": "always", + "panel": "new" + }, + // "runOptions": { + // "runOn": "folderOpen", + // } + }, + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 74ef8de..14edb15 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -
+ JuicyLlama Logo Visit [JuicyLlama > Tools > Llana](https://juicyllama.com/tools/llana) for full installation instructions, documentation and community. -## Database Support +## Databases -We currently support the following databases: +We are working to support all major databases, if you would like to contribute to the open source project and help integrate your faviourt database, checkout our [contribiution guidelines](https://juicyllama.com/developers/contributing). - [ ] [ORACLE](https://expressjs.com/en/guide/database-integration.html#oracle) (Help Wanted) -- [ ] [MYSQL](https://expressjs.com/en/guide/database-integration.html#mysql) (In Progress) +- [x] [MYSQL](https://expressjs.com/en/guide/database-integration.html#mysql) (In Progress) - [ ] MSSQL (Help Wanted) - [ ] [POSTGRES](https://expressjs.com/en/guide/database-integration.html#postgresql) (Help Wanted) - [ ] [MONGODB](https://expressjs.com/en/guide/database-integration.html#mongodb) (Help Wanted) @@ -21,14 +21,14 @@ We currently support the following databases: - [ ] [CASSANDRA](https://expressjs.com/en/guide/database-integration.html#cassandra) (Help Wanted) - [ ] MARIADB (Help Wanted) -## TODO: +[See the complete breakdown of which databases are integrated with which endpoints](#database-support) -- [ ] Auto detect relation {relation}.{col} in fields +## TODO: - [ ] Authentication - - [ ] Support hosts?[] to restrict access by url - - [ ] Support identity column (in role/permissions) - - [ ] Get key table relation working `users_api_keys` + + - [ ] Auth testing files (hosts, apikey) + - [ ] Role based default permissions, e.g. ``` @@ -39,7 +39,11 @@ We currently support the following databases: }] ``` - - [ ] integrate JWT token support + - [ ] Auth testing (role based access) + + - [ ] integrate JWT token support + /login endpoint + + - [ ] Auth testing (user/pass, login) - [ ] Add Demo Database data to Docker setup file @@ -50,7 +54,10 @@ We currently support the following databases: role: {role_string}, own_records: READ | WRITE | DELETE, other_records: READ | WRITE | DELETE, - identifier_restrictions: [{ + identifer_route: 'user_id', // also supports clients.user_clients.user_id + +//replaced by identifer_route if possible? + }] }] @@ -70,7 +77,7 @@ We currently support the following databases: - [ ] add full testing -- [ ] move docs to JL Website +- [ ] Move these docs to juicyllama.com/llana, landing page + docs - [ ] Add redis support for faster performance (e.g. schema caching) @@ -78,15 +85,23 @@ We currently support the following databases: - [ ] use on first external client project +- [ ] move remaining items to github issues + - [ ] Adding more database integrations (postgres, etc) -- [ ] Build integrations with workflow automation tooling (n8n, zapier, make, etc) +- [ ] Scope Llana cloud option for non-technical users + +- [ ] Scope out Setup / Install service (pay to deploy on your database) + +#### Marketing + +- [ ] Build integrations with workflow automation tooling (n8n, zapier, make, etc) and promote on their platforms where possible - [ ] Publish on Daily.dev, ProductHunt, etc -- [ ] Scope Llana cloud option for non-technical users +- [ ] Commend on Medium posts like: https://javascript.plainenglish.io/my-tech-stack-for-building-web-apps-today-43398556bb4d introducing the plugin -- [ ] Setup / Install service (pay to deploy on your database) +- [ ] Basic PPC campaign ## Installation @@ -281,4 +296,14 @@ Example Response: Out of the box you can use our docker demo data to play with the system. Here is some helpful information: -Test user, email: `test@test.com` password: `test` \ No newline at end of file +Test user, email: `test@test.com` password: `test` with API key: `Ex@mp1eS$Cu7eAp!K3y` + + + +## Database Support + +| Endpoint | ORACLE | MYSQL | MSSQL | POSTGRES | MONGODB | REDIS | SNOWFLAKE | ELASTICSEARCH | SQLITE | CASSANDRA | MARIADB | +|---|-|-|-|-|-|-|-|-|-|-|-| +|GET */:id` |[ ]|[x]|[ ]|[ ]|[ ]|[ ]|[ ]|[ ]|[ ]|[ ]|[ ]| +|POST */` |[ ]|[x]|[ ]|[ ]|[ ]|[ ]|[ ]|[ ]|[ ]|[ ]|[ ]| +|POST */list` |[ ]|[x]|[ ]|[ ]|[ ]|[ ]|[ ]|[ ]|[ ]|[ ]|[ ]| diff --git a/docker-compose.yml b/docker-compose.yml index 0e091af..ff32e4e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: "3.9" services: llana-mysql: image: mysql:8.0 diff --git a/package.json b/package.json index 03fc04e..3608044 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", + "start:docker": "docker compose --project-name llana up --build --detach", "test": "jest" }, "dependencies": { diff --git a/src/app.controller.get.ts b/src/app.controller.get.ts index 59f0187..2d85d63 100644 --- a/src/app.controller.get.ts +++ b/src/app.controller.get.ts @@ -1,6 +1,6 @@ import { Controller, Get, Param, Req, Res } from '@nestjs/common'; import { FindService } from './app.service.find'; -import { Logger, context } from './helpers/Logger'; +import { Logger } from './helpers/Logger'; import { UrlToTable } from './helpers/Database'; import { Schema } from './helpers/Schema'; import { GetResponseObject, ListResponseObject } from './types/response.types'; @@ -17,9 +17,7 @@ export class GetController { private readonly schema: Schema, private readonly sort: Sort, private readonly authentication: Authentication - ) { - logger.setContext(context) - } + ) {} @Get('') getDocs(@Res() res): string { @@ -50,7 +48,7 @@ export class GetController { const auth = await this.authentication.auth(req) if (!auth.valid) { - return res.status(403).send(auth.message) + return res.status(401).send(auth.message) } const { limit, offset } = this.pagination.get(req.query) @@ -63,19 +61,24 @@ export class GetController { } } + const relations = combineRelations(req.query.relations, validateFields?.relations) + const validateWhere = this.schema.validateWhereParams(schema, req.query) if (!validateWhere.valid) { return res.status(400).send(validateWhere.message) } let validateRelations - if (req.query.relations) { - validateRelations = await this.schema.validateRelations(schema, req.query.relations) + if (relations) { + validateRelations = await this.schema.validateRelations(schema, relations) + if (!validateRelations.valid) { return res.status(400).send(validateRelations.message) } - for (const relation of validateRelations.params) { + schema = validateRelations.schema + + for (const relation of relations) { const relation_schema = await this.schema.getSchema(relation) if (!relation_schema) { return res.status(400).send(`Relation ${relation} not found`) @@ -133,7 +136,7 @@ export class GetController { return res.status(200).send(await this.service.findMany({ schema, fields: req.query.fields, - relations: req.query.relations, + relations, where: validateWhere.where, limit, offset, @@ -156,7 +159,7 @@ export class GetController { const auth = await this.authentication.auth(req) if (!auth.valid) { - return res.status(403).send(auth.message) + return res.status(401).send(auth.message) } //validate :id field @@ -179,19 +182,23 @@ export class GetController { } } + const relations = combineRelations(req.query.relations, validateFields?.relations) + let validateRelations - if (req.query.relations) { - validateRelations = await this.schema.validateRelations(schema, req.query.relations) + if (relations) { + validateRelations = await this.schema.validateRelations(schema, relations) if (!validateRelations.valid) { return res.status(400).send(validateRelations.message) } + + schema = validateRelations.schema } return res.status(200).send(await this.service.findById({ schema, id: req.params.id, fields: req.query.fields, - relations: req.query.relations + relations })) } @@ -211,7 +218,7 @@ export class GetController { const auth = await this.authentication.auth(req) if (!auth.valid) { - return res.status(403).send(auth.message) + return res.status(401).send(auth.message) } let validateFields @@ -222,19 +229,23 @@ export class GetController { } } + const relations = combineRelations(req.query.relations, validateFields?.relations) + const validateWhere = this.schema.validateWhereParams(schema, req.query) if (!validateWhere.valid) { return res.status(400).send(validateWhere.message) } let validateRelations - if (req.query.relations) { - validateRelations = await this.schema.validateRelations(schema, req.query.relations) + if (relations) { + validateRelations = await this.schema.validateRelations(schema, relations) if (!validateRelations.valid) { return res.status(400).send(validateRelations.message) } - for (const relation of validateRelations.params) { + schema = validateRelations.schema + + for (const relation of relations) { const relation_schema = await this.schema.getSchema(relation) if (!relation_schema) { return res.status(400).send(`Relation ${relation} not found`) @@ -276,10 +287,25 @@ export class GetController { return res.status(200).send(await this.service.findOne({ schema, fields: req.query.fields, - relations: req.query.relations, + relations, where: validateWhere.where })) } } + +function combineRelations(query: string, validatedFieldRelations: string[]): string[] { + const relations = query ? query.split(',') : [] + + if (validatedFieldRelations) { + validatedFieldRelations.forEach(relation => { + if (!relations.includes(relation)) { + relations.push(relation) + } + }) + } + + return relations + +} diff --git a/src/app.module.ts b/src/app.module.ts index aa9bafc..48114bc 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { GetController } from './app.controller.get'; import { FindService } from './app.service.find'; @@ -8,19 +8,27 @@ import { Schema } from './helpers/Schema'; import { Authentication } from './helpers/Authentication'; import auth from './config/auth.config'; import database from './config/database.config'; +import hosts from './config/hosts.config'; import restrictions from './config/restrictions.config'; import { MySQL } from './databases/mysql.database'; import { Pagination } from './helpers/Pagination'; import { Sort } from './helpers/Sort'; +import { HostCheckMiddleware } from './middleware/HostCheck'; @Module({ imports: [ ConfigModule.forRoot({ - load: [auth, database, restrictions], + load: [auth, database, hosts, restrictions], }), ], controllers: [GetController], providers: [FindService, Authentication, Query, Schema, Pagination, Logger, Sort, MySQL], exports: [FindService, Authentication, Query, Schema, Pagination, Logger, Sort, MySQL], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(HostCheckMiddleware) + .forRoutes('*'); + } +} \ No newline at end of file diff --git a/src/app.service.find.ts b/src/app.service.find.ts index 75e30a2..6c41459 100644 --- a/src/app.service.find.ts +++ b/src/app.service.find.ts @@ -1,6 +1,5 @@ import { Injectable } from '@nestjs/common'; import { Query } from './helpers/Query'; -import { Logger, context } from './helpers/Logger'; import { GetResponseObject, ListResponseObject } from './types/response.types'; import { DatabaseFindByIdOptions, DatabaseFindManyOptions, DatabaseFindOneOptions } from './types/database.types'; @@ -8,10 +7,7 @@ import { DatabaseFindByIdOptions, DatabaseFindManyOptions, DatabaseFindOneOption export class FindService { constructor( private readonly query: Query, - private readonly logger: Logger, -){ - logger.setContext(context) -} +){} async findById(options: DatabaseFindByIdOptions): Promise { return await this.query.findById(options) diff --git a/src/config/auth.config.ts b/src/config/auth.config.ts index c4587bb..15ea42e 100644 --- a/src/config/auth.config.ts +++ b/src/config/auth.config.ts @@ -5,8 +5,7 @@ import { WhereOperator } from '../types/database.types' export default registerAs('auth', () => ({ api_key: { table: "users", - column: "api_key", - identity_column: "id", + column: "users_api_keys.api_key", where: [{ column: "deleted_at", operator: WhereOperator.null, @@ -15,7 +14,6 @@ export default registerAs('auth', () => ({ jwt_token: { table: "users", columns: {email: "email", password: "password"}, - identity_column: "id", password: { encryption: AuthPasswordEncryption.SHA512, salt: null, diff --git a/src/config/hosts.config.ts b/src/config/hosts.config.ts new file mode 100644 index 0000000..d64609b --- /dev/null +++ b/src/config/hosts.config.ts @@ -0,0 +1,9 @@ +import { registerAs } from '@nestjs/config' + +/** + * If you would like to globally lock down your API to specific hosts, you can add them here. + */ + +export default registerAs('hosts', () => ([ + //'localhost:3030', +])) \ No newline at end of file diff --git a/src/databases/mysql.database.ts b/src/databases/mysql.database.ts index e2d8718..b936306 100644 --- a/src/databases/mysql.database.ts +++ b/src/databases/mysql.database.ts @@ -2,7 +2,7 @@ import * as mysql from 'mysql2/promise'; import { Connection } from 'mysql2/promise'; import { Injectable } from '@nestjs/common' import { ConfigService } from '@nestjs/config' -import { context, Logger } from '../helpers/Logger' +import { Logger } from '../helpers/Logger' import { DatabaseFindByIdOptions, DatabaseFindManyOptions, DatabaseFindOneOptions, DatabaseFindTotalRecords, DatabaseSchema, DatabaseSchemaColumn, DatabaseWhere, WhereOperator } from '../types/database.types'; import { ListResponseObject } from '../types/response.types'; import { Pagination } from '../helpers/Pagination'; @@ -14,9 +14,7 @@ export class MySQL { private readonly configService: ConfigService, private readonly logger: Logger, private readonly pagination: Pagination - ) { - this.logger.setContext(context) - } + ) {} /** * Create a MySQL connection @@ -41,8 +39,7 @@ export class MySQL { message: e.message } }) - } - + } } /** @@ -69,8 +66,8 @@ export class MySQL { }; }); - const relations_result = await this.performQuery(`SELECT TABLE_NAME as 'table',COLUMN_NAME as 'column',CONSTRAINT_NAME as 'key' FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_NAME = '${table_name}'`) - + const relations_query = `SELECT TABLE_NAME as 'table',COLUMN_NAME as 'column',CONSTRAINT_NAME as 'key' FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_NAME = '${table_name}'` + const relations_result = await this.performQuery(relations_query) const relations = relations_result.filter((row: any) => row.table !== null).map((row: any) => ({ table: row.table, column: row.column, @@ -106,47 +103,30 @@ export class MySQL { async findOne(options: DatabaseFindOneOptions): Promise { - const table_name = options.schema.table - - const fields = options.fields?.split(',').filter(field => !field.includes('.')) - - const where = options.where?.filter(where => !where.column.includes('.')) - - let command = `SELECT ${table_name}.${fields?.length ? fields.join(`, ${table_name}.`) : '*'} ` - command += `FROM ${table_name} ` - - if(where?.length){ - command += `WHERE ${where.map(w => `${w.column} ${w.operator} ${w.value ? `'`+ w.value +`'` : ''}`).join(' AND ')} ` - } - + let command = this.find(options) command += ` LIMIT 1` const results = await this.performQuery(command) - return await this.addRelations(options, results[0]) + + if(!options.joins){ + return await this.addRelations(options, results[0]) + }else{ + return results[0] + } + } /** * Find multiple records */ - async findMany(options: DatabaseFindManyOptions): Promise { - - console.log(options) - + //TODO: support for joins - const table_name = options.schema.table + async findMany(options: DatabaseFindManyOptions): Promise { const total = await this.findTotalRecords(options) - const fields = options.fields?.split(',').filter(field => !field.includes('.')) - - let command = `SELECT ${table_name}.${fields?.length ? fields.join(`, ${table_name}.`) : '*'} FROM ${table_name} ` - - const where = options.where?.filter(where => !where.column.includes('.')) - - if(where?.length){ - command += `WHERE ${where.map(where => `${where.column} ${where.operator} ${where.value ? `'`+ where.value +`'` : ''}`).join(' AND ')} ` - } + let command = this.find(options) const sort = options.sort.filter(sort => !sort.column.includes('.')) @@ -181,6 +161,32 @@ export class MySQL { } + find(options: DatabaseFindOneOptions | DatabaseFindManyOptions): string { + + const table_name = options.schema.table + const primary_key = options.schema.primary_key + + const fields = options.joins ? options.fields?.split(',') : options.fields?.split(',').filter(field => !field.includes('.')) + const where = options.joins ? options.where : options.where?.filter(where => !where.column.includes('.')) + + let command = `SELECT ${fields?.length ? fields.join(`, ${table_name}.`) : '*'} ` + command += `FROM ${table_name} ` + + if(options.joins && options.relations?.length){ + for(const relation of options.relations){ + const schema_relation = options.schema.relations.find(r => r.table === relation) + command += `LEFT JOIN ${schema_relation.table} ON ${table_name}.${primary_key} = ${schema_relation.table}.${schema_relation.column} ` + } + } + + if(where?.length){ + command += `WHERE ${where.map(w => `${w.column} ${w.operator} ${w.value ? `'`+ w.value +`'` : ''}`).join(' AND ')} ` + } + + return command + + } + /** * Get total records with where conditions */ @@ -201,24 +207,20 @@ export class MySQL { return results[0].total } - - - async addRelations(options: DatabaseFindByIdOptions | DatabaseFindOneOptions | DatabaseFindManyOptions, result: any): Promise { if(!result) return {} - if(options.relations?.split(',').length){ + if(options.relations?.length){ const primary_key_id = result[options.schema.columns.find(column => column.primary_key).field] - for(const relation of options.relations.split(',')){ + for(const relation of options.relations){ const schema_relation = options.schema.relations.find(r => r.table === relation).schema const relation_table = options.schema.relations.find(r => r.table === relation) const fields = options.fields?.split(',').filter(field => field.includes(schema_relation.table+'.')) if(fields.length){ - // Remove table name from fields fields.forEach((field, index) => { fields[index] = field.replace(`${schema_relation.table}.`, '') }) @@ -270,8 +272,6 @@ export class MySQL { offset: 0 }) - console.log(relation_result) - result[relation] = relation_result.data } } diff --git a/src/helpers/Authentication.ts b/src/helpers/Authentication.ts index 2415d57..f70f6db 100644 --- a/src/helpers/Authentication.ts +++ b/src/helpers/Authentication.ts @@ -1,6 +1,6 @@ import { Injectable, Req } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { context, Logger } from "./Logger"; +import { Logger } from "./Logger"; import { Restriction, RestrictionLocation, RestrictionType } from "src/types/restrictions.types"; import { AuthAPIKey } from "src/types/auth.types"; import { Query } from "./Query"; @@ -14,9 +14,7 @@ export class Authentication { private readonly logger: Logger, private readonly query: Query, private readonly schema: Schema, - ) { - this.logger.setContext(context) - } + ) {} /** * Create entity schema from database schema @@ -148,6 +146,7 @@ export class Authentication { const api_key_config = this.configService.get('auth.api_key') if(!api_key_config || !api_key_config.table){ + this.logger.error(`[Authentication][auth] System configuration error: API Key lookup table not found`) auth_passed = { valid: false, message: 'System configuration error: API Key lookup table not found' @@ -156,6 +155,7 @@ export class Authentication { } if(!api_key_config.column){ + this.logger.error(`[Authentication][auth] System configuration error: API Key lookup table not found`) auth_passed = { valid: false, message: 'System configuration error: API Key lookup column not found' @@ -163,19 +163,57 @@ export class Authentication { continue } - const schema = await this.schema.getSchema(api_key_config.table) + let schema = await this.schema.getSchema(api_key_config.table) + + const relations = api_key_config.column.includes('.') ? [api_key_config.column.split('.')[0]] : [] + + if (relations.length > 0) { + const validateRelations = await this.schema.validateRelations(schema, relations) + if (!validateRelations.valid) { + this.logger.error(validateRelations.message) + return validateRelations + } + + schema = validateRelations.schema + } + const result = await this.query.findOne({ schema, + relations, + fields: [api_key_config.identity_column ?? api_key_config.column ].join(','), where: [{ column: api_key_config.column, operator: WhereOperator.equals, value: req_api_key - }] - + }], + joins: true }) + let column + + if(api_key_config.column){ + if(api_key_config.column.includes('.')){ + column = api_key_config.column.split('.')[1] + }else{ + column = api_key_config.column + } + } + + let identity_column + + if(api_key_config.identity_column){ + if(api_key_config.identity_column.includes('.')){ + identity_column = api_key_config.identity_column.split('.')[1] + }else{ + identity_column = api_key_config.identity_column + } + } + + const match_column = identity_column ?? column + //key does not match - return unauthorized immediately - if(!result || !result[api_key_config.column] || result[api_key_config.column] !== req_api_key){ + if(!result || !result[match_column] || result[match_column] !== req_api_key){ + this.logger.debug(`[Authentication][auth] API key not found`, {key: req_api_key, match_column, result}) return { valid: false, message: 'Unathorized' } } diff --git a/src/helpers/Logger.ts b/src/helpers/Logger.ts index 4bd1a1a..e987048 100644 --- a/src/helpers/Logger.ts +++ b/src/helpers/Logger.ts @@ -1,8 +1,10 @@ import { Injectable, Scope, ConsoleLogger } from '@nestjs/common'; -export const context = 'Llana' - @Injectable({ scope: Scope.TRANSIENT }) -export class Logger extends ConsoleLogger {} +export class Logger extends ConsoleLogger { + constructor() { + super('Llana') + } +} diff --git a/src/helpers/Pagination.ts b/src/helpers/Pagination.ts index 37332ec..9465a81 100644 --- a/src/helpers/Pagination.ts +++ b/src/helpers/Pagination.ts @@ -1,16 +1,12 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { context, Logger } from "./Logger"; import { GetQueryObject } from "../types/response.types"; @Injectable() export class Pagination { constructor( private readonly configService: ConfigService, - private readonly logger: Logger, - ) { - this.logger.setContext(context) - } + ) {} /** * Takes the query parameters, configs (for defualts) and returns the limit and offset diff --git a/src/helpers/Query.ts b/src/helpers/Query.ts index 31b4c2c..f614d4b 100644 --- a/src/helpers/Query.ts +++ b/src/helpers/Query.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common' -import { Logger, context } from './Logger' +import { Logger } from './Logger' import { DatabaseType, DatabaseFindByIdOptions, DatabaseFindOneOptions, DatabaseFindManyOptions } from '../types/database.types' import { ConfigService } from '@nestjs/config' import { Schema } from './Schema' @@ -14,9 +14,7 @@ export class Query { private readonly logger: Logger, private readonly schema: Schema, private readonly mysql: MySQL, - ) { - this.logger.setContext(context) - } + ) {} /** * Find record by primary key id @@ -24,7 +22,7 @@ export class Query { async findById(options: DatabaseFindByIdOptions): Promise { - const table_name = this.schema.getTableName(options.schema) + const table_name = options.schema.table const primary_key = this.schema.getPrimaryKey(options.schema) this.logger.debug(`[Query][Find][One][Id][${table_name}]`, { @@ -55,13 +53,9 @@ export class Query { async findOne(options: DatabaseFindOneOptions): Promise { - const table_name = this.schema.getTableName(options.schema) + const table_name = options.schema.table - this.logger.debug(`[Query][Find][One][${table_name}]`, { - fields: options.fields, - relations: options.relations, - where: options.where, - }) + this.logger.debug(`[Query][Find][One][${table_name}]`, options) try { switch(this.configService.get('database.type')){ @@ -84,7 +78,7 @@ export class Query { async findMany(options: DatabaseFindManyOptions): Promise { - const table_name = this.schema.getTableName(options.schema) + const table_name = options.schema.table this.logger.debug(`[Query][Find][Many][${table_name}]`, { fields: options.fields, diff --git a/src/helpers/Schema.ts b/src/helpers/Schema.ts index 0b6d477..c563264 100644 --- a/src/helpers/Schema.ts +++ b/src/helpers/Schema.ts @@ -1,9 +1,9 @@ import { Injectable } from "@nestjs/common"; -import { context, Logger } from "./Logger"; +import { Logger } from "./Logger"; import { DatabaseSchema, DatabaseType, WhereOperator } from "../types/database.types"; import { ConfigService } from "@nestjs/config"; import { MySQL } from "../databases/mysql.database"; -import { ValidateResponse } from "../types/schema.types"; +import { ValidateFieldsResponse, validateRelationsResponse, ValidateResponse, validateWhereResponse } from "../types/schema.types"; import { NON_FIELD_PARAMS } from "../app.constants"; @Injectable() @@ -12,9 +12,7 @@ export class Schema { private readonly logger: Logger, private readonly configService: ConfigService, private readonly mysql: MySQL - ) { - this.logger.setContext(context) - } + ) {} /** * Get Table Schema @@ -33,15 +31,6 @@ export class Schema { } } - /** - * Create entity schema from database schema - * @param schema - */ - - getTableName(schema: DatabaseSchema): string { - return schema.table - } - /** * The primary key's name of the table */ @@ -100,25 +89,31 @@ export class Schema { } } - validateFields(schema: DatabaseSchema, fields: string): ValidateResponse { - - const table_name = this.getTableName(schema) + validateFields(schema: DatabaseSchema, fields: string): ValidateFieldsResponse { try{ - const array = fields.split(',').filter(field => !field.includes('.')) + const params = fields.split(',').filter(field => !field.includes('.')) + const params_relations = fields.split(',').filter(field => field.includes('.')) - for(let field of array){ + for(let field of params){ if(!schema.columns.find((col) => col.field === field)){ return { valid: false, - message: `Field ${field} not found in table schema for ${table_name}` + message: `Field ${field} not found in table schema for ${schema.table}` } } } + + const relations = [] + + for(const relation of params_relations){ + relations.push(relation.split('.')[0]) + } return { valid: true, - params: array + params, + relations } }catch(e){ return { @@ -133,18 +128,14 @@ export class Schema { * Validate relations by ensuring that the relation exists in the schema */ - async validateRelations(schema: DatabaseSchema, relations: string): Promise { - - const table_name = this.getTableName(schema) - + async validateRelations(schema: DatabaseSchema, relations: string[]): Promise { + try{ - const array = relations.split(',') - - for(let relation of array){ + for(let relation of relations){ if(!schema.relations.find((col) => col.table === relation)){ return { valid: false, - message: `Relation ${relation} not found in table schema for ${table_name} ` + message: `Relation ${relation} not found in table schema for ${schema.table} ` } } schema.relations.find((col) => col.table === relation).schema = await this.getSchema(relation) @@ -152,7 +143,6 @@ export class Schema { return { valid: true, - params: array, schema: schema } }catch(e){ @@ -170,7 +160,7 @@ export class Schema { * Example: ?id[equals]=1&name=John&age[gte]=21 */ - validateWhereParams(schema: DatabaseSchema, params: any): ValidateResponse { + validateWhereParams(schema: DatabaseSchema, params: any): validateWhereResponse { const where = [] @@ -230,7 +220,7 @@ export class Schema { * Example: ?sort=name.asc,id.desc,content.title.asc */ - validateOrder(schema: DatabaseSchema, sort: string): {valid: boolean, message?: string} { + validateOrder(schema: DatabaseSchema, sort: string): ValidateResponse { const array = sort.split(',').filter(sort => !sort.includes('.')) diff --git a/src/main.ts b/src/main.ts index 31cdf63..4e56ffd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,13 @@ import { NestFactory } from '@nestjs/core' import { AppModule } from './app.module' import 'dotenv/config' -import { Logger, context } from './helpers/Logger' +import { Logger } from './helpers/Logger' async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(process.env.PORT); const logger = new Logger() - logger.setContext(context) let url = await app.getUrl(); url = url.replace('[::1]', 'localhost'); diff --git a/src/middleware/HostCheck.ts b/src/middleware/HostCheck.ts new file mode 100644 index 0000000..c12e1d7 --- /dev/null +++ b/src/middleware/HostCheck.ts @@ -0,0 +1,37 @@ +import { Env } from '@juicyllama/utils' +import { Injectable, NestMiddleware } from '@nestjs/common' +import { ConfigService } from '@nestjs/config'; +import { Request, Response, NextFunction } from 'express'; +import { Logger } from '../helpers/Logger'; + +@Injectable() +export class HostCheckMiddleware implements NestMiddleware { + constructor( + private readonly configService: ConfigService, + private readonly logger: Logger, + ) {} + + use(req: Request, res: Response, next: NextFunction) { + + const allowed_hosts = this.configService.get('hosts') || [] + + if(allowed_hosts.length === 0){ + return next() + } + + for (const host of allowed_hosts) { + if (req.get('host') === host) { + return next() + } + } + + if (Env.IsDev()) { + this.logger.warn(`Host not in approved list, skipping forbidden response as in dev mode`, { host: req.get('host'), allowed_hosts }) + return next() + }else{ + res.status(403).send('Forbidden') + return next() + } + + } +} \ No newline at end of file diff --git a/src/types/auth.types.ts b/src/types/auth.types.ts index 8a53ad7..9ae04f4 100644 --- a/src/types/auth.types.ts +++ b/src/types/auth.types.ts @@ -18,10 +18,11 @@ export interface AuthJWT extends Auth { export interface Auth { table: string, where?: { - column: string, - operator: WhereOperator, + column: string + operator: WhereOperator value?: string }[], + identity_column?: string // If your identity column is not the table primary key } export enum AuthPasswordEncryption { diff --git a/src/types/database.types.ts b/src/types/database.types.ts index 34f4c6d..8ee2369 100644 --- a/src/types/database.types.ts +++ b/src/types/database.types.ts @@ -128,16 +128,17 @@ export interface DatabaseFindOneOptions extends DatabaeseFindOptions { } export interface DatabaseFindManyOptions extends DatabaeseFindOptions { - where?: DatabaseWhere[], - limit?: number, - offset?: number, + where?: DatabaseWhere[] + limit?: number + offset?: number sort?: SortCondition[] } export interface DatabaeseFindOptions { schema: DatabaseSchema, fields?: string, - relations?: string, + relations?: string[], + joins?: boolean // Do join at database level, default false } export interface DatabaseFindTotalRecords { diff --git a/src/types/restrictions.types.ts b/src/types/restrictions.types.ts index 91499e5..4f541e0 100644 --- a/src/types/restrictions.types.ts +++ b/src/types/restrictions.types.ts @@ -4,7 +4,6 @@ export interface Restriction { type: RestrictionType, location: RestrictionLocation, name: string, - hosts?: string[], routes?: { include?: string[], exclude?: string[], diff --git a/src/types/schema.types.ts b/src/types/schema.types.ts index e136355..362888c 100644 --- a/src/types/schema.types.ts +++ b/src/types/schema.types.ts @@ -1,15 +1,25 @@ import { DatabaseSchema, DatabaseWhere } from "./database.types"; -export interface ValidateResponse { - valid: boolean, - message?: string, - where?: DatabaseWhere[] - params?: string[], +export interface ValidateFieldsResponse extends ValidateResponse { + params?: string[] + relations?: string[] +} + +export interface validateRelationsResponse extends ValidateResponse { schema?: DatabaseSchema } +export interface validateWhereResponse extends ValidateResponse { + where?: DatabaseWhere[] +} + +export interface ValidateResponse { + valid: boolean + message?: string +} + export interface SortCondition { - column: string, + column: string operator: 'ASC' | 'DESC' } \ No newline at end of file