refactor(express): express set as default backend

This commit is contained in:
2024-10-29 23:57:47 +01:00
parent dcd1debc73
commit 15fb5a3163
66 changed files with 861 additions and 11559 deletions

View File

@@ -0,0 +1,21 @@
import { Router } from "express";
import { ResponseSuccess } from "@/utils/response/response-success.model";
import {{pascalCase name}}Service from "./{{kebabCase name}}.service";
export const {{camelCase name}}Routes = Router();
const {{camelCase name}}Service = new {{pascalCase name}}Service();
{{camelCase name}}Routes.get("/", async (_, res) => {
try {
const response = await {{camelCase name}}Service.getAll{{pascalCase name}}();
res.status(200);
res.send(new ResponseSuccess(response));
} catch (e) {
res.status(500);
res.send(e);
}
});
export { {{camelCase name}}Service };

View File

@@ -0,0 +1,9 @@
class {{pascalCase name}}Service {
constructor(private info?: string) {}
async getAll{{pascalCase name}}() {
return Promise.resolve(this.info);
}
}
export default {{pascalCase name}}Service;

View File

@@ -1,25 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

154
back/.gitignore vendored
View File

@@ -1,39 +1,80 @@
# compiled output
/dist
/node_modules
/build
# Created by https://www.toptal.com/developers/gitignore/api/node
# Edit at https://www.toptal.com/developers/gitignore?templates=node
### Node ###
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# OS
.DS_Store
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Tests
/coverage
/.nyc_output
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
@@ -42,15 +83,62 @@ lerna-debug.log*
.env.production.local
.env.local
# temp directory
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Docusaurus cache and generated files
.docusaurus
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node

View File

