From 64ce205296b92a3770e410a54cee8fdcbf3dd6b8 Mon Sep 17 00:00:00 2001 From: Kakious Date: Thu, 18 Jul 2024 21:59:27 -0400 Subject: [PATCH] feat: inital workings of service --- .devcontainer/devcontainer.json | 4 +- .devcontainer/docker-compose.yml | 2 +- keys/cookies.json | 2 +- keys/jwks.json | 17 ++ package.json | 12 +- pnpm-lock.yaml | 171 +++++++++++ src/app.const.ts | 2 + src/app.controller.ts | 7 +- src/app.module.ts | 22 ++ src/app.service.ts | 16 +- src/config/config.ts | 6 + src/database/models/api_keys.model.ts | 32 ++ src/database/models/oidc_client.model.ts | 87 ++++++ .../models/oidc_client_permissions.model.ts | 28 ++ src/database/models/oidc_grant.model.ts | 69 +++++ .../models/oidc_resource_servers.model.ts | 0 src/database/models/organization.model.ts | 41 +++ .../models/organization_role.model.ts | 0 src/database/models/user.model.ts | 39 ++- src/database/models/user_audit_log.model.ts | 0 src/database/models/user_connection.model.ts | 0 src/encryption/encryption.module.ts | 0 src/encryption/encryption.service.ts | 0 src/main.ts | 13 +- src/redis/redis.module.ts | 10 + src/redis/redis.service.ts | 286 ++++++++++++++++++ src/util/time.util.ts | 7 + views/auth/login.hbs | 114 +++++++ views/home/index.hbs | 10 + 29 files changed, 975 insertions(+), 22 deletions(-) create mode 100644 src/app.const.ts create mode 100644 src/database/models/api_keys.model.ts create mode 100644 src/database/models/oidc_client.model.ts create mode 100644 src/database/models/oidc_client_permissions.model.ts create mode 100644 src/database/models/oidc_grant.model.ts create mode 100644 src/database/models/oidc_resource_servers.model.ts create mode 100644 src/database/models/organization.model.ts create mode 100644 src/database/models/organization_role.model.ts create mode 100644 src/database/models/user_audit_log.model.ts create mode 100644 src/database/models/user_connection.model.ts create mode 100644 src/encryption/encryption.module.ts create mode 100644 src/encryption/encryption.service.ts create mode 100644 src/redis/redis.module.ts create mode 100644 src/redis/redis.service.ts create mode 100644 src/util/time.util.ts create mode 100644 views/auth/login.hbs create mode 100644 views/home/index.hbs diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ed537da..093fc62 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -10,14 +10,14 @@ "service": "waterwolf-auth", // The optional 'workspaceFolder' property is the path VS Code should open by default when // connected. This is typically a file mount in .devcontainer/docker-compose.yml - "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "workspaceFolder": "/app/", "features": { "ghcr.io/cirolosapio/devcontainers-features/alpine-git:0": {} }, // Features to add to the dev container. More info: https://containers.dev/features. // "features": {}, // Use 'forwardPorts' to make a list of ports inside the container available locally. - "forwardPorts": [3000, "mysql:3306"], + "forwardPorts": [3000, "mysql:3306", "redis:6379"], "portsAttributes": { "3000": { diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 97d8e1d..feec63b 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -13,7 +13,7 @@ services: volumes: # Update this to wherever you want VS Code to mount the folder of your project - - ..:/workspaces:cached + - ..:/app:cached # Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust. # cap_add: diff --git a/keys/cookies.json b/keys/cookies.json index 36f9d35..7613541 100644 --- a/keys/cookies.json +++ b/keys/cookies.json @@ -1 +1 @@ -['THIS IS A SECURE THING'] \ No newline at end of file +["secure_token_1"] \ No newline at end of file diff --git a/keys/jwks.json b/keys/jwks.json index e69de29..2a2cf19 100644 --- a/keys/jwks.json +++ b/keys/jwks.json @@ -0,0 +1,17 @@ +{ + "keys": [ + { + "p": "xA4KcjSuyAXWzjyG48B_s1Svo-srjm3HbzgNzXz3vyvpkZU3XF-z0TR_Vfoct5LBO8M6Rj4OXyo0vho3v1DrGgkC_V5hMfGvov4C9S31_i5Pl0gjSC_OfScqtTGftJSrA2a9ob21IhAYrE4xtCTf2ETuKaiMY5IZ-HFKuinNI7E", + "kty": "RSA", + "q": "s0sQRM3OTslJPCAXvgYpWrg1reqaKy7RBuidH90ovHWiQOySwtTcsawek5o3tD6wF-3AUZ6iu_i9Jy4T99Ocz-aOR421GHKTU2SmLmPDpM4d7WvZXvCbddF4k1ihf5OkYYV4HSC10_6SVnSLUxf34YSlfdrRlT3DVFfBg_m9aYc", + "d": "ZG1BW0fs-Wu-q9YYM7cdX7v6XixMaFEYDd7Qv-wLK1-sns7Jl0Xd6P8cKEneneRk7oBdEpjCkHiBxsrWydn4JfDZOL4ItD7lTm6q0jq_n6W2SKYu7_0Vxmm3ohuxYuQSoTofJ4G4T_yfAU3-vpF0a-jv9izl8YXZ7H3JZJawKH9TgkpYXQXmjzzro87-JtfsVBlnH_9vhUQrsBS3Eb61voUUH58swTfD0JxnfeogRD8Y0HxuwghhO_xAcE_ZMkOV0W-b4NY6jnM1MFTeP5itHAEEo5SLwXel4eRv5r9GV4XD_2eMYnzvoeA8BVOMWYQgYRanmfVvGbLZYTOmqLxNoQ", + "e": "AQAB", + "use": "sig", + "qi": "Gk6c0VY9i3ka6XNN0PcLEpgM0nIMgfOjYyjl6u0yPSA8-gbLJEKsH1xL4iJj31102VszFpGT5kCCIXUIgcAvetB0A0xxucdAO7CDMEHJoPyNYvd4i5erHuO7Kj2i6I67qmcZGHPzK-TbE4xekVSb8E5lz38J7VTwElleQ5wa08Q", + "dp": "biaJXfMVhBIrxsGg89MSrFnXONyHI0WweF9g-ePNeh4c44uXiBHJALBjHpYgjk8ovAAK_K4e-v7GlUw7qAS5om4PvPTK3PmyOXxHgyMog3_XfeKs2ADsHcrkptrTpOymTInr3zSr0RCEHELukAzrqyHHQaaOAd9zMe_NEV0tAXE", + "alg": "RS256", + "dq": "szd0IqJp950CZGRb9yknizQZDCg2RLX-YN6BuMkToBYhwq33IWMu2zaGNdpwle4XjUOs-qkMV8KSKKjJcu8Gj1YRoHqIq9BTbYdtCW_Vr1YM2jb0yA7QBpwE35w3ilOle4mzf8Ijnq2Xz22dmsiZkcZKuhvRZVGgfx1dJTOs3t8", + "n": "iU9N4HxKzLHkNwkbMxozz2fyxC5vl_2m67nFnlI7i_L9ZBPvF2UlEzShAsheriLu4zb4b-5soKVc4L5gII-98dSz_jkO2q6I2UjUDjGKVXht1zAtUqYY5WUTHR6l6Mv2Tt24Mksk2DM5NGzYdFhEZAeP1rWphjJnRXSVXMvvx_IxC5OiQeP6JrH-LZIYzidlwcVUXQaFspkTTDPdovaf29x-NNXxbRrX2fKG7nFehCcZ2S4o6j9annrcxwzbZuOYHtHXTg8euUDs-PNmfeZVB7rGvAPeuslB4QGs45zERa9e9_fKk3YpU-6WVGU5VwaYjuRyuQVUx_L6htwOsX5rVw" + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 618841d..c00edc3 100644 --- a/package.json +++ b/package.json @@ -32,21 +32,26 @@ "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.3.10", "@nestjs/platform-express": "^10.3.10", + "@nestjs/terminus": "^10.2.3", "@nestjs/typeorm": "^10.0.2", + "@opentelemetry/api": "^1.9.0", "bullmq": "^5.9.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.5", + "hbs": "^4.2.0", "ioredis": "^5.4.1", "mysql2": "^3.10.2", "nanoid": "^5.0.7", + "nestjs-otel": "^6.1.1", "nestjs-postal-client": "^0.0.6", "oidc-provider": "^8.5.1", "pug": "^3.0.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.20", - "typia": "^6.5.1" + "typia": "^6.5.1", + "uuid": "^10.0.0" }, "devDependencies": { "@nestjs/cli": "^10.4.2", @@ -90,5 +95,6 @@ ], "coverageDirectory": "../coverage", "testEnvironment": "node" - } -} \ No newline at end of file + }, + "packageManager": "pnpm@9.5.0+sha512.140036830124618d624a2187b50d04289d5a087f326c9edfc0ccd733d76c4f52c3a313d4fc148794a2a9d81553016004e6742e8cf850670268a7387fc220c903" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f4f559..c509ea1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,9 +26,15 @@ importers: '@nestjs/platform-express': specifier: ^10.3.10 version: 10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10) + '@nestjs/terminus': + specifier: ^10.2.3 + version: 10.2.3(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/typeorm@10.0.2(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.10.2)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.5.3))))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.10.2)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.5.3))) '@nestjs/typeorm': specifier: ^10.0.2 version: 10.0.2(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.10.2)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.5.3))) + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 bullmq: specifier: ^5.9.0 version: 5.9.0 @@ -41,6 +47,9 @@ importers: dotenv: specifier: ^16.4.5 version: 16.4.5 + hbs: + specifier: ^4.2.0 + version: 4.2.0 ioredis: specifier: ^5.4.1 version: 5.4.1 @@ -50,6 +59,9 @@ importers: nanoid: specifier: ^5.0.7 version: 5.0.7 + nestjs-otel: + specifier: ^6.1.1 + version: 6.1.1(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(reflect-metadata@0.2.2)(rxjs@7.8.1)) nestjs-postal-client: specifier: ^0.0.6 version: 0.0.6(rtrsvjp22aq2opwvwwi7bofxyi) @@ -71,6 +83,9 @@ importers: typia: specifier: ^6.5.1 version: 6.5.1(typescript@5.5.3) + uuid: + specifier: ^10.0.0 + version: 10.0.0 devDependencies: '@nestjs/cli': specifier: ^10.4.2 @@ -659,6 +674,54 @@ packages: class-validator: optional: true + '@nestjs/terminus@10.2.3': + resolution: {integrity: sha512-iX7gXtAooePcyQqFt57aDke5MzgdkBeYgF5YsFNNFwOiAFdIQEhfv3PR0G+HlH9F6D7nBCDZt9U87Pks/qHijg==} + peerDependencies: + '@grpc/grpc-js': '*' + '@grpc/proto-loader': '*' + '@mikro-orm/core': '*' + '@mikro-orm/nestjs': '*' + '@nestjs/axios': ^1.0.0 || ^2.0.0 || ^3.0.0 + '@nestjs/common': ^9.0.0 || ^10.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 + '@nestjs/microservices': ^9.0.0 || ^10.0.0 + '@nestjs/mongoose': ^9.0.0 || ^10.0.0 + '@nestjs/sequelize': ^9.0.0 || ^10.0.0 + '@nestjs/typeorm': ^9.0.0 || ^10.0.0 + '@prisma/client': '*' + mongoose: '*' + reflect-metadata: 0.1.x || 0.2.x + rxjs: 7.x + sequelize: '*' + typeorm: '*' + peerDependenciesMeta: + '@grpc/grpc-js': + optional: true + '@grpc/proto-loader': + optional: true + '@mikro-orm/core': + optional: true + '@mikro-orm/nestjs': + optional: true + '@nestjs/axios': + optional: true + '@nestjs/microservices': + optional: true + '@nestjs/mongoose': + optional: true + '@nestjs/sequelize': + optional: true + '@nestjs/typeorm': + optional: true + '@prisma/client': + optional: true + mongoose: + optional: true + sequelize: + optional: true + typeorm: + optional: true + '@nestjs/testing@10.3.10': resolution: {integrity: sha512-i3HAtVQJijxNxJq1k39aelyJlyEIBRONys7IipH/4r8W0J+M1V+y5EKDOyi4j1SdNSb/vmNyWpZ2/ewZjl3kRA==} peerDependencies: @@ -1056,6 +1119,9 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1204,6 +1270,10 @@ packages: resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -1307,6 +1377,10 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + check-disk-space@3.4.0: + resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==} + engines: {node: '>=16'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1328,6 +1402,10 @@ packages: class-validator@0.14.1: resolution: {integrity: sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==} + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + cli-color@2.0.4: resolution: {integrity: sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==} engines: {node: '>=0.10'} @@ -1920,6 +1998,9 @@ packages: debug: optional: true + foreachasync@3.0.0: + resolution: {integrity: sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==} + foreground-child@3.2.1: resolution: {integrity: sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==} engines: {node: '>=14'} @@ -2044,6 +2125,11 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + handlebars@4.7.7: + resolution: {integrity: sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -2075,6 +2161,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hbs@4.2.0: + resolution: {integrity: sha512-dQwHnrfWlTk5PvG9+a45GYpg0VpX47ryKF8dULVd6DtwOE6TEcYQXQ5QM6nyOx/h7v3bvEQbdn19EDAcfUAgZg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + hexoid@1.0.0: resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} engines: {node: '>=8'} @@ -3788,6 +3878,11 @@ packages: peerDependencies: typescript: '>=4.8.0 <5.6.0' + uglify-js@3.18.0: + resolution: {integrity: sha512-SyVVbcNBCk0dzr9XL/R/ySrmYf0s372K6/hFklzgcp2lBFyXtw4I7BOdDjlLhE1aVqaI/SHWXWmYdlZxuyF38A==} + engines: {node: '>=0.8.0'} + hasBin: true + uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -3823,6 +3918,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -3846,6 +3945,9 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + walk@2.3.15: + resolution: {integrity: sha512-4eRTBZljBfIISK1Vnt69Gvr2w/wc3U6Vtrw7qiN5iqYJPH7LElcYh/iU4XWhdCy2dZqv1ToMyYlybDylfG/5Vg==} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -3889,6 +3991,10 @@ packages: engines: {node: '>= 8'} hasBin: true + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + with@7.0.2: resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==} engines: {node: '>= 10.0.0'} @@ -3897,6 +4003,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -4660,6 +4769,18 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.1 + '@nestjs/terminus@10.2.3(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/typeorm@10.0.2(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.10.2)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.5.3))))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.10.2)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.5.3)))': + dependencies: + '@nestjs/common': 10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) + boxen: 5.1.2 + check-disk-space: 3.4.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.1 + optionalDependencies: + '@nestjs/typeorm': 10.0.2(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.10.2)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.5.3))) + typeorm: 0.3.20(ioredis@5.4.1)(mysql2@3.10.2)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.5.3)) + '@nestjs/testing@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10))': dependencies: '@nestjs/common': 10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -5116,6 +5237,10 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + ansi-colors@4.1.3: {} ansi-escapes@4.3.2: @@ -5282,6 +5407,17 @@ snapshots: transitivePeerDependencies: - supports-color + boxen@5.1.2: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -5403,6 +5539,8 @@ snapshots: chardet@0.7.0: {} + check-disk-space@3.4.0: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -5429,6 +5567,8 @@ snapshots: libphonenumber-js: 1.11.4 validator: 13.12.0 + cli-boxes@2.2.1: {} + cli-color@2.0.4: dependencies: d: 1.0.2 @@ -6073,6 +6213,8 @@ snapshots: follow-redirects@1.15.6: {} + foreachasync@3.0.0: {} + foreground-child@3.2.1: dependencies: cross-spawn: 7.0.3 @@ -6226,6 +6368,15 @@ snapshots: graphemer@1.4.0: {} + handlebars@4.7.7: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.18.0 + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -6248,6 +6399,11 @@ snapshots: dependencies: function-bind: 1.1.2 + hbs@4.2.0: + dependencies: + handlebars: 4.7.7 + walk: 2.3.15 + hexoid@1.0.0: {} highlight.js@10.7.3: {} @@ -8123,6 +8279,9 @@ snapshots: randexp: 0.5.3 typescript: 5.5.3 + uglify-js@3.18.0: + optional: true + uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 @@ -8151,6 +8310,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@10.0.0: {} + uuid@9.0.1: {} v8-compile-cache-lib@3.0.1: {} @@ -8167,6 +8328,10 @@ snapshots: void-elements@3.1.0: {} + walk@2.3.15: + dependencies: + foreachasync: 3.0.0 + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -8230,6 +8395,10 @@ snapshots: dependencies: isexe: 2.0.0 + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + with@7.0.2: dependencies: '@babel/parser': 7.24.8 @@ -8239,6 +8408,8 @@ snapshots: word-wrap@1.2.5: {} + wordwrap@1.0.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 diff --git a/src/app.const.ts b/src/app.const.ts new file mode 100644 index 0000000..6787ab2 --- /dev/null +++ b/src/app.const.ts @@ -0,0 +1,2 @@ +// This is the internal client ID for the application itself. +export const internalClientId = 'internal.management'; diff --git a/src/app.controller.ts b/src/app.controller.ts index 194f662..c3bb11d 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Render } from '@nestjs/common'; import { AppService } from './app.service'; import { PostalClientService } from 'nestjs-postal-client'; @@ -10,8 +10,9 @@ export class AppController { ) {} @Get() - getHello(): string { - return this.appService.getHello(); + @Render('home/index') + home() { + return { message: 'Hello world!'} } @Get('email_test') diff --git a/src/app.module.ts b/src/app.module.ts index 31bcfd0..8765a5a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,15 +4,37 @@ import { AppService } from './app.service'; import { ConfigModule } from '@nestjs/config'; import config from './config/config'; import { MailModule } from './mail/mail.module'; +import { RedisModule } from './redis/redis.module'; +import { OpenTelemetryModule } from 'nestjs-otel'; @Module({ imports: [ + OpenTelemetryModule.forRoot({ + metrics: { + apiMetrics: { + enable: true, + ignoreRoutes: [ + '/favicon.ico', + '/OidcServiceWorker.js', + '/swagger', + '/swagger-json', + '/swagger-yaml', + '/swagger/(.*)', + '/metrics', + '/interaction/(.*}', + ], + ignoreUndefinedRoutes: true, + }, + }, + }), ConfigModule.forRoot({ cache: true, isGlobal: true, load: [config], }), MailModule, + RedisModule, + ], controllers: [AppController], providers: [AppService], diff --git a/src/app.service.ts b/src/app.service.ts index 927d7cc..d12de69 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -1,8 +1,8 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getHello(): string { + return 'Hello World!'; + } +} diff --git a/src/config/config.ts b/src/config/config.ts index 1eb2551..3c65d63 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -3,4 +3,10 @@ export default async () => ({ port: process.env.PORT || 3000, host: process.env.HOST || 'localhost', }, + redis: { + host: process.env['REDIS_HOST'] ?? 'localhost', + port: process.env['REDIS_POST'] ?? 6379, + password: process.env['REDIS_PASSWORD'] ?? '', + db: process.env['REDIS_DB'] ?? 0 + } }); diff --git a/src/database/models/api_keys.model.ts b/src/database/models/api_keys.model.ts new file mode 100644 index 0000000..cda56b4 --- /dev/null +++ b/src/database/models/api_keys.model.ts @@ -0,0 +1,32 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { User } from './user.model'; + +@Entity('api_keys') +export class ApiKey extends BaseEntity { + @PrimaryColumn() + id: string; + + @PrimaryColumn() + key: string; + + @Column() + title: string; + + @ManyToOne(() => User, (user) => user.apiKeys) + user: User; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/database/models/oidc_client.model.ts b/src/database/models/oidc_client.model.ts new file mode 100644 index 0000000..49f6769 --- /dev/null +++ b/src/database/models/oidc_client.model.ts @@ -0,0 +1,87 @@ +import type { + ClientAuthMethod, + ResponseType, + SigningAlgorithmWithNone, +} from 'oidc-provider'; + +import { + Column, + Entity, + JoinTable, + ManyToMany, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; + +import { OidcClientPermission } from './oidc_client_permissions.model'; +import { MAX_STRING_LENGTH } from '../database.const'; + +@Entity() +export class OidcClient { + // Client ID + @PrimaryGeneratedColumn('uuid') + client_id: string; + + // Owner Org ID + @Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: false }) + ownerName: string; + + @Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: false }) + client_secret: string; + + @Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: true }) + client_name: string; + + @Column({ type: 'simple-array', nullable: false }) + redirect_uris: string[]; + + @Column({ type: 'simple-array', nullable: true }) + client_cors: string[]; + + @Column({ type: 'simple-array', nullable: true }) + allowed_introspection_targets: string[]; + + @Column({ type: 'simple-array', nullable: false }) + include_permissions_from_client: string[]; + + @Column({ type: 'simple-array', nullable: false }) + post_logout_redirect_uris: string[]; + + @Column({ type: 'simple-array', nullable: false }) + response_types: ResponseType[]; + + @Column({ type: 'simple-array', nullable: false }) + grant_types: string[]; + + @Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: false }) + token_endpoint_auth_method: ClientAuthMethod; + + @Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: false }) + application_type: 'web' | 'native'; + + @Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: false }) + logo_uri: string; + + @Column({ type: 'boolean', nullable: false, default: false }) + restricted: boolean; + + @OneToMany(() => OidcClientPermission, (permission) => permission.client) + permissions: OidcClientPermission[]; + + @ManyToMany( + () => OidcClientPermission, + (permission) => permission.assignedClients, + ) + @JoinTable({ + name: 'oidc_client_permissions', + joinColumn: { + name: 'client_id', + }, + inverseJoinColumn: { + name: 'permission_id', + }, + }) + assignedPermissions: OidcClientPermission[]; + + [key: string]: unknown; +} diff --git a/src/database/models/oidc_client_permissions.model.ts b/src/database/models/oidc_client_permissions.model.ts new file mode 100644 index 0000000..2b7e374 --- /dev/null +++ b/src/database/models/oidc_client_permissions.model.ts @@ -0,0 +1,28 @@ +import { + BeforeInsert, + Column, + Entity, + ManyToMany, + ManyToOne, + PrimaryColumn, + PrimaryGeneratedColumn, + Unique, +} from 'typeorm'; + +import { OidcClient } from './oidc_client.model'; + +@Unique(['title', 'client']) +@Entity('oidc_client_permission') +export class OidcClientPermission { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + title: string; + + @ManyToMany(() => OidcClient, (oidcClient) => oidcClient.assignedPermissions) + assignedClients: OidcClient[]; + + @ManyToOne(() => OidcClient, (oidcClient) => oidcClient.permissions) + client: OidcClient; +} diff --git a/src/database/models/oidc_grant.model.ts b/src/database/models/oidc_grant.model.ts new file mode 100644 index 0000000..07f3a3e --- /dev/null +++ b/src/database/models/oidc_grant.model.ts @@ -0,0 +1,69 @@ +import type { AdapterPayload } from 'oidc-provider'; +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +import { + convertFromNumberToTime, + convertFromTimeToNumber, +} from '../../util/time.util'; + +@Entity() +export class OidcGrant implements AdapterPayload { + @PrimaryColumn() + id: string; + + @Index('oidc_account_id') + @Column({ + nullable: true, + }) + accountId: string; + + @Index('oidc_client_id') + @Column({ + nullable: true, + }) + clientId: string; + + @Column({ + type: 'timestamp', + nullable: true, + transformer: { + from: (value: Date) => convertFromTimeToNumber(value), + to: (value: number) => convertFromNumberToTime(value), + }, + }) + iat?: number; + + @Column({ + type: 'timestamp', + nullable: true, + transformer: { + from: (value: Date) => convertFromTimeToNumber(value), + to: (value: number) => convertFromNumberToTime(value), + }, + }) + exp?: number; + + @Column({ type: 'json', nullable: true }) + openid?: { + scope?: string; + claims?: string[]; + }; + + @Column({ type: 'simple-array', nullable: true }) + resources?: string[]; + + generateResponse(): AdapterPayload { + return { + iat: this.iat, + exp: this.exp, + accountId: this.accountId, + clientId: this.clientId, + kind: 'Grant', + jti: this.id, + openid: this.openid, + resources: this.resources, + }; + } + + [key: string]: unknown; +} diff --git a/src/database/models/oidc_resource_servers.model.ts b/src/database/models/oidc_resource_servers.model.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/database/models/organization.model.ts b/src/database/models/organization.model.ts new file mode 100644 index 0000000..eb91607 --- /dev/null +++ b/src/database/models/organization.model.ts @@ -0,0 +1,41 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { MAX_STRING_LENGTH } from '../database.const'; + +@Entity('organization') +export class Organization { + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: MAX_STRING_LENGTH }) + name: string; + + @Column({ length: MAX_STRING_LENGTH, nullable: true }) + logo?: string; + + @Column({ length: MAX_STRING_LENGTH, nullable: true }) + background?: string; + + @Column({ length: MAX_STRING_LENGTH, nullable: true }) + website?: string; + + @Column({ length: MAX_STRING_LENGTH, nullable: true }) + description?: string; + + @Column({ name: 'owner_id' }) + ownerId: number; + + @Column({ + name: 'created_at', + type: 'timestamp', + default: () => 'CURRENT_TIMESTAMP', + }) + createdAt: Date; + + @Column({ + name: 'updated_at', + type: 'timestamp', + default: () => 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }) + updatedAt: Date; +} diff --git a/src/database/models/organization_role.model.ts b/src/database/models/organization_role.model.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/database/models/user.model.ts b/src/database/models/user.model.ts index 9954049..ac2ac0c 100644 --- a/src/database/models/user.model.ts +++ b/src/database/models/user.model.ts @@ -1,5 +1,6 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; import { MAX_STRING_LENGTH, UserRole } from '../database.const'; +import { ApiKey } from './api_keys.model'; @Entity('user') export class User { @@ -9,9 +10,15 @@ export class User { @Column({ length: MAX_STRING_LENGTH }) username: string; + @Column({ length: MAX_STRING_LENGTH, name: 'display_name', nullable: true }) + displayName: string; + @Column({ length: MAX_STRING_LENGTH }) email: string; + @Column({ name: 'email_verified', default: false }) + emailVerified: boolean; + @Column({ length: MAX_STRING_LENGTH }) password: string; @@ -25,5 +32,33 @@ export class User { avatar: string; @Column({ length: 15, enum: UserRole, default: UserRole.USER }) - role: string; + role: UserRole; + + @Column({ name: 'disabled', default: false }) + disabled: boolean; + + // This is for Gravatar Support + @Column({ name: 'email_hash', length: MAX_STRING_LENGTH, nullable: true }) + emailHash: string; + + // Relationship Mapping + + @OneToMany(() => ApiKey, (apiKey) => apiKey.user) + apiKeys: ApiKey[]; + + // Created At/Updated At + @Column({ + name: 'created_at', + type: 'timestamp', + default: () => 'CURRENT_TIMESTAMP', + }) + createdAt: Date; + + @Column({ + name: 'updated_at', + type: 'timestamp', + default: () => 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }) + updatedAt: Date; } diff --git a/src/database/models/user_audit_log.model.ts b/src/database/models/user_audit_log.model.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/database/models/user_connection.model.ts b/src/database/models/user_connection.model.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/encryption/encryption.module.ts b/src/encryption/encryption.module.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/encryption/encryption.service.ts b/src/encryption/encryption.service.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/main.ts b/src/main.ts index 13cad38..71ad6d6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,17 @@ import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { join } from 'path'; import { AppModule } from './app.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create( + AppModule, + ); + + app.useStaticAssets(join(__dirname, '..', 'public')); + app.setBaseViewsDir(join(__dirname, '..', 'views')); + app.setViewEngine('hbs'); + await app.listen(3000); } -bootstrap(); +bootstrap(); \ No newline at end of file diff --git a/src/redis/redis.module.ts b/src/redis/redis.module.ts new file mode 100644 index 0000000..5e1a6b2 --- /dev/null +++ b/src/redis/redis.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { RedisService } from './redis.service'; + +@Module({ + providers: [RedisService, ConfigService], + exports: [RedisService], +}) +export class RedisModule {} \ No newline at end of file diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts new file mode 100644 index 0000000..a217931 --- /dev/null +++ b/src/redis/redis.service.ts @@ -0,0 +1,286 @@ +import { Injectable, Logger, OnApplicationShutdown } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HealthIndicatorResult, HealthIndicatorStatus } from '@nestjs/terminus'; +import { ObservableGauge } from '@opentelemetry/api'; +import Redis, { ChainableCommander } from 'ioredis'; +import { MetricService } from 'nestjs-otel'; + +@Injectable() +export class RedisService implements OnApplicationShutdown { + private _ioredis: Redis; + private readonly logger = new Logger(RedisService.name); + private readonly serviceConnectedObservableGauge: ObservableGauge; + + private readonly host: string | undefined; + private readonly port: number; + private readonly password: string | undefined; + private readonly db: number; + + constructor( + configService: ConfigService, + private readonly metricService: MetricService, + ) { + this.host = configService.getOrThrow('redis.host'); + this.port = +configService.get('redis.port') ?? 6789; + this.password = configService.get('redis.password') ?? ''; + this.db = +configService.get('redis.db') ?? 0; + + this.serviceConnectedObservableGauge = this.metricService.getObservableGauge( + 'service.connected', + { description: 'Whether a needed service is connected' }, + ); + + this.logger.debug(`Connecting to Redis at ${this.host}:${this.port} using db ${this.db}`); + this._ioredis = new Redis({ + host: this.host, + port: this.port, + password: this.password, + db: this.db, + enableAutoPipelining: true, + }); + + this._ioredis.on('ready', () => { + this.logger.log(`Connected to Redis at ${this.host}:${this.port} using db ${this.db}`); + }); + + this._ioredis.on('error', (err) => { + this.logger.error(err, 'Redis error'); + }); + + this.serviceConnectedObservableGauge.addCallback((result) => { + result.observe(this._ioredis.status === 'ready' ? 1 : 0, { target: 'redis' }); + }); + } + + async onApplicationShutdown(): Promise { + await this._ioredis.quit(); + } + + public get ioredis(): Redis { + return this._ioredis; + } + + /** + * Store a value or object in redis + * @param key Key for the value to store + * @param value Value to store + * @param ttl Time to live in seconds + */ + public async set(key: string, value: string | object, ttl?: number): Promise { + this.logger.debug(`Setting key ${key}`); + if (typeof value === 'object') { + value = JSON.stringify(value); + } + if (ttl) { + await this._ioredis.set(key, value, 'EX', ttl); + } else { + await this._ioredis.set(key, value); + } + } + + /** + * Will return a string or object based on if it was stored as one. Otherwise will return null if not found. + * @param key Key for the value to get + * @returns + */ + public async get(key: string): Promise { + this.logger.debug(`Getting key ${key}`); + const value = await this._ioredis.get(key); + if (!value) { + return null; + } + + try { + return JSON.parse(value); + } catch (error) { + return value; + } + } + + /** + * Remove a value from redis + * @param key Key for the value to remove + * @returns + */ + public async del(key: string): Promise { + await this._ioredis.del(key); + } + + /** + * Get then delete a value from redis + * @param key Key for the value to get and remove + * @returns + */ + public async getDel(key: string): Promise { + const value = await this.get(key); + await this.del(key); + return value; + } + + /** + * Create a redis multi command + * @returns Redis.Multi + */ + public multi(): ChainableCommander { + return this._ioredis.multi(); + } + + /** + * Execute a redis multi command + * @param multi Redis.Multi + * @returns Promise + */ + public async exec(multi: ChainableCommander): Promise { + return await multi.exec(); + } + + /** + * Set a ttl on a key + * @param key Key for the value to set the ttl on + * @param ttl Time to live in seconds + * @returns Promise + */ + public async expire(key: string, ttl: number): Promise { + return await this._ioredis.expire(key, ttl); + } + + /** + * Set a key with a ttl + * @param key Key for the value to set + * @param value Value to set + * @param ttl Time to live in seconds + * @returns Promise + */ + public async setex(key: string, value: string | object, ttl: number): Promise { + if (typeof value === 'object') { + value = JSON.stringify(value); + } + await this._ioredis.setex(key, ttl, value); + } + + /** + * Get the ttl on a key + * @param key Key for the value to get the ttl on + * @returns Promise + */ + public async ttl(key: string): Promise { + return await this._ioredis.ttl(key); + } + + /** + * Run a JSON.GET command and parse the result, return null if not found + * @param key Key for the value to get + * @returns Promise + */ + public async jsonGet(key: string): Promise { + const value = (await this._ioredis.call('JSON.GET', key)) as string | null; + if (!value) { + return null; + } + + try { + return JSON.parse(value); + } catch (error) { + return null; + } + } + + /** + * Run a JSON.SET command and stringify the value if it is an object + * @param key Key for the value to set + * @param value Value to set + * @returns Promise + */ + public async jsonSet(key: string, value: string | object): Promise { + if (typeof value === 'object') { + value = JSON.stringify(value); + } + await this._ioredis.set(key, value); + } + + /** + * Run a JSON.SET command with an expiration and stringify the value if it is an object + * @param key Key for the value to set + * @param value Value to set + * @param ttl Time to live in seconds + * @returns Promise + */ + public async jsonSetEx(key: string, value: string | object, ttl: number): Promise { + if (typeof value === 'object') { + value = JSON.stringify(value); + } + await this._ioredis.set(key, value, 'EX', ttl); + } + + /** + * Modify a value of a JSON.SET command + * @param key Key for the value to modify + * @param path Path to the value to modify + * @param value Value to set + * @returns Promise + */ + public async jsonSetPath( + key: string, + path: string, + value: string | object | number, + ): Promise { + if (typeof value === 'object') { + value = JSON.stringify(value); + } + await this._ioredis.call('JSON.SET', key, path, value); + } + + /** + * Preform a lrange command and parse the results + * @param key Key for the value to get + * @param start Start index + * @param stop Stop index + * @param parseJson Parse the results as JSON, normally false + * @returns Promise + */ + public async lrange( + key: string, + start: number, + stop: number, + parseJson = false, + ): Promise { + const value = await this._ioredis.lrange(key, start, stop); + if (!value) { + return []; + } + + if (parseJson) { + return value.map((v) => JSON.parse(v)); + } + + return value; + } + + /** + * Ping the redis server + */ + public async ping(): Promise { + return await this._ioredis.ping(); + } + + /** + * Check the health of the redis connection + * @returns Promise up | down + */ + public async checkHealth(): Promise { + let status: HealthIndicatorStatus = 'down'; + + try { + await this.ping(); + status = 'up'; + } catch (error) { + status = 'down'; + } + + return { + redis: { + status, + }, + }; + } +} \ No newline at end of file diff --git a/src/util/time.util.ts b/src/util/time.util.ts new file mode 100644 index 0000000..984fbce --- /dev/null +++ b/src/util/time.util.ts @@ -0,0 +1,7 @@ +export const convertFromNumberToTime = (time: number | null): Date | null => { + return time ? new Date(time * 1000) : null; +}; + +export const convertFromTimeToNumber = (time: Date | null): number | null => { + return time ? time.getTime() / 1000 : null; +}; diff --git a/views/auth/login.hbs b/views/auth/login.hbs new file mode 100644 index 0000000..22ec098 --- /dev/null +++ b/views/auth/login.hbs @@ -0,0 +1,114 @@ + + + + + + Login Page + + + + + +
+ + + \ No newline at end of file diff --git a/views/home/index.hbs b/views/home/index.hbs new file mode 100644 index 0000000..dd8c015 --- /dev/null +++ b/views/home/index.hbs @@ -0,0 +1,10 @@ + + + + + App + + + {{ message }} + + \ No newline at end of file