diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4ee093f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,22 @@ +name: Build +on: + push: + branches: + - main # The default branch + - v4.* # The other version branches to be analyzed + - test # long-lived test branch + pull_request: + types: [opened, synchronize, reopened] +jobs: + sonarcloud: + name: SonarQube Cloud + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: SonarQube Cloud Scan + uses: SonarSource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 5bcdebc..6b16075 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,47 +1,30 @@ -# ---- Deps ---- -FROM groupclaes/npm AS depedencies - -# change the working directory to new exclusive app folder +# ---- deps ---- +FROM groupclaes/esbuild:v0.24.0 AS deps WORKDIR /usr/src/app -# copy package file COPY package.json ./package.json COPY .npmrc ./.npmrc -# install node packages -RUN npm install --omit=dev - -# ---- Build ---- -FROM depedencies AS build +RUN npm install --omit=dev --ignore-scripts -# copy project +# ---- build ---- +FROM deps AS build COPY index.ts ./index.ts COPY src/ ./src -# install node packages -RUN npm install - -# create esbuild package -RUN esbuild ./index.ts --bundle --platform=node --minify --packages=external --external:'./config' --outfile=index.min.js - -# --- release --- -FROM groupclaes/node +RUN npm install --ignore-scripts && npm run build +# ---- final ---- +FROM groupclaes/node:20 # add lib form pdf and image manipulation USER root RUN apk add --no-cache file imagemagick -# set current user to node USER node - -# change the working directory to new exclusive app folder WORKDIR /usr/src/app -# copy dependencies -COPY --chown=node:node --from=depedencies /usr/src/app ./ - -# copy project file -COPY --chown=node:node --from=build /usr/src/app/index.min.js ./ +# removed --chown=node:node +COPY --from=deps /usr/src/app ./ +COPY --from=build /usr/src/app/index.min.js ./ -# command to run when intantiate an image CMD ["node","index.min.js"] \ No newline at end of file diff --git a/README.md b/README.md index 08dd809..06379fc 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,30 @@ -# Manage PCM api contains all controllers for the management ui available on pcm.groupclaes.be +# Management API for PCM [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=groupclaes_pcm-api-manage&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=groupclaes_pcm-api-manage) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=groupclaes_pcm-api-manage&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=groupclaes_pcm-api-manage) [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=groupclaes_pcm-api-manage&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=groupclaes_pcm-api-manage) [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=groupclaes_pcm-api-manage&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=groupclaes_pcm-api-manage) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=groupclaes_pcm-api-manage&metric=bugs)](https://sonarcloud.io/summary/new_code?id=groupclaes_pcm-api-manage) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=groupclaes_pcm-api-manage&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=groupclaes_pcm-api-manage) -added jose, check JWT token using JWKS, on ISSUER \ No newline at end of file +This api contains all controllers for the management interface available on https://pcm.groupclaes.be/ + +## Available controllers & routes +- /access-log + - GET / #retrieve list of access known in DB +- /attributes + - GET / #retrieve list of access known in DB +- /browse + - GET / +- /check + - GET / #get a list of items and datasheet availablility for the provided supplier_id + - GET /search #query suppliers based on search parameters + - POST /export #generate export csv based on body send in request +- /directories + - GET / # list of directories known in DB + - GET /:id #retrieve details of request directory #id +- /document + - +- /languages + - GET / #retrieve list of languages known in DB +- /profile + - GET /dashboard #retrieve user's dashboard view; showing items last changed +- /search + - GET / #execute search query +- /upload + - POST / #upload content to PCM +- /users + - GET / #retrieve list of users known in DB \ No newline at end of file diff --git a/package.json b/package.json index 78ab0b0..026910d 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "author": "Jamie Vangeysel", "license": "MIT", "scripts": { + "build": "esbuild ./index.ts --bundle --platform=node --minify --packages=external --external:'./config' --outfile=index.min.js", "test": "tap --reporter=list --show-full-coverage" }, "dependencies": { diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..0b4f6d7 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,15 @@ +sonar.projectKey=groupclaes_pcm-api-manage +sonar.organization=groupclaes +sonar.coverage.skip=true +sonar.coverage.exclusions=**/* + +# This is the name and version displayed in the SonarCloud UI. +#sonar.projectName=pcm-api-manage +#sonar.projectVersion=1.0 + + +# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. +#sonar.sources=. + +# Encoding of the source code. Default is default system encoding +#sonar.sourceEncoding=UTF-8 \ No newline at end of file diff --git a/src/controllers/browse.controller.ts b/src/controllers/browse.controller.ts index 7d26c5f..20cd354 100644 --- a/src/controllers/browse.controller.ts +++ b/src/controllers/browse.controller.ts @@ -8,7 +8,7 @@ declare module 'fastify' { export interface FastifyInstance { getSqlPool: (name?: string) => Promise } - + export interface FastifyRequest { jwt: JWTPayload hasRole: (role: string) => boolean @@ -61,39 +61,4 @@ export default async function (fastify: FastifyInstance) { return reply.error('failed to get browse view!') } }) - - /** - * Get browse page content - * @route GET /{APP_VERSION}/manage/browse/breadcrumbs - */ - fastify.get('/breadcrumbs', async function (request: FastifyRequest<{ - Querystring: { - directory: number - } - }>, reply: FastifyReply) { - const start = performance.now() - - if (!request.jwt?.sub) - return reply.fail({ jwt: 'missing authorization' }, 401) - - if (!request.hasPermission('read', 'GroupClaes.PCM/browse')) - return reply.fail({ role: 'missing permission' }, 403) - - try { - const pool = await fastify.getSqlPool() - const repo = new Browse(request.log, pool) - - const result = await repo.getBreadcrumbs(request.query.directory, request.jwt.sub) - - if (result.verified) { - if (result.error) return reply.error(result.error) - - return reply.success({ breadcrumbs: result.breadcrumbs }) - } - return reply.error('Session has expired!', 401, performance.now() - start) - } catch (err) { - request.log.error({ err }, 'failed to get breadcrumbs!') - return reply.error('failed to get breadcrumbs!') - } - }) } \ No newline at end of file diff --git a/src/controllers/document.controller.ts b/src/controllers/document.controller.ts index 7f89b6a..4594eeb 100644 --- a/src/controllers/document.controller.ts +++ b/src/controllers/document.controller.ts @@ -88,7 +88,13 @@ export default async function (fastify: FastifyInstance) { } } - return reply.success({ document: result.result }, 200, performance.now() - start) + let response: { document: any, breadcrumbs?: any } = { + document: result.result + } + if (result.breadcrumbs) + response.breadcrumbs = result.breadcrumbs + + return reply.success(response, 200, performance.now() - start) } return reply.error('Session has expired!', 401, performance.now() - start) diff --git a/src/controllers/search.controller.ts b/src/controllers/search.controller.ts index f5a3802..e4fb448 100644 --- a/src/controllers/search.controller.ts +++ b/src/controllers/search.controller.ts @@ -24,8 +24,8 @@ declare module 'fastify' { export default async function (fastify: FastifyInstance) { /** - * Get all attribute entries from DB - * @route GET /{APP_VERSION}/manage/languages + * Search + * @route GET /{APP_VERSION}/manage/search */ fastify.get('', async function (request: FastifyRequest<{ Querystring: { diff --git a/src/controllers/upload.controller.ts b/src/controllers/upload.controller.ts index 86019d2..0ef446b 100644 --- a/src/controllers/upload.controller.ts +++ b/src/controllers/upload.controller.ts @@ -18,7 +18,7 @@ const deleteOnDirs = [ 231 ] -import Document, { DBResultSet } from '../repositories/document.repository' +import Document, { DBResultSet, IPostedDocument } from '../repositories/document.repository' import sql from 'mssql' import { env } from 'process' @@ -56,17 +56,7 @@ export default async function (fastify: FastifyInstance) { const start = performance.now() let error - - request.log.info({ client_ip: request.headers['x-client-ip'] }, 'the client ip is') - - const ip_address = request.headers['x-client-ip']?.toString().split(',')[0] - if (ip_address && ip_address === '172.18.15.9') { - request.jwt = { - sub: '0', - roles: ['admin:GroupClaes.PCM/*'] - } - request.hasPermission = (r, s) => true - } + checkIfDevserver(request) if (!request.jwt?.sub) return reply.fail({ jwt: 'missing authorization' }, 401) @@ -109,28 +99,15 @@ export default async function (fastify: FastifyInstance) { fs.mkdirSync(`${env['DATA_PATH']}/content/${uuid.substring(0, 2)}/${uuid}`, { recursive: true }) await pump(data.file, fs.createWriteStream(_fn)) - let dt: Date | undefined - - if (deleteOnDirs.some(e => e == request.query.directory_id)) { - // try parse date - const fn = path.parse(data.filename).name - const last8 = fn.substring(fn.length - 8) - if (/^[0-9]{8}$/.test(last8.toLocaleLowerCase())) { - const year = parseInt(last8.substring(0, 4)) - const month = parseInt(last8.substring(4, 6)) - const day = parseInt(last8.substring(6, 8)) - - dt = new Date(Date.UTC(year, month - 1, day)) - } - } - let mimetype = data.mimetype let filename = data.filename let filesize = data.file.bytesRead + const dt = deleteOnDate(request.query.directory_id, path.parse(filename).name) + + // filesize = convertImage(mimetype, filename, _fn, uuid) // check if the uploaded file is a jpg or png, if so convert the file to webp. // converted files will have an extra file in directory for safekeeping: file_source - //if (request.jwt.sub === '1') { if ([ 'image/png', 'image/jpeg', @@ -148,16 +125,6 @@ export default async function (fastify: FastifyInstance) { }) .toBuffer() - // http://pcm.groupclaes.be/v4/i/mac/artikel/foto/ ?s=thumb_large&swp - // const is_smaller = buffer.length < filesize - // if (!is_smaller) - // buffer = await sharp(_ofn) - // .withMetadata() - // .webp({ - // effort: 6 - // }) - // .toBuffer() - fs.writeFileSync(_fn, buffer) // remove file_source fs.unlinkSync(_ofn) @@ -185,14 +152,24 @@ export default async function (fastify: FastifyInstance) { // check if the file was not deleted } } - //} + + const document: IPostedDocument = { + uuid, + directory_id: request.query.directory_id, + name: filename, + mime_type: mimetype, + size: filesize, + object_type, + document_type, + deleted_on: dt + } if (!request.query.mode) { - results.push(await repo.create(uuid, request.query.directory_id, filename, mimetype, filesize, object_type, document_type, dt, request.jwt.sub)) + results.push(await repo.create(undefined, document, request.jwt.sub)) } else if (request.query.id) { switch (request.query.mode) { - case 'update': - const result = await repo.createUpdate(request.query.id, uuid, request.query.directory_id, filename, mimetype, filesize, object_type, document_type, dt, request.jwt.sub) + case 'update': { + const result = await repo.create(request.query.id, document, request.jwt.sub, 'Update') if (result.result.length > 0) { const _ouuid = result.result[0].guid.toLocaleLowerCase() const _ofn = `${env['DATA_PATH']}/content/${_ouuid.substring(0, 2)}/${_ouuid}/file` @@ -201,9 +178,9 @@ export default async function (fastify: FastifyInstance) { } results.push(result) break - + } case 'version': - results.push(await repo.createVersion(request.query.id, uuid, request.query.directory_id, filename, mimetype, filesize, object_type, document_type, dt, request.jwt.sub)) + results.push(await repo.create(request.query.id, document, request.jwt.sub, 'Version')) break } } @@ -220,4 +197,32 @@ export default async function (fastify: FastifyInstance) { return reply.error('failed to upload document!') } }) +} + +function deleteOnDate(directory_id: number, filename: string): Date | undefined { + if (deleteOnDirs.some(e => e == directory_id)) { + // try parse date + const last8 = filename.substring(filename.length - 8) + if (/^\d{8}$/.test(last8.toLocaleLowerCase())) { + const year = parseInt(last8.substring(0, 4)) + const month = parseInt(last8.substring(4, 6)) + const day = parseInt(last8.substring(6, 8)) + + return new Date(Date.UTC(year, month - 1, day)) + } + } + return undefined +} + +function checkIfDevserver(request: FastifyRequest) { + const ip_address = request.headers['x-client-ip']?.toString().split(',')[0] + request.log.info({ client_ip: ip_address }, 'checkIfDevserver') + + if (ip_address && ip_address === '172.18.15.9') { + request.jwt = { + sub: '0', + roles: ['admin:GroupClaes.PCM/*'] + } + request.hasPermission = (r, s) => true + } } \ No newline at end of file diff --git a/src/helper.ts b/src/helper.ts index 480dd44..cdb04d9 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -109,12 +109,4 @@ const BitConverter = { | src[index + 6] << 8 | src[index + 7] } -} - -export interface IBaseAPIResponse { - status: 'error' | 'success' | 'fail' - code?: number // HTTP status code - message?: string // Required when status is 'error' - data?: { [key: string]: any } | any // Required if status is 'success' or 'fail', data should never be an array - executionTime?: number // Optional: Execution time in milliseconds } \ No newline at end of file diff --git a/src/repositories/browse.repository.ts b/src/repositories/browse.repository.ts index 8de2538..0ba27fa 100644 --- a/src/repositories/browse.repository.ts +++ b/src/repositories/browse.repository.ts @@ -32,7 +32,8 @@ export default class Browse { verified, result: { directories: result.recordsets[1][0] || [], - documents: result.recordsets[2][0] || [] + documents: result.recordsets[2][0] || [], + breadcrumbs: result.recordsets[3][0] || [] } } } else { diff --git a/src/repositories/check.repository.ts b/src/repositories/check.repository.ts index 5346720..70a844a 100644 --- a/src/repositories/check.repository.ts +++ b/src/repositories/check.repository.ts @@ -60,7 +60,7 @@ const csvBuilder = (item) => { return lines.join('\n') } -const stringBuilder = (item: undefined | any, document: undefined | any): string => { +const stringBuilder = (item: undefined | { itemNum: string, description: string, shipperItemNum: string, ean: string }, document: undefined | { name: string }): string => { if (item) return item.itemNum + ';' + item.description + ';' + item.shipperItemNum + ';' + item.ean + ';' + ((document) ? document.name + ';' : ';') diff --git a/src/repositories/document.repository.ts b/src/repositories/document.repository.ts index 9d84f55..fc05034 100644 --- a/src/repositories/document.repository.ts +++ b/src/repositories/document.repository.ts @@ -38,7 +38,7 @@ export default class Document { r.input('user_id', sql.Int, user_id) let result - if (new RegExp('^[{]?[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}[}]?$').test(id.toString(10))) { + if (/^{?[0-9abcdefABCDEF]{8}-([0-9abcdefABCDEF]{4}-){3}[0-9abcdefABCDEF]{12}}?$/.test(id.toString(10))) { r.input('guid', sql.UniqueIdentifier, id) result = await r.execute('[GetDocumentByGuid]') } else { @@ -53,7 +53,8 @@ export default class Document { return { error, verified, - result: result.recordsets[1][0][0] || [] + result: result.recordsets[1][0][0] || [], + breadcrumbs: result.recordsets[2][0] || [] } } else { throw new Error(error) @@ -82,75 +83,21 @@ export default class Document { } } - async create(uuid: string, directory_id: number, name: string, mime_type: string, size: number, object_type: string, document_type: string, deleted_on: Date | undefined, user_id?: string): Promise { + async create(id: undefined | number, document: IPostedDocument, user_id?: string, type?: 'Version' | 'Update'): Promise { const r = new sql.Request(this._pool) - r.input('uuid', sql.UniqueIdentifier, uuid) - r.input('directory_id', sql.Int, directory_id) - r.input('name', sql.VarChar, name) - r.input('mime_type', sql.VarChar, mime_type) - r.input('size', sql.BigInt, size) - r.input('object_type', sql.VarChar, object_type) - r.input('document_type', sql.VarChar, document_type) - r.input('deleted_on', sql.DateTime, deleted_on) - r.input('user_id', sql.Int, user_id) - - const result = await r.execute(`${this.schema}usp_create`) - - const { error, verified } = result.recordset[0] - - if (!error) { - return { - error, - verified, - result: result.recordsets[1][0].id || [] - } - } else { - throw new Error(error) - } - } - - async createUpdate(id: number, uuid: string, directory_id: number, name: string, mime_type: string, size: number, object_type: string, document_type: string, deleted_on: Date | undefined, user_id?: string): Promise { - const r = new sql.Request(this._pool) - r.input('id', sql.Int, id) - r.input('uuid', sql.UniqueIdentifier, uuid) - r.input('directory_id', sql.Int, directory_id) - r.input('name', sql.VarChar, name) - r.input('mime_type', sql.VarChar, mime_type) - r.input('size', sql.BigInt, size) - r.input('object_type', sql.VarChar, object_type) - r.input('document_type', sql.VarChar, document_type) - r.input('deleted_on', sql.DateTime, deleted_on) - r.input('user_id', sql.Int, user_id) - - const result = await r.execute(`${this.schema}usp_createUpdate`) - - const { error, verified } = result.recordset[0] - - if (!error) { - return { - error, - verified, - result: result.recordsets[1] - } - } else { - throw new Error(error) - } - } - - async createVersion(id: number, uuid: string, directory_id: number, name: string, mime_type: string, size: number, object_type: string, document_type: string, deleted_on: Date | undefined, user_id?: string): Promise { - const r = new sql.Request(this._pool) - r.input('id', sql.Int, id) - r.input('uuid', sql.UniqueIdentifier, uuid) - r.input('directory_id', sql.Int, directory_id) - r.input('name', sql.VarChar, name) - r.input('mime_type', sql.VarChar, mime_type) - r.input('size', sql.BigInt, size) - r.input('object_type', sql.VarChar, object_type) - r.input('document_type', sql.VarChar, document_type) - r.input('deleted_on', sql.DateTime, deleted_on) + if (id !== undefined) + r.input('id', sql.Int, id) + r.input('uuid', sql.UniqueIdentifier, document.uuid) + r.input('directory_id', sql.Int, document.directory_id) + r.input('name', sql.VarChar, document.name) + r.input('mime_type', sql.VarChar, document.mime_type) + r.input('size', sql.BigInt, document.size) + r.input('object_type', sql.VarChar, document.object_type) + r.input('document_type', sql.VarChar, document.document_type) + r.input('deleted_on', sql.DateTime, document.deleted_on) r.input('user_id', sql.Int, user_id) - const result = await r.execute(`${this.schema}usp_createVersion`) + const result = await r.execute(`${this.schema}usp_create${type ?? ''}`) const { error, verified } = result.recordset[0] @@ -312,9 +259,7 @@ export default class Document { const result = await r.query('SELECT [count] = COUNT(*) FROM items WHERE Id = @object_id AND CompanyId = @company_id') - if (result.recordset[0] && result.recordset[0].count) - return result.recordset[0].count > 0 - return false + return result.recordset[0]?.count > 0 || false } async getRelativePath(directory_id: number): Promise { @@ -367,4 +312,15 @@ export interface IDocument { languages?: number[] objectIds?: number[] attributes?: number[] +} + +export interface IPostedDocument{ + uuid: string + directory_id: number + name: string + mime_type: string + size: number + object_type: string + document_type: string + deleted_on: Date | undefined } \ No newline at end of file