@@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@@ -1,23 +0,0 @@
# Use the official Node.js slim image as a base image
FROM node:18-slim
# Set the working directory inside the container
WORKDIR /app
# Copy package.json and package-lock.json to the working directory
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the application code to the working directory
COPY . .
# Build the NestJS application
RUN npm run build
# Expose the port that the NestJS application runs on
EXPOSE 3000
# Start the NestJS application
CMD ["npm", "run", "start:prod"]

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 Daniel Heras Quesada
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,67 +0,0 @@
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript archetype repository.
## Installation
```bash
$ npm install
```
## Running the app
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Test
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Authentication functionality
All done with the `Auth` decorator, which lets you determine a list of authorized roles.
The are 3 available roles:
- Public: no role nor user needed
- User
- Admin
These can be extended by simply adding new elements to the `src/users/roles/role.enum.ts` enumeration.
```ts
@Auth(Role.Public)
@Get()
getHello(): string { return "Hello!" }
```
### JWT
User login is managed by a `JWT` based structure. The client must send both username and password in the body of a `POST` method at `/auth/login`, which would return a `JWT` in case of success. This token must be used to interact with the server on every non-public route. Internally the server assumes the user passwords are stored hashed on a [Mariadb](https://mariadb.org/) database.
## Stay in touch
- Author - [Daniel Heras Quesada](https://dqnid.com)
- Twitter - [@nestframework](https://twitter.com/nestframework)
- Linkedin - [daniel-heras-quesada](https://www.linkedin.com/in/daniel-heras-quesada/)
## License
This archetype is [MIT licensed](LICENSE).

View File

@@ -1,10 +0,0 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: nestjs-app
ports:
- '3000:3000'

View File

@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

8796
back/package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,78 +1,31 @@
{
"name": "back-nest-archetype",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"name": "express-backend",
"version": "1.0.0",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"dev": "nodemon src/app.ts",
"start": "ts-node src/app.ts",
"build": "tsc",
"serve": "node dist/app.js"
},
"author": "Daniel Heras Quesada",
"license": "ISC",
"description": "",
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.7.4",
"express": "^4.21.0",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"typescript": "^5.6.2"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/throttler": "^5.1.2",
"@nestjs/typeorm": "^10.0.2",
"bcrypt": "^5.1.1",
"mysql2": "^3.9.7",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"@types/jsonwebtoken": "^9.0.7",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.11.3"
}
}

View File

@@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@@ -1,18 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { Throttle } from '@nestjs/throttler';
import { Auth } from './auth/auth.decorator';
import { Role } from './users/roles/role.enum';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
// Override default configuration for Rate limiting and duration.
@Throttle({ default: { limit: 10, ttl: 1000 } })
@Auth(Role.Public)
@Get()
getHello(): string {
return this.appService.getHello();
}
}

View File

@@ -1,41 +0,0 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { ThrottlerModule } from '@nestjs/throttler';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { ExampleModule } from './example/example.module';
import { User } from './users/entities/user.entity';
import { Example } from './example/entities/example.entity';
@Module({
imports: [
ConfigModule.forRoot(),
AuthModule,
UsersModule,
ExampleModule,
ThrottlerModule.forRoot([
{
ttl: 10000,
limit: 20,
},
]),
TypeOrmModule.forRoot({
type: process.env.DB_TYPE as 'mysql' | 'mariadb',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_MAIN,
entities: [User, Example],
synchronize: false,
connectTimeout: 20000,
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

View File

@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

33
back/src/app.ts Normal file
View File

@@ -0,0 +1,33 @@
import express from "express";
import config from "./config";
import cors from "cors";
import { routes } from "./modules";
import { authorizationMiddleware } from "./modules/auth/auth.middleware";
const app = express();
const port = config.port;
if (config.enableCors) {
app.use(cors());
}
app.use(express.json());
// Global middleware
app.use(authorizationMiddleware as any); // TODO: move out of here
app.use((req, _res, next) => {
console.log(`LOG: new ${req.method} request for ${req.url}`);
next();
});
// Route specific middleware
app.use("/example", (req, _res, next) => {
console.log(`LOG: new ${req.method} example request for ${req.url}`);
next();
});
app.use(config.baseRoute, routes); // / serves as the base for the imported routes
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});

View File

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

View File

@@ -1,35 +0,0 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Request,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { Role } from 'src/users/roles/role.enum';
import { Auth } from './auth.decorator';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Auth(Role.Public)
@HttpCode(HttpStatus.OK)
@Post('login')
signIn(@Body() signInDto: Record<string, any>) {
return this.authService.signIn(signInDto.username, signInDto.password);
}
@Auth(Role.Admin)
@Get('profile')
getProfile(@Request() req) {
return req.user;
}
@Get('options')
getOptions(@Request() req) {
return req.roles;
}
}

View File

@@ -1,8 +0,0 @@
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { Role } from 'src/users/roles/role.enum';
export const ROLES_METADATA = 'roles';
export function Auth(...roles: Role[]) {
return applyDecorators(SetMetadata(ROLES_METADATA, roles));
}

View File

@@ -1,60 +0,0 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { Request } from 'express';
import { Reflector } from '@nestjs/core';
import { ROLES_METADATA } from './auth.decorator';
import { Role } from 'src/users/roles/role.enum';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const decorator_roles = this.reflector.getAllAndOverride<Role[]>(
ROLES_METADATA,
[context.getHandler(), context.getClass()],
);
if (decorator_roles && decorator_roles.includes(Role.Public)) {
return true;
}
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: jwtConstants.secret,
});
// 💡 We're assigning the payload to the request object here
// so that we can access it in our route handlers
request['user'] = payload;
const found = payload.roles.some((r: Role) =>
decorator_roles.includes(r),
);
if (!found) {
throw new UnauthorizedException();
}
} catch {
throw new UnauthorizedException();
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -1,30 +0,0 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { jwtConstants } from './constants';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './auth.guard';
//TODO: TIMING LOGIC: ID Token: 60 minutes. Access Token: 60 minutes. Refresh Token: 90 days
@Module({
imports: [
UsersModule,
JwtModule.register({
global: true,
secret: jwtConstants.secret,
signOptions: { expiresIn: '60m' },
}),
],
providers: [
AuthService,
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}

View File

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

View File

@@ -1,33 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from 'src/users/users.service';
import * as bcrypt from 'bcrypt';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
) {}
async signIn(
username: string,
pass: string,
): Promise<{ access_token: string }> {
console.log(username, pass);
const user = await this.usersService.findOne(username);
if (!user) throw new UnauthorizedException();
const isSamePasswd = await bcrypt.compare(`${pass}`, `${user?.password}`);
if (!isSamePasswd) throw new UnauthorizedException();
const payload = {
sub: user.id,
username: user.username,
roles: user.roles,
picture: user.picture,
};
return { access_token: await this.jwtService.signAsync(payload) };
}
}

View File

@@ -1,5 +0,0 @@
//TODO: remove this and do it propertly
export const jwtConstants = {
secret:
'DO NOT USE THIS VALUE. INSTEAD, CREATE A COMPLEX SECRET AND KEEP IT SAFE OUTSIDE OF THE SOURCE CODE.',
};

11
back/src/config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { configDotenv } from "dotenv";
configDotenv();
const config = {
enableCors: true,
port: process.env.PORT || 3000,
baseRoute: "/api/v1",
};
export default config;

26
back/src/db.ts Normal file
View File

@@ -0,0 +1,26 @@
import mysql, { QueryError } from "mysql2/promise";
import { ResponseError } from "./utils/response/response-error.model";
const db_pool = mysql.createPool({
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "3307"),
user: process.env.DB_USERNAME ?? "dbuser",
password: process.env.DB_PASSWORD ?? "securepassword",
database: process.env.DB_MAIN ?? "path",
});
async function DB_Query<T>(query: string): Promise<Partial<T>[]> {
try {
const [results, _fields] = await db_pool.query(query);
return results as T[];
} catch (e) {
const queryError = e as QueryError;
throw new ResponseError({
code: queryError.code,
number: queryError.errno,
detail: queryError.message,
});
}
}
export { db_pool as db_connection, DB_Query as db_query };

