diff --git a/migrations/20201122143517_alter_reconstructions_table_add_state_column.ts b/migrations/20201122143517_alter_reconstructions_table_add_state_column.ts index b3420a3..78275eb 100644 --- a/migrations/20201122143517_alter_reconstructions_table_add_state_column.ts +++ b/migrations/20201122143517_alter_reconstructions_table_add_state_column.ts @@ -21,4 +21,6 @@ export async function down(knex: Knex): Promise { table.dropColumn('state'); } ); + + await knex.schema.raw('DROP TYPE "ReconstructionState";'); } diff --git a/migrations/20201205154738_alter_reconstructions_table_add_reconstruction_file_column.ts b/migrations/20201205154738_alter_reconstructions_table_add_reconstruction_file_column.ts new file mode 100644 index 0000000..c24b46b --- /dev/null +++ b/migrations/20201205154738_alter_reconstructions_table_add_reconstruction_file_column.ts @@ -0,0 +1,19 @@ +import * as Knex from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.table( + 'reconstructions', + (table: Knex.AlterTableBuilder) => { + table.string('reconstruction_file').nullable(); + } + ); +} + +export async function down(knex: Knex): Promise { + await knex.schema.table( + 'reconstructions', + (table: Knex.AlterTableBuilder) => { + table.dropColumn('reconstruction_file'); + } + ); +} diff --git a/migrations/20201215192946_create_apps_table.ts b/migrations/20201215192946_create_apps_table.ts new file mode 100644 index 0000000..d67c786 --- /dev/null +++ b/migrations/20201215192946_create_apps_table.ts @@ -0,0 +1,16 @@ +import * as Knex from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('apps', (table: Knex.CreateTableBuilder) => { + table.bigIncrements('id').primary(); + table.string('name').notNullable(); + table.string('domain').notNullable(); + table.string('key', 2000).notNullable().unique(); + table.string('secret', 2000).notNullable(); + table.timestamps(false, true); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('apps'); +} diff --git a/package.json b/package.json index c0fbc90..4886e90 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,19 @@ "description": "REST API for ThreeDify", "scripts": { "build": "tsc -p .", + "migrate:up": "knex migrate:up", + "migrate:list": "knex migrate:list", + "migrate:down": "knex migrate:down", + "migrate:make": "knex migrate:make", + "migrate:latest": "knex migrate:latest", + "migrate:rollback": "knex migrate:rollback", "start": "node -r dotenv/config dist/src/index.js", "start:dev": "nodemon -r dotenv/config src/index.ts", "lint": "pretty-quick --check --pattern \"src/**/*\" && eslint", + "script:generateApp": "ts-node -r dotenv/config scripts/generateApp.ts", "lint:fix": "pretty-quick --write --pattern \"src/**/*\" && eslint --fix", - "listDriveFiles": "ts-node -r dotenv/config scripts/listDriveFiles.ts", - "generateGoogleToken": "ts-node -r dotenv/config scripts/generateGoogleToken.ts" + "script:listDriveFiles": "ts-node -r dotenv/config scripts/listDriveFiles.ts", + "script:generateGoogleToken": "ts-node -r dotenv/config scripts/generateGoogleToken.ts" }, "private": "true", "license": "MIT", @@ -30,6 +37,7 @@ "@types/range-parser": "^1.2.3", "@types/swagger-jsdoc": "^3.0.2", "@types/swagger-ui-express": "^4.1.2", + "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "^3.8.0", "@typescript-eslint/parser": "^3.8.0", "eslint": "^7.6.0", @@ -62,7 +70,8 @@ "pg": "^8.3.3", "range-parser": "^1.2.1", "swagger-jsdoc": "^4.3.0", - "swagger-ui-express": "^4.1.4" + "swagger-ui-express": "^4.1.4", + "uuid": "^8.3.2" }, "husky": { "hooks": { diff --git a/scripts/generateApp.ts b/scripts/generateApp.ts new file mode 100644 index 0000000..6369b1e --- /dev/null +++ b/scripts/generateApp.ts @@ -0,0 +1,63 @@ +import readline from 'readline'; +import { ValidationResult } from 'joi'; +import Debug, { Debugger } from 'debug'; + +import db from '../src/utils/db'; +import NewApp from '../src/domain/NewApp'; +import appAuth from '../src/services/appAuth'; +import NewAppValidationSchema from '../src/validationSchema/NewAppValidationSchema'; + +const debug: Debugger = Debug('threedify:script:generateApp'); + +async function question( + rl: readline.Interface, + question: string +): Promise { + return new Promise((resolve) => { + rl.question(question, (answer) => resolve(answer)); + }); +} + +(async () => { + const reader = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + debug('Connecting to database...'); + db.init(); + + debug('Enter app details...'); + const newApp: NewApp = { + name: await question(reader, 'App name? '), + domain: await question(reader, 'App domain? '), + }; + reader.close(); + + debug('Validating app details...'); + const result: ValidationResult = NewAppValidationSchema.validate(newApp, { + abortEarly: false, + }); + + if (result.error) { + debug('Validation failed...'); + debug('Validation Errors: %O', result.error.details); + + process.exit(1); + } + + debug('Creating app...'); + const validatedApp: NewApp = result.value; + const app = await appAuth.createNewApp(validatedApp); + + debug('Save the app key and secret...'); + debug('App Key: %s', app?.key); + debug('App Secret: %s', app?.rawSecret); + } catch (err) { + debug('Oops! an error occurred: %O', err); + process.exit(2); + } + + process.exit(0); +})(); diff --git a/src/config.ts b/src/config.ts index 9c46387..03927f4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,27 +6,30 @@ import { Options as SwaggerOptions } from 'swagger-jsdoc'; import packageJson from '../package.json'; +import apps from './services/apps'; import { SortOrder } from './domain/PaginationQuery'; import GoogleAPIConfig from './domain/GoogleAPIConfig'; import PaginationConfig from './domain/PaginationConfig'; +import { AvailableStorageAPI } from './domain/StorageAPI'; +import SupportedMimeTypes from './domain/SupportedMimeTypes'; interface Config { port: number; baseUrl: string; saltRound: number; - storageAPI: string; corsConfig: CorsOptions; uploadDirectory: string; requestLogFormat: string; accessTokenSecret: string; refreshTokenSecret: string; multerConfig: MulterOptions; - supportedMimeTypes: string[]; swaggerConfig: SwaggerOptions; accessTokenConfig: SignOptions; + storageAPI: AvailableStorageAPI; refreshTokenConfig: SignOptions; googleAPIConfig?: GoogleAPIConfig; paginationConfig: PaginationConfig; + supportedMimeTypes: SupportedMimeTypes; } const config: Config = { @@ -34,7 +37,9 @@ const config: Config = { requestLogFormat: 'tiny', baseUrl: process.env.BASE_URL || '', port: +(process.env?.PORT || 3000), - storageAPI: process.env.STORAGE_API || 'local', + storageAPI: + (process.env.STORAGE_API?.trim().toUpperCase() as AvailableStorageAPI) || + AvailableStorageAPI.LOCAL, accessTokenSecret: process.env.ACCESS_TOKEN_SECRET || '', refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET || '', accessTokenConfig: { @@ -46,10 +51,30 @@ const config: Config = { expiresIn: 7 * 24 * 60 * 60, }, corsConfig: { - origin: process.env.APP_BASE_URL, credentials: true, + origin: async (origin, callback) => { + if (!origin) { + callback(null, false); + return; + } + try { + const domains = await apps.fetchAllowedDomains(); + for (let domain of domains) { + if (domain.test(origin)) { + callback(null, true); + return; + } + } + callback(null, false); + } catch (err) { + callback(err, false); + } + }, + }, + supportedMimeTypes: { + image: ['image/jpeg', 'image/png'], + reconstruction: ['text/plain'], }, - supportedMimeTypes: ['image/jpeg', 'image/png'], uploadDirectory: resolve(__dirname, '../uploads'), multerConfig: { dest: resolve(__dirname, '../uploads', 'tmp'), diff --git a/src/controllers/auth.ts b/src/controllers/auth.ts index 8960f08..36634bd 100644 --- a/src/controllers/auth.ts +++ b/src/controllers/auth.ts @@ -3,12 +3,12 @@ import { NextFunction, Request, Response } from 'express'; import User from '../models/User'; import Token from '../models/Token'; -import authService from '../services/auth'; import { NewUser } from '../domain/NewUser'; import tokenService from '../services/tokens'; import UserResponse from '../domain/UserResponse'; +import userAuthService from '../services/userAuth'; import { LoginCredential, TokenCredential } from '../domain/login'; -import { AuthenticatedRequest } from '../middlewares/authenticate'; +import { AuthenticatedRequest } from '../middlewares/authenticateUser'; const debug: Debugger = Debug('threedify:controller:auth'); @@ -18,7 +18,7 @@ export async function register( next: NextFunction ) { try { - let user: User | undefined = await authService.createNewUser(req.body); + let user: User | undefined = await userAuthService.createNewUser(req.body); if (user) { res.json({ @@ -50,7 +50,7 @@ export async function login( ) { try { debug('Authenticating user.'); - let user: User | undefined = await authService.login(req.body); + let user: User | undefined = await userAuthService.login(req.body); if (user) { debug('Generating tokens for user.'); diff --git a/src/controllers/reconstructions.ts b/src/controllers/reconstructions.ts index 4a9b006..ed4dce3 100644 --- a/src/controllers/reconstructions.ts +++ b/src/controllers/reconstructions.ts @@ -1,14 +1,21 @@ +import { Readable } from 'stream'; +import { lookup } from 'mime-types'; import Debug, { Debugger } from 'debug'; import { Request, NextFunction, Response } from 'express'; +import StorageAPI from '../domain/StorageAPI'; +import { getStorageAPI } from '../utils/storage'; import Reconstruction from '../models/Reconstruction'; +import { getUploadDirectory } from '../utils/uploads'; import PaginationQuery from '../domain/PaginationQuery'; import PaginatedResult from '../domain/PaginatedResult'; import { getPaginationQuery } from '../utils/pagination'; import NewReconstruction from '../domain/NewReconstruction'; import reconstructionService from '../services/reconstructions'; -import { AuthRequestWithFiles } from '../middlewares/uploadImage'; +import ReconstructionState from '../domain/ReconstructionState'; +import { AuthRequestWithImages } from '../middlewares/uploadImage'; import ReconstructionCreationResponse from '../domain/ReconstructionCreationResponse'; +import { AuthRequestWithReconstructionFile } from '../middlewares/uploadReconstruction'; const debug: Debugger = Debug('threedify:controller:reconstructions'); @@ -52,6 +59,7 @@ export async function reconstruction( next: NextFunction ) { try { + debug('Fetching requested reconstruction: %d', +req.params.id); let reconstruction: | Reconstruction | undefined = await reconstructionService.fetchReconstructionById( @@ -78,6 +86,173 @@ export async function reconstruction( } } +export async function reconstructionFailed( + req: Request, + res: Response, + next: NextFunction +) { + try { + debug('Fetching requested reconstruction: %d', +req.params.id); + let reconstruction: + | Reconstruction + | undefined = await reconstructionService.fetchReconstructionById( + +req.params.id + ); + + if (reconstruction) { + // TODO: Add process logs for the reconstruction and failed state. + + debug('Setting state of reconstruction to in queue.'); + await reconstructionService.setState( + reconstruction, + ReconstructionState.INQUEUE + ); + + res.sendStatus(200); + return; + } + + next({ + status: 404, + message: 'Reconstruction not found.', + }); + } catch (err) { + debug('ERROR: %O', err); + + next({ + status: 500, + message: 'Error occurred while updating reconstruction state.', + ...err, + }); + } +} + +export async function reconstructionCompleted( + req: Request, + res: Response, + next: NextFunction +) { + const authReq: AuthRequestWithReconstructionFile = req as AuthRequestWithReconstructionFile; + + try { + debug('Fetching requested reconstruction: %d', +req.params.id); + let reconstruction: + | Reconstruction + | undefined = await reconstructionService.fetchReconstructionById( + +req.params.id + ); + + if (reconstruction) { + // TODO: Add process logs for the reconstruction and completed state. + + debug('Setting state of reconstruction to completed.'); + await reconstructionService.markAsCompleted( + reconstruction, + authReq.reconstructionFileName + ); + + res.sendStatus(200); + return; + } + + next({ + status: 404, + message: 'Reconstruction not found.', + }); + } catch (err) { + debug('ERROR: %O', err); + + next({ + status: 500, + message: 'Error occurred while updating reconstruction state.', + ...err, + }); + } +} + +export async function reconstructionFile( + req: Request, + res: Response, + next: NextFunction +) { + try { + debug('Fetching requested reconstruction: %d', +req.params.id); + let reconstruction: + | Reconstruction + | undefined = await reconstructionService.fetchReconstructionById( + +req.params.id, + true + ); + + debug('Check if reconstruction exists and completed.'); + if (reconstruction && reconstruction.reconstructionFile) { + const storageAPI: StorageAPI = getStorageAPI(); + const filePath: string = await storageAPI.getFilePath( + getUploadDirectory(), + reconstruction.reconstructionFile + ); + + debug('Check if reconstruction file exists.'); + if (await storageAPI.fileExists(filePath)) { + let stream: Readable = await storageAPI.openReadStream(filePath); + + res.setHeader( + 'Content-Type', + lookup(reconstruction.reconstructionFile) || 'text/plain' + ); + stream.pipe(res); + + return; + } + } + + next({ + status: 404, + message: 'Reconstruction file not found.', + }); + } catch (err) { + debug('ERROR: %O', err); + + next({ + status: 500, + message: 'Error occurred while fetching reconstruction file.', + ...err, + }); + } +} + +export async function reconstructionBatch( + req: Request, + res: Response, + next: NextFunction +) { + try { + let reconstruction: + | Reconstruction[] + | undefined = await reconstructionService.fetchReconstructionBatch( + +req.params.size || 10 + ); + + if (reconstruction) { + res.json(reconstruction); + return; + } + + next({ + status: 404, + message: 'Reconstructions not found.', + }); + } catch (err) { + debug('ERROR: %O', err); + + next({ + status: 500, + message: 'Error occurred while fetching reconstructions.', + ...err, + }); + } +} + export async function userReconstruction( req: Request, res: Response>, @@ -118,11 +293,11 @@ export async function create( res: Response, next: NextFunction ) { - const authReq: AuthRequestWithFiles< + const authReq: AuthRequestWithImages< {}, ReconstructionCreationResponse, NewReconstruction - > = req as AuthRequestWithFiles< + > = req as AuthRequestWithImages< {}, ReconstructionCreationResponse, NewReconstruction @@ -165,4 +340,8 @@ export default { create, reconstruction, userReconstruction, + reconstructionFile, + reconstructionBatch, + reconstructionFailed, + reconstructionCompleted, }; diff --git a/src/controllers/users.ts b/src/controllers/users.ts index 88cde43..7d81d38 100644 --- a/src/controllers/users.ts +++ b/src/controllers/users.ts @@ -4,7 +4,7 @@ import { NextFunction, Request, Response } from 'express'; import User from '../models/User'; import userService from '../services/users'; import UserResponse from '../domain/UserResponse'; -import { AuthenticatedRequest } from '../middlewares/authenticate'; +import { AuthenticatedRequest } from '../middlewares/authenticateUser'; const debug: Debugger = Debug('threedify:controller:users'); diff --git a/src/domain/AppCredential.ts b/src/domain/AppCredential.ts new file mode 100644 index 0000000..211ef0a --- /dev/null +++ b/src/domain/AppCredential.ts @@ -0,0 +1,6 @@ +export interface AppCredential { + key: string; + secret: string; +} + +export default AppCredential; diff --git a/src/domain/NewApp.ts b/src/domain/NewApp.ts new file mode 100644 index 0000000..a9d15db --- /dev/null +++ b/src/domain/NewApp.ts @@ -0,0 +1,8 @@ +export interface NewApp { + name: string; + key?: string; + domain: string; + rawSecret?: string; +} + +export default NewApp; diff --git a/src/domain/PaginationQuery.ts b/src/domain/PaginationQuery.ts index cdd8e2c..f9147bd 100644 --- a/src/domain/PaginationQuery.ts +++ b/src/domain/PaginationQuery.ts @@ -3,6 +3,12 @@ * * components: * parameters: + * q: + * in: query + * name: q + * description: The search query string. + * schema: + * type: string * page: * in: query * name: page @@ -44,6 +50,7 @@ export enum SortOrder { } export interface PaginationQuery { + q?: string; page: number; size: number; filters: string[]; diff --git a/src/domain/SupportedMimeTypes.ts b/src/domain/SupportedMimeTypes.ts new file mode 100644 index 0000000..247a60b --- /dev/null +++ b/src/domain/SupportedMimeTypes.ts @@ -0,0 +1,6 @@ +export interface SupportedMimeTypes { + image: string[]; + reconstruction: string[]; +} + +export default SupportedMimeTypes; diff --git a/src/middlewares/authenticateApp.ts b/src/middlewares/authenticateApp.ts new file mode 100644 index 0000000..acf53c5 --- /dev/null +++ b/src/middlewares/authenticateApp.ts @@ -0,0 +1,50 @@ +import Debug, { Debugger } from 'debug'; +import { NextFunction, Request, Response } from 'express'; + +import App from '../models/App'; +import appAuth from '../services/appAuth'; + +const debug: Debugger = Debug('threedify:middleware:authenticateApp'); + +export async function authenticateApp( + req: Request, + res: Response, + next: NextFunction +) { + try { + debug('Authenticating App...'); + const appId: string | undefined = req.header('x-threedify-app-id')?.trim(); + const appSecret: string | undefined = req + .header('x-threedify-app-secret') + ?.trim(); + + debug('Check if app id and secret exists.'); + if (appId && appSecret) { + const authenticatedApp: App | undefined = await appAuth.authenticate({ + key: appId, + secret: appSecret, + }); + + if (authenticatedApp) { + debug('App %s authenticated successfully.', authenticatedApp.name); + next(); + return; + } + } + + next({ + status: 401, + message: 'Invalid app id or secret.', + }); + } catch (err) { + debug('ERROR: %O', err); + + next({ + status: 500, + message: 'Error occurred while authenticating app.', + ...err, + }); + } +} + +export default authenticateApp; diff --git a/src/middlewares/authenticate.ts b/src/middlewares/authenticateUser.ts similarity index 85% rename from src/middlewares/authenticate.ts rename to src/middlewares/authenticateUser.ts index 2914d86..f2c78d9 100644 --- a/src/middlewares/authenticate.ts +++ b/src/middlewares/authenticateUser.ts @@ -3,10 +3,10 @@ import { NextFunction, Request, Response } from 'express'; import { ParamsDictionary, Query } from 'express-serve-static-core'; import User from '../models/User'; -import authService from '../services/auth'; import { TokenCredential } from '../domain/login'; +import userAuthService from '../services/userAuth'; -const debug: Debugger = Debug('threedify:middleware:authenticate'); +const debug: Debugger = Debug('threedify:middleware:authenticateUser'); export interface AuthenticatedRequest< P = ParamsDictionary, @@ -18,12 +18,13 @@ export interface AuthenticatedRequest< tokenCred: TokenCredential; } -export async function authenticate( +export async function authenticateUser( req: Request, res: Response, next: NextFunction ) { try { + debug('Authenticating User...'); const authReq: AuthenticatedRequest = req as AuthenticatedRequest; const authCode: string | undefined = authReq.header('authorization'); @@ -34,7 +35,7 @@ export async function authenticate( debug('Check if access token exists.'); if (authType.toLowerCase() === 'bearer' && accessToken) { debug('Authenticate with access token.'); - const user: User | undefined = await authService.authenticate({ + const user: User | undefined = await userAuthService.authenticate({ accessToken, }); @@ -65,4 +66,4 @@ export async function authenticate( } } -export default authenticate; +export default authenticateUser; diff --git a/src/middlewares/uploadImage.ts b/src/middlewares/uploadImage.ts index 4b0ea70..825521a 100644 --- a/src/middlewares/uploadImage.ts +++ b/src/middlewares/uploadImage.ts @@ -2,19 +2,20 @@ import Debug, { Debugger } from 'debug'; import { NextFunction, Request, Response } from 'express'; import { ParamsDictionary, Query } from 'express-serve-static-core'; +import config from '../config'; import Image from '../models/Image'; import { upload } from '../utils/uploads'; import imageService from '../services/images'; import { multiple, single } from '../utils/multer'; -import { AuthenticatedRequest } from './authenticate'; +import { AuthenticatedRequest } from './authenticateUser'; import { ValidationErrorItem, ValidationErrorResponse, } from '../domain/validations'; -const debug: Debugger = Debug('threedify:services:uploadImage'); +const debug: Debugger = Debug('threedify:middleware:uploadImage'); -export interface RequestWithFiles< +export interface RequestWithImages< P = ParamsDictionary, ResBody = any, ReqBody = any, @@ -24,21 +25,21 @@ export interface RequestWithFiles< fileValidationErrors: ValidationErrorItem[]; } -export type AuthRequestWithFiles< +export type AuthRequestWithImages< P = ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = Query > = AuthenticatedRequest & - RequestWithFiles; + RequestWithImages; async function uploadImage( file: Express.Multer.File, key: string, - authReq: AuthRequestWithFiles + authReq: AuthRequestWithImages ) { - debug('Uploading file: %s', file.originalname); - const fileName: string = await upload(file); + debug('Uploading image: %s', file.originalname); + const fileName: string = await upload(file, config.supportedMimeTypes.image); authReq.images = authReq.images || []; authReq.fileValidationErrors = authReq.fileValidationErrors || []; @@ -72,7 +73,7 @@ export function uploadSingleImage(key: string) { res: Response, next: NextFunction ) => { - const authReq: AuthRequestWithFiles = req as AuthRequestWithFiles; + const authReq: AuthRequestWithImages = req as AuthRequestWithImages; try { debug('Uploading file for: %s', key); const file: Express.Multer.File = req.file; @@ -97,7 +98,7 @@ export function uploadSingleImage(key: string) { errors: [ { [key]: { - message: 'Image file not uploaded.', + message: 'Image not uploaded.', }, }, ], @@ -106,7 +107,7 @@ export function uploadSingleImage(key: string) { debug('%O', err); next({ status: 500, - message: 'Error occurred while uploading file.', + message: 'Error occurred while uploading image.', err: err, }); } @@ -122,7 +123,7 @@ export function uploadImages(key: string) { res: Response, next: NextFunction ) => { - const authReq: AuthRequestWithFiles = req as AuthRequestWithFiles; + const authReq: AuthRequestWithImages = req as AuthRequestWithImages; try { debug('Uploading files for: %s', key); if (req.files.length > 0) { @@ -146,7 +147,7 @@ export function uploadImages(key: string) { errors: [ { [key]: { - message: 'Image files not uploaded.', + message: 'Images not uploaded.', }, }, ], @@ -155,7 +156,7 @@ export function uploadImages(key: string) { debug('%O', err); next({ status: 500, - message: 'Error occurred while uploading file.', + message: 'Error occurred while uploading image.', err: err, }); } diff --git a/src/middlewares/uploadReconstruction.ts b/src/middlewares/uploadReconstruction.ts new file mode 100644 index 0000000..bc8bb30 --- /dev/null +++ b/src/middlewares/uploadReconstruction.ts @@ -0,0 +1,112 @@ +import Debug, { Debugger } from 'debug'; +import { NextFunction, Request, Response } from 'express'; +import { ParamsDictionary, Query } from 'express-serve-static-core'; + +import config from '../config'; +import { single } from '../utils/multer'; +import { upload } from '../utils/uploads'; +import { AuthenticatedRequest } from './authenticateUser'; +import { + ValidationErrorItem, + ValidationErrorResponse, +} from '../domain/validations'; + +const debug: Debugger = Debug('threedify:middleware:uploadImage'); + +export interface RequestWithReconstructionFile< + P = ParamsDictionary, + ResBody = any, + ReqBody = any, + ReqQuery = Query +> extends Request { + reconstructionFileName: string; + fileValidationErrors: ValidationErrorItem[]; +} + +export type AuthRequestWithReconstructionFile< + P = ParamsDictionary, + ResBody = any, + ReqBody = any, + ReqQuery = Query +> = AuthenticatedRequest & + RequestWithReconstructionFile; + +async function uploadFile( + file: Express.Multer.File, + key: string, + authReq: AuthRequestWithReconstructionFile +) { + debug('Uploading reconstruction: %s, %s', file.originalname, file.mimetype); + const fileName: string = await upload( + file, + config.supportedMimeTypes.reconstruction + ); + + authReq.fileValidationErrors = authReq.fileValidationErrors || []; + + if (fileName) { + authReq.reconstructionFileName = fileName; + } else { + debug('File validation failed.'); + authReq.fileValidationErrors.push({ + [key]: { + value: { filename: file.originalname, mimetype: file.mimetype }, + message: "Uploaded file isn't a valid reconstruction file.", + }, + }); + } +} + +export function uploadReconstruction(key: string) { + return [ + single(key), + async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const authReq: AuthRequestWithReconstructionFile = req as AuthRequestWithReconstructionFile; + try { + debug('Uploading file for: %s', key); + const file: Express.Multer.File = req.file; + + if (file) { + await uploadFile(file, key, authReq); + + if (authReq.fileValidationErrors.length === 1) { + res.status(422); + res.json({ + errors: authReq.fileValidationErrors, + }); + return; + } + + next(); + return; + } + + res.status(422); + res.json({ + errors: [ + { + [key]: { + message: 'Reconstruction file not uploaded.', + }, + }, + ], + }); + } catch (err) { + debug('%O', err); + next({ + status: 500, + message: 'Error occurred while uploading reconstruction file.', + err: err, + }); + } + }, + ]; +} + +export default { + uploadReconstruction, +}; diff --git a/src/models/App.ts b/src/models/App.ts new file mode 100644 index 0000000..5a74080 --- /dev/null +++ b/src/models/App.ts @@ -0,0 +1,20 @@ +import { Model } from 'objection'; + +const TABLE_NAME: string = 'apps'; + +export class App extends Model { + id!: number; + key!: string; + name!: string; + secret!: string; + domain!: string; + + createdAt?: Date; + updatedAt?: Date; + + static get tableName() { + return TABLE_NAME; + } +} + +export default App; diff --git a/src/models/BaseModel.ts b/src/models/BaseModel.ts new file mode 100644 index 0000000..b2c715d --- /dev/null +++ b/src/models/BaseModel.ts @@ -0,0 +1,9 @@ +import { Model } from 'objection'; + +export class BaseModel extends Model { + static get filters(): string[] { + return []; + } +} + +export default BaseModel; diff --git a/src/models/Image.ts b/src/models/Image.ts index fa19e2a..5925d44 100644 --- a/src/models/Image.ts +++ b/src/models/Image.ts @@ -1,5 +1,5 @@ -import { Model } from 'objection'; import User from './User'; +import BaseModel from './BaseModel'; const TABLE_NAME: string = 'images'; @@ -48,7 +48,7 @@ const TABLE_NAME: string = 'images'; * items: * $ref: '#/components/schemas/Image' */ -export class Image extends Model { +export class Image extends BaseModel { id!: number; fileName!: string; mimetype!: string; @@ -66,7 +66,7 @@ export class Image extends Model { static get relationMappings() { return { uploadedByUser: { - relation: Model.BelongsToOneRelation, + relation: BaseModel.BelongsToOneRelation, modelClass: User, join: { from: 'images.uploadedBy', diff --git a/src/models/Reconstruction.ts b/src/models/Reconstruction.ts index a7b1c5c..69dfc6f 100644 --- a/src/models/Reconstruction.ts +++ b/src/models/Reconstruction.ts @@ -1,7 +1,8 @@ -import { Model, QueryBuilder } from 'objection'; +import { QueryBuilder } from 'objection'; import User from './User'; import Image from './Image'; +import BaseModel from './BaseModel'; import ReconstructionState from '../domain/ReconstructionState'; const TABLE_NAME: string = 'reconstructions'; @@ -16,6 +17,11 @@ const TABLE_NAME: string = 'reconstructions'; * required: * - id * - name + * - state + * - createdBy + * - createdByUser + * - createdAt + * - updatedAt * properties: * id: * type: number @@ -73,11 +79,12 @@ const TABLE_NAME: string = 'reconstructions'; * items: * $ref: '#/components/schemas/Reconstruction' */ -export class Reconstruction extends Model { +export class Reconstruction extends BaseModel { id!: number; name!: string; createdBy!: number; state!: ReconstructionState; + reconstructionFile?: string; createdAt?: Date; updatedAt?: Date; @@ -91,6 +98,27 @@ export class Reconstruction extends Model { static get modifiers() { return { + search(builder: QueryBuilder) { + const { ref } = Reconstruction; + const q = builder.context().queryString; + + builder.where(ref('name'), 'like', `%${q}%`); + }, + defaultSelect(builder: QueryBuilder) { + const { ref } = Reconstruction; + builder.select( + ref('id'), + ref('name'), + ref('state'), + ref('createdBy'), + ref('createdAt'), + ref('updatedAt') + ); + }, + withReconstructionFile(builder: QueryBuilder) { + const { ref } = Reconstruction; + builder.select(ref('reconstructionFile')); + }, inQueue(builder: QueryBuilder) { const { ref } = Reconstruction; @@ -114,10 +142,14 @@ export class Reconstruction extends Model { }; } + static get filters(): string[] { + return ['search', 'inQueue', 'inProgress', 'completed', 'orderByCreatedAt']; + } + static get relationMappings() { return { createdByUser: { - relation: Model.BelongsToOneRelation, + relation: BaseModel.BelongsToOneRelation, modelClass: User, join: { from: 'reconstructions.createdBy', @@ -125,7 +157,7 @@ export class Reconstruction extends Model { }, }, images: { - relation: Model.ManyToManyRelation, + relation: BaseModel.ManyToManyRelation, modelClass: Image, join: { from: 'reconstructions.id', diff --git a/src/models/Token.ts b/src/models/Token.ts index 2125770..188d570 100644 --- a/src/models/Token.ts +++ b/src/models/Token.ts @@ -1,10 +1,9 @@ -import { Model } from 'objection'; - import User from './User'; +import BaseModel from './BaseModel'; const TABLE_NAME: string = 'tokens'; -export class Token extends Model { +export class Token extends BaseModel { id!: number; userId!: number; accessToken!: string; @@ -22,7 +21,7 @@ export class Token extends Model { static get relationMappings() { return { user: { - relation: Model.BelongsToOneRelation, + relation: BaseModel.BelongsToOneRelation, modelClass: User, join: { from: 'tokens.userId', diff --git a/src/models/User.ts b/src/models/User.ts index 0a7e3ea..fa6d3b7 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -1,4 +1,6 @@ -import { Model, QueryBuilder } from 'objection'; +import { QueryBuilder } from 'objection'; + +import BaseModel from './BaseModel'; const TABLE_NAME: string = 'users'; @@ -51,7 +53,7 @@ const TABLE_NAME: string = 'users'; * items: * $ref: '#/components/schemas/User' */ -export class User extends Model { +export class User extends BaseModel { id!: number; email!: string; username!: string; diff --git a/src/routers/auth.ts b/src/routers/auth.ts index 1cdf087..e3c3d75 100644 --- a/src/routers/auth.ts +++ b/src/routers/auth.ts @@ -1,8 +1,8 @@ import { Router } from 'express'; import AuthController from '../controllers/auth'; -import authenticate from '../middlewares/authenticate'; import validateNewUser from '../middlewares/validateNewUser'; +import authenticateUser from '../middlewares/authenticateUser'; import checkUniqueEmail from '../middlewares/checkUniqueEmail'; import checkUniqueUsername from '../middlewares/checkUniqueUsername'; import validateLoginCredential from '../middlewares/validateLoginCredential'; @@ -71,7 +71,7 @@ router.post('/login', validateLoginCredential, AuthController.login); * 500: * $ref: '#/components/responses/HTTPError' */ -router.delete('/logout', authenticate, AuthController.logout); +router.delete('/logout', authenticateUser, AuthController.logout); /** * @swagger diff --git a/src/routers/index.ts b/src/routers/index.ts index 02d3659..ecc1767 100644 --- a/src/routers/index.ts +++ b/src/routers/index.ts @@ -7,13 +7,15 @@ import userRouter from './users'; import imageRouter from './images'; import reconstructionRouter from './reconstructions'; +import authenticateApp from '../middlewares/authenticateApp'; + const router: Router = Router(); router.use('/', homeRouter); router.use('/docs', docsRouter); -router.use('/auth', authRouter); -router.use('/users', userRouter); -router.use('/images', imageRouter); -router.use('/reconstructions', reconstructionRouter); +router.use('/auth', authenticateApp, authRouter); +router.use('/users', authenticateApp, userRouter); +router.use('/images', authenticateApp, imageRouter); +router.use('/reconstructions', authenticateApp, reconstructionRouter); export default router; diff --git a/src/routers/reconstructions.ts b/src/routers/reconstructions.ts index 5acedba..a341cf8 100644 --- a/src/routers/reconstructions.ts +++ b/src/routers/reconstructions.ts @@ -1,13 +1,17 @@ import { Router } from 'express'; -import authenticate from '../middlewares/authenticate'; import { uploadImages } from '../middlewares/uploadImage'; +import authenticateUser from '../middlewares/authenticateUser'; import ReconstructionController from '../controllers/reconstructions'; +import { uploadReconstruction } from '../middlewares/uploadReconstruction'; import validateNewReconstruction from '../middlewares/validateNewReconstruction'; const router: Router = Router(); const imageUploadMiddlewares = uploadImages('images'); +const reconstructionUploadMiddlewares = uploadReconstruction( + 'reconstruction_file' +); /** * @swagger @@ -16,6 +20,7 @@ const imageUploadMiddlewares = uploadImages('images'); * get: * description: End point to fetch all reconstructions. * parameters: + * - $ref: '#/components/parameters/q' * - $ref: '#/components/parameters/page' * - $ref: '#/components/parameters/size' * - $ref: '#/components/parameters/reconstruction_filters' @@ -50,6 +55,34 @@ router.get('/', ReconstructionController.index); */ router.get('/:id', ReconstructionController.reconstruction); +/** + * @swagger + * + * /reconstructions/{id}/reconstructionFile: + * get: + * description: End point to fetch reconstruction file. + * parameters: + * - name: id + * in: path + * description: Id of reconstruction. + * responses: + * 200: + * description: Reconstruction file. + * content: + * application/octet-stream: + * schema: + * type: string + * format: binary + * 404: + * $ref: '#/components/responses/HTTPError' + * 500: + * $ref: '#/components/responses/HTTPError' + */ +router.get( + '/:id/reconstructionFile', + ReconstructionController.reconstructionFile +); + /** * @swagger * @@ -72,11 +105,99 @@ router.get('/:id', ReconstructionController.reconstruction); */ router.post( '/create', - authenticate, + authenticateUser, imageUploadMiddlewares[0], validateNewReconstruction, imageUploadMiddlewares[1], ReconstructionController.create ); +/** + * @swagger + * + * /reconstructions/batch: + * put: + * description: End point to fetch reconstructions batch for processing. + * Also updates the reconstructions to in progress state. + * parameters: + * - $ref: '#/components/parameters/size' + * responses: + * 200: + * description: A list of queued reconstructions to process. + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/Reconstruction' + * 404: + * $ref: '#/components/responses/HTTPError' + * 500: + * $ref: '#/components/responses/HTTPError' + */ +router.put('/batch', ReconstructionController.reconstructionBatch); + +/** + * @swagger + * + * /reconstructions/{id}/failed: + * put: + * description: End point to set reconstruction state to failed. This changes the state to `IN QUEUE`. + * parameters: + * - name: id + * in: path + * description: Id of reconstruction. + * responses: + * 200: + * description: OK + * content: + * text/plain: + * schema: + * type: string + * 404: + * $ref: '#/components/responses/HTTPError' + * 500: + * $ref: '#/components/responses/HTTPError' + */ +router.put('/:id/failed', ReconstructionController.reconstructionFailed); + +/** + * @swagger + * + * /reconstructions/{id}/success: + * put: + * description: End point to set reconstruction state to completed. This expects a reconstruction file (.ply). + * parameters: + * - name: id + * in: path + * description: Id of reconstruction. + * requestBody: + * description: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: + * - reconstruction_file + * properties: + * reconstruction_file: + * type: string + * format: binary + * responses: + * 200: + * $ref: '#/components/responses/ReconstructionCreationResponse' + * 401: + * $ref: '#/components/responses/HTTPError' + * 422: + * $ref: '#/components/responses/ValidationErrorResponse' + * 500: + * $ref: '#/components/responses/HTTPError' + */ +router.put( + '/:id/success', + reconstructionUploadMiddlewares, + ReconstructionController.reconstructionCompleted +); + export default router; diff --git a/src/routers/users.ts b/src/routers/users.ts index 063d39e..89c9328 100644 --- a/src/routers/users.ts +++ b/src/routers/users.ts @@ -4,7 +4,7 @@ import { sendStatus } from '../utils/response'; import UserController from '../controllers/users'; -import authenticate from '../middlewares/authenticate'; +import authenticateUser from '../middlewares/authenticateUser'; import checkUniqueEmail from '../middlewares/checkUniqueEmail'; import checkUniqueUsername from '../middlewares/checkUniqueUsername'; import ReconstructionController from '../controllers/reconstructions'; @@ -27,7 +27,7 @@ const router: Router = Router(); * 500: * $ref: '#/components/responses/HTTPError' */ -router.get('/me', authenticate, UserController.me); +router.get('/me', authenticateUser, UserController.me); /** * @swagger @@ -47,7 +47,7 @@ router.get('/me', authenticate, UserController.me); * 500: * $ref: '#/components/responses/HTTPError' */ -router.get('/', authenticate, UserController.index); +router.get('/', authenticateUser, UserController.index); /** * @swagger @@ -71,7 +71,7 @@ router.get('/', authenticate, UserController.index); * 500: * $ref: '#/components/responses/HTTPError' */ -router.get('/:userId(\\d+)', authenticate, UserController.user); +router.get('/:userId(\\d+)', authenticateUser, UserController.user); /** * @swagger @@ -85,6 +85,7 @@ router.get('/:userId(\\d+)', authenticate, UserController.user); * - name: user_id * in: path * description: Id of user to fetch reconstructions. + * - $ref: '#/components/parameters/q' * - $ref: '#/components/parameters/page' * - $ref: '#/components/parameters/size' * - $ref: '#/components/parameters/reconstruction_filters' @@ -101,7 +102,7 @@ router.get('/:userId(\\d+)', authenticate, UserController.user); */ router.get( '/:userId(\\d+)/reconstructions', - authenticate, + authenticateUser, ReconstructionController.userReconstruction ); diff --git a/src/services/appAuth.ts b/src/services/appAuth.ts new file mode 100644 index 0000000..c8364f2 --- /dev/null +++ b/src/services/appAuth.ts @@ -0,0 +1,62 @@ +import crypto from 'crypto'; +import Debug, { Debugger } from 'debug'; + +import App from '../models/App'; +import appService from './apps'; +import uuid from '../utils/uuid'; +import NewApp from '../domain/NewApp'; +import { hash, compare } from '../utils/hash'; +import AppCredential from '../domain/AppCredential'; + +const debug: Debugger = Debug('threedify:services:appAuth'); + +export async function createNewApp( + newApp: NewApp +): Promise { + debug('Creating new app.'); + + newApp.key = uuid.generate(); + newApp.rawSecret = crypto.randomBytes(20).toString('base64'); + + debug('Hashing app secret.'); + const hashedSecret: string = await hash(newApp.rawSecret || ''); + + const app: Partial = { + key: newApp.key, + name: newApp.name, + secret: hashedSecret, + domain: newApp.domain, + }; + + debug('Inserting app record.'); + await appService.insertApp(app); + + return newApp; +} + +export async function authenticate( + appCred: AppCredential +): Promise { + debug('Check if app key and secret exists.'); + if (!appCred.key || !appCred.secret) { + return; + } + + debug('Check if the app exists.'); + const app: App | undefined = await appService.fetchAppByKey(appCred.key); + if (app) { + debug('Check if secret matches.'); + const secretMatched: boolean = await compare(appCred.secret, app.secret); + + if (secretMatched) { + return app; + } + } + + return; +} + +export default { + createNewApp, + authenticate, +}; diff --git a/src/services/apps.ts b/src/services/apps.ts new file mode 100644 index 0000000..483c6b9 --- /dev/null +++ b/src/services/apps.ts @@ -0,0 +1,31 @@ +import Debug, { Debugger } from 'debug'; + +import App from '../models/App'; + +const debug: Debugger = Debug('threedify:services:apps'); + +export async function fetchAppByKey(key: string): Promise { + debug('Fetching app with key: %s.', key); + + return await App.query().where('key', '=', key).first(); +} + +export async function insertApp(app: Partial): Promise { + debug('Inserting app.'); + + return await App.query().insert(app); +} + +export async function fetchAllowedDomains(): Promise { + debug('Fetching allowed domains..'); + + return (await App.query().select('domain')).map( + (app) => new RegExp(app.domain) + ); +} + +export default { + insertApp, + fetchAppByKey, + fetchAllowedDomains, +}; diff --git a/src/services/reconstructions.ts b/src/services/reconstructions.ts index b931d80..768636b 100644 --- a/src/services/reconstructions.ts +++ b/src/services/reconstructions.ts @@ -1,3 +1,4 @@ +import Objection from 'objection'; import Debug, { Debugger } from 'debug'; import Image from '../models/Image'; @@ -5,6 +6,7 @@ import Reconstruction from '../models/Reconstruction'; import { applyPagination } from '../utils/pagination'; import PaginationQuery from '../domain/PaginationQuery'; import PaginatedResult from '../domain/PaginatedResult'; +import ReconstructionState from '../domain/ReconstructionState'; const debug: Debugger = Debug('threedify:services:reconstructions'); @@ -14,19 +16,65 @@ export async function fetchAllReconstructions( debug('Fetching all reconstructions.'); return await applyPagination( - Reconstruction.query().withGraphFetched( - '[createdByUser(defaultSelect), images.uploadedByUser(defaultSelect)]' - ), - query + Reconstruction.query() + .modify('defaultSelect') + .withGraphFetched( + '[createdByUser(defaultSelect), images.uploadedByUser(defaultSelect)]' + ), + query, + Reconstruction.filters + ); +} + +export async function fetchReconstructionBatch( + size: number +): Promise { + return await Reconstruction.transaction( + async (trx: Objection.Transaction) => { + debug('Fetching reconstructions batch...'); + const reconstructions: + | Reconstruction[] + | undefined = await Reconstruction.query(trx) + .modify('defaultSelect') + .withGraphFetched('[images]') + .context({ sortOrder: 'ASC' }) + .modify(['orderByCreatedAt', 'inQueue']) + .limit(size); + + if (reconstructions?.length > 0) { + debug('Updating reconstructions state to in progress...'); + await Reconstruction.query(trx) + .patch({ + state: ReconstructionState.INPROGRESS, + updatedAt: new Date(), + }) + .whereIn( + 'id', + reconstructions.map((recon) => recon.id) + ); + + return reconstructions.map((recon) => { + recon.state = ReconstructionState.INPROGRESS; + return recon; + }); + } + } ); } export async function fetchReconstructionById( - id: number + id: number, + withReconstructionFile?: boolean ): Promise { debug('Fetching reconstruction with id: %d.', id); + let filters = ['defaultSelect']; + if (withReconstructionFile) { + filters.push('withReconstructionFile'); + } + return await Reconstruction.query() + .modify(filters) .where('id', '=', id) .withGraphFetched( '[createdByUser(defaultSelect), images.uploadedByUser(defaultSelect)]' @@ -42,11 +90,13 @@ export async function fetchReconstructionByUserId( return await applyPagination( Reconstruction.query() + .modify('defaultSelect') .where('createdBy', '=', userId) .withGraphFetched( '[createdByUser(defaultSelect), images.uploadedByUser(defaultSelect)]' ), - query + query, + Reconstruction.filters ); } @@ -63,6 +113,23 @@ export async function addImages( .first(); } +export async function markAsCompleted( + reconstruction: Reconstruction, + reconstructionFile: string +) { + await reconstruction.$query().patch({ + reconstructionFile: reconstructionFile, + state: ReconstructionState.COMPLETED, + }); +} + +export async function setState( + reconstruction: Reconstruction, + state: ReconstructionState +) { + await reconstruction.$query().patch({ state: state }); +} + export async function insertReconstruction( reconstruction: Partial ): Promise { @@ -70,15 +137,19 @@ export async function insertReconstruction( return await Reconstruction.query() .insertAndFetch(reconstruction) + .modify('defaultSelect') .withGraphFetched( '[createdByUser(defaultSelect), images.uploadedByUser(defaultSelect)]' ); } export default { + setState, addImages, + markAsCompleted, insertReconstruction, fetchAllReconstructions, fetchReconstructionById, + fetchReconstructionBatch, fetchReconstructionByUserId, }; diff --git a/src/services/auth.ts b/src/services/userAuth.ts similarity index 97% rename from src/services/auth.ts rename to src/services/userAuth.ts index 7502470..f4a0702 100644 --- a/src/services/auth.ts +++ b/src/services/userAuth.ts @@ -8,7 +8,7 @@ import { verifyAndDecodeAccessToken } from '../utils/tokens'; import AccessTokenPayload from '../domain/AccessTokenPayload'; import { LoginCredential, TokenCredential } from '../domain/login'; -const debug: Debugger = Debug('threedify:services:auth'); +const debug: Debugger = Debug('threedify:services:userAuth'); export async function createNewUser( newUser: NewUser diff --git a/src/utils/pagination.ts b/src/utils/pagination.ts index 9f4396f..0bbb988 100644 --- a/src/utils/pagination.ts +++ b/src/utils/pagination.ts @@ -29,6 +29,11 @@ export function getPaginationQuery(query: any): PaginationQuery { (paginationQuery.filters as string | undefined)?.split(',') || defaultOption.filters; + if (paginationQuery.q) { + debug('Adding search filter.'); + filters.push('search'); + } + debug('Extracting pagination sort order.'); let sortOrder = (paginationQuery.order as string)?.toUpperCase(); if (!Object.values(SortOrder).includes(sortOrder as SortOrder)) { @@ -36,6 +41,7 @@ export function getPaginationQuery(query: any): PaginationQuery { } return { + q: paginationQuery.q, filters: filters.map((x) => x.trim()), page: +paginationQuery.page || defaultOption.page, size: +paginationQuery.size || defaultOption.size, @@ -45,12 +51,19 @@ export function getPaginationQuery(query: any): PaginationQuery { export async function applyPagination( schema: QueryBuilder, - query: PaginationQuery + query: PaginationQuery, + availableFilters?: string[] ): Promise | undefined> { + let availableModelFilters = availableFilters || []; + debug('Applying pagination.'); const result: Page = await schema - .context({ sortOrder: query.order }) - .modify(query.filters) + .context({ sortOrder: query.order, queryString: query.q }) + .modify( + query.filters.filter((filter) => { + return availableModelFilters.includes(filter); + }) + ) .page(query.page - 1, query.size); if (result.results.length > 0) { diff --git a/src/utils/uploads.ts b/src/utils/uploads.ts index a888b7b..876ee00 100644 --- a/src/utils/uploads.ts +++ b/src/utils/uploads.ts @@ -14,8 +14,11 @@ function getRandomString(): string { return randomBytes(20).toString('hex'); } -export function isFileSupported(mimeType: string): boolean { - return config.supportedMimeTypes.includes(mimeType); +export function isFileSupported( + mimeType: string, + supportedMimeTypes: string[] +): boolean { + return supportedMimeTypes.includes(mimeType); } export function getUploadDirectory(): string { @@ -56,12 +59,15 @@ export function cleanUp(tmpFilePath: string) { unlinkSync(tmpFilePath); } -export async function upload(file: Express.Multer.File): Promise { +export async function upload( + file: Express.Multer.File, + supportedMimeTypes: string[] +): Promise { debug('Uploading file.'); let fileName: string = ''; let filePath: string = ''; - if (isFileSupported(file.mimetype)) { + if (isFileSupported(file.mimetype, supportedMimeTypes)) { [fileName, filePath] = await getFileName(file.mimetype); await saveFile(file.path, filePath, file.mimetype); } diff --git a/src/utils/uuid.ts b/src/utils/uuid.ts new file mode 100644 index 0000000..a59e5ab --- /dev/null +++ b/src/utils/uuid.ts @@ -0,0 +1,9 @@ +import { v4 as uuidv4 } from 'uuid'; + +export function generate() { + return uuidv4(); +} + +export default { + generate, +}; diff --git a/src/validationSchema/NewAppValidationSchema.ts b/src/validationSchema/NewAppValidationSchema.ts new file mode 100644 index 0000000..f2b1165 --- /dev/null +++ b/src/validationSchema/NewAppValidationSchema.ts @@ -0,0 +1,11 @@ +import Joi from 'joi'; +import { NewApp } from '../domain/NewApp'; + +export const NewAppValidationSchema: Joi.ObjectSchema = Joi.object< + NewApp +>({ + name: Joi.string().required(), + domain: Joi.string().required(), +}); + +export default NewAppValidationSchema; diff --git a/yarn.lock b/yarn.lock index ac9e7b8..5f2e4b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -282,6 +282,11 @@ "@types/express" "*" "@types/serve-static" "*" +"@types/uuid@^8.3.0": + version "8.3.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f" + integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ== + "@typescript-eslint/eslint-plugin@^3.8.0": version "3.10.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.10.1.tgz#7e061338a1383f59edc204c605899f93dc2e2c8f" @@ -4051,6 +4056,11 @@ uuid@^8.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.1.tgz#2ba2e6ca000da60fce5a196954ab241131e05a31" integrity sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg== +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + v8-compile-cache@^2.0.3: version "2.1.1" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz#54bc3cdd43317bca91e35dcaf305b1a7237de745"