View File

@@ -1 +0,0 @@
export class CreateExampleDto {}

View File

@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateExampleDto } from './create-example.dto';
export class UpdateExampleDto extends PartialType(CreateExampleDto) {}

View File

@@ -1,19 +0,0 @@
import { Column, Entity, Generated, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class Example {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
description: string;
@Column()
image: string;
@Column()
created_at: string;
}

View File

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

View File

@@ -1,49 +0,0 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
} from '@nestjs/common';
import { ExampleService } from './example.service';
import { CreateExampleDto } from './dto/create-example.dto';
import { UpdateExampleDto } from './dto/update-example.dto';
import { Auth } from 'src/auth/auth.decorator';
import { Role } from 'src/users/roles/role.enum';
@Controller('example')
export class ExampleController {
constructor(private readonly exampleService: ExampleService) {}
@Auth(Role.Admin)
@Post()
create(@Body() createExampleDto: CreateExampleDto) {
return this.exampleService.create(createExampleDto);
}
@Auth(Role.Public)
@Get()
findAll() {
return this.exampleService.findAll();
}
@Auth(Role.User)
@Get(':id')
findOne(@Param('id') id: string) {
return this.exampleService.findOne(+id);
}
@Auth(Role.Admin)
@Patch(':id')
update(@Param('id') id: string, @Body() updateExampleDto: UpdateExampleDto) {
return this.exampleService.update(+id, updateExampleDto);
}
@Auth(Role.Admin)
@Delete(':id')
remove(@Param('id') id: string) {
return this.exampleService.remove(+id);
}
}

View File

@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { ExampleService } from './example.service';
import { ExampleController } from './example.controller';
import { Example } from './entities/example.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [TypeOrmModule.forFeature([Example])],
controllers: [ExampleController],
providers: [ExampleService],
})
export class ExampleModule {}

View File

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

View File

@@ -1,36 +0,0 @@
import { Injectable } from '@nestjs/common';
import { CreateExampleDto } from './dto/create-example.dto';
import { UpdateExampleDto } from './dto/update-example.dto';
import { Example } from './entities/example.entity';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
@Injectable()
export class ExampleService {
constructor(
@InjectRepository(Example)
private exampleRepository: Repository<Example>,
@InjectDataSource()
private dataSource: DataSource,
) {}
create(createExampleDto: CreateExampleDto) {
return this.exampleRepository.create(createExampleDto);
}
findAll() {
return this.dataSource.query('select * from example');
}
findOne(id: number) {
return this.exampleRepository.findOneBy({ id });
}
update(id: number, updateExampleDto: UpdateExampleDto) {
return this.exampleRepository.update({ id }, updateExampleDto);
}
remove(id: number) {
return this.exampleRepository.delete(id);
}
}

View File

@@ -1,9 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
await app.listen(3000);
}
bootstrap();

View File

@@ -0,0 +1 @@
export const AUTH_KEY_SIZE = 30;

View File

@@ -0,0 +1,85 @@
import { NextFunction, Request, Response } from "express";
import { authService } from "./auth.routes";
import { Role } from "../users/users.types";
import { ResponseError } from "../../utils/response/response-error.model";
import config from "../../config";
async function authorization(req: Request, res: Response, next: NextFunction) {
const resource = req.url.replace(config.baseRoute, "");
const isPublic = authorizationTable.some((permission) => {
const regex = new RegExp(permission.resource);
return (
regex.test(resource) &&
permission.actions.includes(req.method as HttpMethod) &&
permission.roles.includes(Role.Public)
);
});
if (isPublic) {
return next();
}
const authHeader = req.get("authorization");
const token = authHeader?.split(" ")[1];
if (token == null) {
res.status(401);
return res.send(new ResponseError(401));
}
const user = authService.verifyToken(token);
if (user && user instanceof Object) {
const authorized = authorizationTable.some((permission) => {
const regex = new RegExp(permission.resource);
return (
regex.test(resource) &&
permission.roles.some((role) => user.roles.includes(role)) &&
permission.actions.includes(req.method as HttpMethod)
);
});
if (authorized) {
return next();
}
}
res.status(401);
return res.send(new ResponseError(401));
}
type HttpMethod =
| "GET"
| "POST"
| "PUT"
| "DELETE"
| "PATCH"
| "OPTIONS"
| "HEAD";
type Permissions = {
roles: Array<Role>;
resource: string;
actions: Array<HttpMethod>;
};
const authorizationTable: Permissions[] = [
{
roles: [Role.Public],
resource: "^/auth.*", // begins with /auth (is auth would be ^\/auth$)
actions: ["POST"],
},
{
roles: [Role.Admin],
resource: "^/users.*",
actions: ["GET", "PUT", "POST", "DELETE"],
},
{
roles: [Role.User],
resource: "^/users.*",
actions: ["GET"],
},
{
roles: [Role.Public],
resource: "^/example.*",
actions: ["GET", "OPTIONS"],
},
];
export { authorization as authorizationMiddleware };

View File

@@ -0,0 +1,33 @@
import { Router } from "express";
import { userService } from "../users/users.routes";
import { AuthService } from "./auth.service";
import { ResponseError } from "../../utils/response/response-error.model";
export const authRoutes = Router();
const authService = new AuthService(userService);
authRoutes.post("/login", async (req, res) => {
try {
if (!req.body) {
res.status(400);
res.send(new ResponseError(400));
}
const token = await authService.signIn(
req.body.username,
req.body.password,
);
if (token) {
res.status(200);
res.send({ access_token: token });
} else {
res.status(401);
res.send(new ResponseError(401));
}
} catch (e) {
res.status(500);
res.send(e);
}
});
export { authService };

View File

@@ -0,0 +1,51 @@
import { sign, verify } from "jsonwebtoken";
import UsersService from "../users/users.service";
import { compare } from "bcrypt";
import { generateRandomString } from "./auth.utils";
import { AUTH_KEY_SIZE } from "./auth.constants";
import { Role } from "../users/users.types";
export class AuthService {
private secret_key: string;
constructor(private usersService: UsersService) {
this.secret_key = generateRandomString(AUTH_KEY_SIZE);
}
async signIn(username: string, password: string) {
const user = await this.usersService.getUserByUsername(username);
if (!user) {
return null;
}
const isSamePasswd = await compare(`${password}`, `${user?.password}`);
if (!isSamePasswd) return null;
const payload = {
sub: user.id,
username: user.username,
roles: user.roles,
picture: user.picture,
};
const token = sign(payload, this.secret_key, { expiresIn: 60 * 60 });
return token;
}
verifyToken(jwt: string) {
const token = jwt.split(".")[1];
if (!token) return false;
try {
const payload = verify(jwt, this.secret_key);
if (payload instanceof Object)
return {
username: payload.username as string,
roles: payload.roles as Role[],
};
else return false;
} catch (e) {
return false;
}
}
}

View File

View File

@@ -0,0 +1,6 @@
function generateRandomString(size: number) {
const value = Math.random() * Math.pow(10, size);
return btoa(value.toString());
}
export { generateRandomString };

View File

@@ -0,0 +1,8 @@
import { Router } from "express";
export const defaultRoute = Router();
defaultRoute.get("/", (_, res) => {
res.send("Nothing to see here");
res.status(200);
});

View File

@@ -0,0 +1,40 @@
import { Router } from "express";
import ExampleService from "./example.service";
import { ResponseSuccess } from "../../utils/response/response-success.model";
import { ResponseError } from "../../utils/response/response-error.model";
export const exampleRoutes = Router();
const exampleService = new ExampleService();
exampleRoutes.get("/", async (_, res) => {
try {
const response = await exampleService.getAllExamples();
res.status(200);
res.send(new ResponseSuccess(response));
} catch (e) {
res.status(500);
res.send(e);
}
});
exampleRoutes.get("/:id", async (req, res) => {
try {
const response = await exampleService.getExampleById(
parseInt(req.params.id),
);
if (response) {
res.status(200);
res.send(new ResponseSuccess(response));
} else {
res.status(404);
res.send(new ResponseError({ code: "NOT_FOUND", number: 404 }));
}
} catch (e) {
res.status(500);
res.send(e);
}
});
export { exampleService };

View File

@@ -0,0 +1,23 @@
import { db_query } from "../../db";
import { Example } from "./example.types";
class ExampleService {
constructor(private info?: string) {}
async getAllExamples(): Promise<Example[]> {
const examples = await db_query("select * from example");
return examples as Example[];
}
async getExampleById(id: number): Promise<Example | null> {
const example = await db_query(
`select * from example as example WHERE id = ${id};`,
);
if (example.length) {
return example[0] as Example;
}
return null;
}
}
export default ExampleService;

View File

@@ -0,0 +1,7 @@
export type Example = {
id: number;
name: string;
description: string;
image: string;
created_at: string;
};

16
back/src/modules/index.ts Normal file
View File

@@ -0,0 +1,16 @@
import express from "express";
import { defaultRoute } from "./default.route";
import { userRoutes } from "./users/users.routes";
import { authRoutes } from "./auth/auth.routes";
import { exampleRoutes } from "./example/example.routes";
export const routes = express.Router();
/*
* Routes
* */
routes.use("/", defaultRoute);
routes.use("/users", userRoutes);
routes.use("/auth", authRoutes);
routes.use("/example", exampleRoutes);

View File

@@ -0,0 +1,39 @@
import { Router } from "express";
import UsersService from "./users.service";
import { sanitize_user } from "./users.utils";
import { ResponseSuccess } from "../../utils/response/response-success.model";
import { ResponseError } from "../../utils/response/response-error.model";
export const userRoutes = Router();
const userService = new UsersService();
//TODO: block access to NON-admins or simply comment
userRoutes.get("/", async (_, res) => {
try {
const response = await userService.getAllUsers();
res.status(200);
res.send(new ResponseSuccess(response.map((user) => sanitize_user(user))));
} catch (e) {
res.status(500);
res.send(e);
}
});
userRoutes.get("/:username", async (req, res) => {
try {
const response = await userService.getUserByUsername(req.params.username);
if (response) {
res.status(200);
res.send(new ResponseSuccess(sanitize_user(response)));
} else {
res.status(404);
res.send(new ResponseError({ code: "NOT_FOUND", number: 404 }));
}
} catch (e) {
res.status(500);
res.send(e);
}
});
export { userService };

View File

@@ -0,0 +1,31 @@
import { db_query } from "../../db";
import { User } from "./users.types";
class UsersService {
constructor() {}
async getAllUsers(): Promise<User[]> {
const data = await db_query("select * from user");
const users: User[] = data.map((user: any) => ({
...user,
roles: user.roles.split(";"),
}));
return users;
}
async getUserByUsername(username: string): Promise<User | null> {
const data = await db_query(
`select * from user as user WHERE LOWER(username) = LOWER('${username}');`,
);
if (data.length) {
const user = {
...(data[0] as User),
roles: (data[0] as any).roles.split(";"),
};
return user;
}
return null;
}
}
export default UsersService;

View File

@@ -0,0 +1,17 @@
export enum Role {
Public = "public",
User = "user",
Manager = "manager",
Admin = "admin",
}
export type User = {
id: number;
username: string;
password: string;
roles: Role[];
picture: string;
created_at: string;
};
export type UserDetailResult = Omit<User, "password">;

View File

@@ -0,0 +1,7 @@
import { User, UserDetailResult } from "./users.types";
// TODO: prettify this
export function sanitize_user(user: User): UserDetailResult {
delete (user as any).password;
return user;
}

View File

@@ -1,22 +0,0 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
// TODO: username should be unique and, maybe can act as ID?
// NOTE: CREATE TABLE user (id bigint primary key DEFAULT UUID_SHORT(), username char(20) not null, password char(20) not null, roles char(50) not null);
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@Column()
password: string;
@Column()
roles: string;
@Column()
picture: string;
}

View File

@@ -1,6 +0,0 @@
export enum Role {
Public = 'public',
User = 'user',
Manager = 'manager',
Admin = 'admin',
}

View File

@@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

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

View File

@@ -1,53 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Role } from './roles/role.enum';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { DataSource, Repository } from 'typeorm';
export type UserType = {
id: number;
username: string;
password: string;
roles: Role[];
picture: string;
};
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
@InjectDataSource()
private dataSource: DataSource,
) {}
async findOne(username: string): Promise<UserType | undefined> {
const db_user = await this.usersRepository.findOneBy({ username });
if (!db_user) return null;
//TODO: change this shabby mapping for a more adequate database structure
const user: UserType = {
id: db_user.id,
username: db_user.username,
password: db_user.password,
roles: db_user.roles.split(';') as Role[],
picture: db_user.picture,
};
console.log(user);
return user;
}
async doSOmething() {
this.dataSource.query('SELECT * from users');
}
// async create(
// username: string,
// password: string,
// roles: Role[],
// ): Promise<User | undefined> {
// const roles_string = roles.join(';');
// const create_result = this.usersRepository.create(
// new User(username, password, roles_string),
// );
// }
}

View File

@@ -0,0 +1,33 @@
type BasicError = {
code: string | number;
number?: number;
detail?: string;
status?: number;
suggestion?: string;
};
const code_error_mapping: Record<number, BasicError> = {
400: {
code: "BAD REQUEST",
status: 400,
},
401: {
code: "UNAUTHORIZED",
status: 401,
},
};
export class ResponseError extends Error {
public error: BasicError | number;
constructor(
error: BasicError | number,
public timestamp: number = Date.now(),
) {
super();
if (error instanceof Object) {
this.error = error;
} else {
this.error = code_error_mapping[error];
}
}
}

View File

@@ -0,0 +1,16 @@
export class ResponseSuccess {
public data?: unknown;
public results?: unknown[];
public count?: number;
constructor(data: unknown) {
if (data instanceof Array) {
this.results = data;
this.count = data.length;
} else if (data instanceof Object) {
Object.assign(this, data);
} else {
this.data = data;
}
}
}

View File

@@ -1,24 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View File

@@ -1,9 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@@ -1,21 +1,17 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules"
]
}