First stable build with system check

This commit is contained in:
Matthew Bessette 2024-04-14 00:38:05 +00:00
commit b790a70d3a
53 changed files with 14776 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
data
!**/.gitkeep

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v20.12.2

0
README.md Normal file
View File

View File

@ -0,0 +1,29 @@
type CloudDavContact {
urn: ID!
identityGroupUrn: String!
firstName: String
lastName: String
company: String
phones: [String]
addresses: [CloudDavContactAddress]
dates: [CloudDavContactLabeledString]
urls: [CloudDavContactLabeledString]
notes: String
}
type CloudDavContactAddress {
urn: ID!
type: String
street1: String
street2: String
city: String
state: String
postalCode: String
country: String
}
type CloudDavContactLabeledString {
context: String!
value: String!
}

View File

@ -0,0 +1,29 @@
type IdentityGroupToIdentityUserEdge {
data: [IdentityUser]
error: IdentityGroupToIdentityUserError
}
type IdentityUserToIdentityGroupEdge {
data: [IdentityGroup]
error: IdentityUserToIdentityGroupError
}
type IdentityUserToIdentityProfileEdge {
data: IdentityProfile
error: IdentityUserToIdentityProfileError
}
type IdentityUserToIdentityEmailEdge {
data: [IdentityEmail]
error: IdentityUserToIdentityEmailError
}
type IdentityUserToIdentityAuthDeviceEdge {
data: [IdentityAuthDevice]
error: IdentityUserToIdentityAuthDeviceError
}
type IdentityGroupToCloudDavContactEdge {
data: [CloudDavContact]
error: IdentityGroupToCloudDavContactError
}

View File

@ -0,0 +1,3 @@
enum IdentityAuthDeviceTypeEnum {
PASSWORD
}

View File

@ -0,0 +1,24 @@
enum IdentityGroupToIdentityUserError {
UNKNOWN
}
enum IdentityUserToIdentityGroupError {
UNKNOWN
}
enum IdentityUserToIdentityProfileError {
UNKNOWN
NOT_FOUND
}
enum IdentityUserToIdentityEmailError {
UNKNOWN
}
enum IdentityUserToIdentityAuthDeviceError {
UNKNOWN
}
enum IdentityGroupToCloudDavContactError {
UNKNOWN
}

View File

@ -0,0 +1,46 @@
type IdentityGroup {
urn: ID!
isAdmin: Boolean!
name: String
Users: IdentityGroupToIdentityUserEdge!
Contacts: IdentityGroupToCloudDavContactEdge!
}
type IdentityUser {
urn: ID!
externalId: String!
username: String!
Groups: IdentityUserToIdentityGroupEdge!
Profile: IdentityUserToIdentityProfileEdge!
Emails: IdentityUserToIdentityEmailEdge!
AuthDevices: IdentityUserToIdentityAuthDeviceEdge!
}
type IdentityProfile {
urn: ID!
firstName: String
lastName: String
}
type IdentityEmail {
urn: ID!
email: String!
userUrn: String!
verified: Boolean!
default: Boolean!
}
type IdentityAuthDevice {
urn: ID!
userUrn: String!
deviceType: IdentityAuthDeviceTypeEnum!
IdentityAuthDevicePassword: IdentityAuthDevicePassword
}
type IdentityAuthDevicePassword {
urn: ID!
expiry: String!
}

View File

@ -0,0 +1,5 @@
enum SystemSettingHashValueTypeEnum {
BOOLEAN
STRING
NUMBER
}

View File

@ -0,0 +1,8 @@
enum SystemSettingsQueryOutputError {
UNKNOWN
}
enum UpdateSystemSettingOutputError {
UNKNOWN
NOT_FOUND
}

View File

@ -0,0 +1,12 @@
input UpdateSystemSettingInput {
urn: ID!
hashValue: String!
}
type UpdateSystemSettingOutput {
error: UpdateSystemSettingOutputError
}
type Mutation {
updateSystemSetting(input: UpdateSystemSettingInput!): UpdateSystemSettingOutput!
}

View File

@ -0,0 +1,8 @@
type SystemSettingsQueryOutput {
data: [SystemSetting]
error: SystemSettingsQueryOutputError
}
type Query {
systemSettings: SystemSettingsQueryOutput!
}

View File

@ -0,0 +1,6 @@
type SystemSetting {
urn: ID!
hashKey: String!
hashValueType: SystemSettingHashValueTypeEnum!
hashValue: String!
}

View File

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

5
services/core/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
.env
__generated__
graphql.schema.json
dist

View File

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

View File

@ -0,0 +1,8 @@
# Namespacing
- Database and Graphql is namespaced by prefixed camelcase
- Typescript is namespaced by `namespace` keyword
# Adding to the enumeration types
- Update graphql enum
- Update services/core/enumerations namespaced types

73
services/core/README.md Normal file
View File

@ -0,0 +1,73 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Installation
```bash
$ npm install
```
## Running the app
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Test
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](LICENSE).

17
services/core/codegen.ts Normal file
View File

@ -0,0 +1,17 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
overwrite: true,
schema: "../../graphql",
generates: {
"src/__generated__/graphql.ts": {
plugins: ["typescript", "typescript-resolvers"]
},
"./graphql.schema.json": {
plugins: ["introspection"]
}
}
};
export default config;

View File

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

13495
services/core/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,85 @@
{
"name": "@homelab-personal-cloud/core",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"generate": "graphql-codegen --config codegen.ts",
"migrate": "npx prisma db push",
"migrate:post": "nest start --entryFile prisma-post-migrations"
},
"dependencies": {
"@apollo/server": "^4.10.2",
"@nestjs/apollo": "^12.1.0",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/graphql": "^12.1.1",
"@nestjs/platform-express": "^10.0.0",
"@prisma/client": "^5.12.1",
"bcrypt": "^5.1.1",
"graphql": "^16.8.1",
"joi": "^17.12.3",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/introspection": "4.0.3",
"@graphql-codegen/typescript": "^4.0.6",
"@graphql-codegen/typescript-resolvers": "^4.0.6",
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"prisma": "^5.12.1",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@ -0,0 +1,121 @@
-- CreateTable
CREATE TABLE "SystemSetting" (
"hashKey" TEXT NOT NULL PRIMARY KEY,
"hashValue" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "SystemPostMigration" (
"name" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "IdentityGroup" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"name" TEXT
);
-- CreateTable
CREATE TABLE "IdentityGroupToIdentityUser" (
"groupId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
PRIMARY KEY ("groupId", "userId"),
CONSTRAINT "IdentityGroupToIdentityUser_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "IdentityGroup" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "IdentityGroupToIdentityUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "IdentityUser" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"externalId" TEXT NOT NULL,
"username" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "IdentityProfileNonNormalized" (
"userId" INTEGER NOT NULL,
"hashKey" TEXT NOT NULL,
"hashValue" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("userId", "hashKey"),
CONSTRAINT "IdentityProfileNonNormalized_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "IdentityUserEmails" (
"email" TEXT NOT NULL PRIMARY KEY,
"userId" INTEGER NOT NULL,
"verified" BOOLEAN NOT NULL DEFAULT false,
"default" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "IdentityUserEmails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "EnumIdentityAuthDeviceType" (
"enumValue" TEXT NOT NULL PRIMARY KEY
);
-- CreateTable
CREATE TABLE "IdentityAuthDevice" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"deviceType" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IdentityAuthDevice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "IdentityAuthDevice_deviceType_fkey" FOREIGN KEY ("deviceType") REFERENCES "EnumIdentityAuthDeviceType" ("enumValue") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "IdentityAuthDeviceNonNormalized" (
"authDeviceId" INTEGER NOT NULL,
"hashKey" TEXT NOT NULL,
"hashValue" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("authDeviceId", "hashKey"),
CONSTRAINT "IdentityAuthDeviceNonNormalized_authDeviceId_fkey" FOREIGN KEY ("authDeviceId") REFERENCES "IdentityAuthDevice" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "EnumCloudDavResourceType" (
"enumValue" TEXT NOT NULL PRIMARY KEY
);
-- CreateTable
CREATE TABLE "CloudDavResource" (
"id" TEXT NOT NULL PRIMARY KEY,
"identityGroupId" INTEGER NOT NULL,
"resourceType" TEXT NOT NULL,
CONSTRAINT "CloudDavResource_identityGroupId_fkey" FOREIGN KEY ("identityGroupId") REFERENCES "IdentityGroup" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "CloudDavResource_resourceType_fkey" FOREIGN KEY ("resourceType") REFERENCES "EnumCloudDavResourceType" ("enumValue") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "CloudDavResourceNonNormalized" (
"davResourceId" TEXT NOT NULL,
"hashKey" TEXT NOT NULL,
"hashValue" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("davResourceId", "hashKey"),
CONSTRAINT "CloudDavResourceNonNormalized_davResourceId_fkey" FOREIGN KEY ("davResourceId") REFERENCES "CloudDavResource" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "IdentityUser_externalId_key" ON "IdentityUser"("externalId");
-- CreateIndex
CREATE UNIQUE INDEX "IdentityUser_username_key" ON "IdentityUser"("username");
-- CreateIndex
CREATE INDEX "IdentityAuthDevice_userId_idx" ON "IdentityAuthDevice"("userId");
-- CreateIndex
CREATE INDEX "IdentityAuthDevice_userId_deviceType_idx" ON "IdentityAuthDevice"("userId", "deviceType");
-- CreateIndex
CREATE INDEX "CloudDavResource_identityGroupId_idx" ON "CloudDavResource"("identityGroupId");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

View File

@ -0,0 +1,142 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:../../../data/core.db"
}
//
// Namespace: System
//
model SystemSetting {
hashKey String @id
hashValue String
}
model SystemPostMigration {
name String @id
createdAt DateTime @default(now())
}
//
// Namespace: Identity
//
model IdentityGroup {
id Int @id @default(autoincrement())
isAdmin Boolean @default(false)
name String?
users IdentityGroupToIdentityUser[]
davResources CloudDavResource[]
}
model IdentityGroupToIdentityUser {
groupId Int
group IdentityGroup @relation(fields: [groupId], references: [id])
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
@@id([groupId, userId])
}
model IdentityUser {
id Int @id @default(autoincrement())
externalId String @unique @default(uuid())
username String @unique
groups IdentityGroupToIdentityUser[]
profileHashMapPairs IdentityProfileNonNormalized[]
emails IdentityUserEmails[]
authDevices IdentityAuthDevice[]
}
model IdentityProfileNonNormalized {
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
hashKey String
hashValue String
createdAt DateTime @default(now())
@@id([userId, hashKey])
}
model IdentityUserEmails {
email String @id
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
verified Boolean @default(false)
default Boolean @default(false)
}
model EnumIdentityAuthDeviceType {
enumValue String @id
authDevices IdentityAuthDevice[]
}
model IdentityAuthDevice {
id Int @id @default(autoincrement())
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
deviceType String
deviceTypeRelation EnumIdentityAuthDeviceType @relation(fields: [deviceType], references: [enumValue])
createdAt DateTime @default(now())
hashMapPairs IdentityAuthDeviceNonNormalized[]
@@index([userId])
@@index([userId, deviceType])
}
model IdentityAuthDeviceNonNormalized {
authDeviceId Int
davResource IdentityAuthDevice @relation(fields: [authDeviceId], references: [id])
hashKey String
hashValue String
createdAt DateTime @default(now())
@@id([authDeviceId, hashKey])
}
//
// Namespace: cloud-dav
//
model EnumCloudDavResourceType {
enumValue String @id
davResources CloudDavResource[]
}
model CloudDavResource {
id String @id @default(uuid())
identityGroupId Int
IdentityGroup IdentityGroup @relation(fields: [identityGroupId], references: [id])
resourceType String
resourceTypeRelation EnumCloudDavResourceType @relation(fields: [resourceType], references: [enumValue])
hashMapPairs CloudDavResourceNonNormalized[]
@@index([identityGroupId])
}
model CloudDavResourceNonNormalized {
davResourceId String
davResource CloudDavResource @relation(fields: [davResourceId], references: [id])
hashKey String
hashValue String
createdAt DateTime @default(now())
@@id([davResourceId, hashKey])
}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { AuthModule } from './auth/auth.module';
import { ConfigModule } from './config/config.module';
import { PrismaModule } from './prisma/prisma.module';
import { GraphqlModule } from './graphql/graphql.module';
@Module({
imports: [
AuthModule,
ConfigModule,
PrismaModule,
GraphqlModule,
],
controllers: [],
providers: [],
})
export class AppModule {}

View File

@ -0,0 +1,4 @@
import { Module } from '@nestjs/common';
@Module({})
export class AuthModule {}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ConfigService } from './config.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule {}

View File

@ -0,0 +1,34 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { normalizePairs } from '../utils';
import { PrismaService } from '../prisma/prisma.service';
import { Config, configValidator} from './config.struct';
@Injectable()
export class ConfigService {
private readonly _config: Promise<Config>;;
constructor(
private readonly prismaService: PrismaService,
) {
this._config = new Promise(async (resolve, reject) => {
const settingsHashMap = await this.prismaService.systemSetting.findMany();
const settingsObject = normalizePairs(settingsHashMap);
const { value: config, error } = configValidator.validate(settingsObject, { abortEarly: false, allowUnknown: false });
if (error) {
return reject(error);
}
return resolve(config);
});
}
async get<K extends keyof Config>(key: K): Promise<Config[K]> {
const config = await this._config;
return config[key];
}
}

View File

@ -0,0 +1,15 @@
import * as Joi from 'joi';
import { SystemSettings } from '../enumerations';
export interface Config {
[SystemSettings.Graphql.Debug]: boolean;
[SystemSettings.Graphql.IntrospectionEnabled]: boolean;
[SystemSettings.Graphql.PlaygroundEnabled]: boolean;
}
export const configValidator: Joi.ObjectSchema<Config> = Joi.object<Config, true>({
'graphql.debug.enabled': Joi.boolean().required(),
'graphql.introspection.enabled': Joi.boolean().required(),
'graphql.playground.enabled': Joi.boolean().required(),
});

View File

@ -0,0 +1,9 @@
export namespace CloudDav {
export namespace Resource {
export enum Type {
Contact = 'contact'
}
}
}

View File

@ -0,0 +1,13 @@
export namespace Identity {
export namespace AuthDevice {
export enum Type {
Password = 'password',
}
export enum PasswordHashKey {
Expiry = 'expiry',
PasswordHashString = 'password_hash_string',
}
}
}

View File

@ -0,0 +1,3 @@
export * from './cloud-dav.enumerations';
export * from './identity.enumerations';
export * from './system-settings.enumerations';

View File

@ -0,0 +1,7 @@
export namespace SystemSettings {
export enum Graphql {
Debug = 'graphql.debug.enabled',
IntrospectionEnabled = 'graphql.introspection.enabled',
PlaygroundEnabled = 'graphql.playground.enabled',
}
}

View File

@ -0,0 +1,31 @@
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ConfigService } from '../config/config.service';
import { SystemSettings } from '../enumerations';
import { SystemSettingsResolver } from './system-settings.resolver';
import { PrismaModule } from '../prisma/prisma.module';
import { ConfigModule } from '../config/config.module';
@Module({
imports: [
ConfigModule,
PrismaModule,
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
useFactory: async (configService: ConfigService) => ({
debug: await configService.get(SystemSettings.Graphql.Debug),
playground: await configService.get(SystemSettings.Graphql.PlaygroundEnabled),
introspection: await configService.get(SystemSettings.Graphql.IntrospectionEnabled),
typePaths: ['../../graphql/**/*.graphql'],
}),
inject: [ConfigService],
imports: [ConfigModule],
})
],
providers: [
SystemSettingsResolver,
]
})
export class GraphqlModule {}

View File

@ -0,0 +1,90 @@
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SystemSettingHashValueTypeEnum, SystemSettingsQueryOutput, SystemSettingsQueryOutputError, UpdateSystemSettingInput, UpdateSystemSettingOutput, UpdateSystemSettingOutputError } from '../__generated__/graphql';
import { normalizePairs } from '../utils';
const getUrn = (hashKey: string): string => `urn:system-settings:${hashKey}`;
const parseUrn = (urn: string): string => urn.split(':').pop();
@Resolver('SystemSettings')
export class SystemSettingsResolver {
private readonly logger: Logger = new Logger(SystemSettingsResolver.name);
constructor(
private readonly prismaService: PrismaService,
) {}
@Query()
async systemSettings(): Promise<SystemSettingsQueryOutput> {
try {
const systemSettingsPairs = await this.prismaService.systemSetting.findMany();
const systemSettingsHashSet = normalizePairs(systemSettingsPairs);
return {
data: Object.keys(systemSettingsHashSet).map(hashKey => ({
urn: getUrn(hashKey),
hashKey,
hashValue: systemSettingsHashSet[hashKey].toString(),
hashValueType: this.determineHashValueType(systemSettingsHashSet[hashKey]),
})),
}
} catch (e) {
this.logger.error(e.message);
return {
error: SystemSettingsQueryOutputError.Unknown,
}
}
}
@Mutation()
async updateSystemSetting(
@Args('input') { urn, hashValue }: UpdateSystemSettingInput
): Promise<UpdateSystemSettingOutput> {
try {
const hashKey = parseUrn(urn);
const existing = await this.prismaService.systemSetting.findUnique({
where: {
hashKey
}
});
if (!existing) {
return {
error: UpdateSystemSettingOutputError.NotFound,
}
}
await this.prismaService.systemSetting.update({
data: { hashValue },
where: { hashKey },
});
return {};
} catch (e) {
this.logger.error(e.message);
return {
error: UpdateSystemSettingOutputError.Unknown,
}
}
}
private determineHashValueType = (hashValue: string | number | boolean): SystemSettingHashValueTypeEnum => {
if (typeof hashValue === 'number') {
return SystemSettingHashValueTypeEnum.Number;
}
if (typeof hashValue === 'boolean') {
return SystemSettingHashValueTypeEnum.Boolean;
}
return SystemSettingHashValueTypeEnum.String;
}
}

View File

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

View File

@ -0,0 +1,27 @@
import { NestFactory, repl } from '@nestjs/core';
import { PrismaModule } from './prisma/prisma.module';
import { PrismaService } from './prisma/prisma.service';
import { DataMigration } from './prisma/data-migrations/data-migration.interface';
import { _00ApplicationBootstrapDataMigration } from './prisma/data-migrations';
async function bootstrap() {
const app = await NestFactory.create(PrismaModule);
const prismaService = app.get(PrismaService);
const registeredMigrations: DataMigration[] = [
new _00ApplicationBootstrapDataMigration,
];
const alreadyRan = await prismaService.systemPostMigration.findMany();
const alreadyRanList = alreadyRan.map(e => e.name);
for (const migration of registeredMigrations) {
if (alreadyRanList.includes(migration.name)) {
continue;
}
await migration.run(prismaService);
await prismaService.systemPostMigration.create({ data: { name: migration.name }});
alreadyRanList.push(migration.name);
}
}
bootstrap();

View File

@ -0,0 +1,79 @@
import { Identity, CloudDav, SystemSettings } from '../../enumerations';
import { SecureStringUtil, denormalizeIntoPairs } from '../../utils';
import { PrismaService } from '../prisma.service';
import { DataMigration } from './data-migration.interface';
export class _00ApplicationBootstrapDataMigration implements DataMigration {
readonly name = '00-application-bootstrap-data-migration';
async run(prisma: PrismaService) {
await prisma.$queryRaw`PRAGMA journal_mode=WAL;`;
await prisma.$transaction(async (tx) => {
await tx.enumIdentityAuthDeviceType.create({
data: {
enumValue: Identity.AuthDevice.Type.Password,
}
});
await tx.enumCloudDavResourceType.create({
data: {
enumValue: CloudDav.Resource.Type.Contact,
}
});
const adminGroup = await tx.identityGroup.create({
data: {
isAdmin: true,
}
});
const adminUser = await tx.identityUser.create({
data: {
username: 'admin',
}
});
await tx.identityGroupToIdentityUser.create({
data: {
userId: adminUser.id,
groupId: adminGroup.id,
},
});
await tx.identityAuthDevice.create({
data: {
userId: adminUser.id,
deviceType: Identity.AuthDevice.Type.Password,
hashMapPairs: {
create: [
{
hashKey: Identity.AuthDevice.PasswordHashKey.Expiry,
hashValue: '0',
},
{
hashKey: Identity.AuthDevice.PasswordHashKey.PasswordHashString,
hashValue: await SecureStringUtil.generateNewHash('thiswillrequirechanging'),
}
]
}
}
});
const startingConfig = {
[SystemSettings.Graphql.Debug]: false,
[SystemSettings.Graphql.IntrospectionEnabled]: true,
[SystemSettings.Graphql.PlaygroundEnabled]: true,
}
const configData = denormalizeIntoPairs(startingConfig);
await tx.systemSetting.createMany({
data: configData,
})
});
}
}

View File

@ -0,0 +1,6 @@
import { PrismaService } from '../prisma.service';
export interface DataMigration {
name: string;
run(prisma: PrismaService): void;
}

View File

@ -0,0 +1 @@
export * from './00-application-bootstrap-data-migration';

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Module({
providers: [
PrismaService,
],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -0,0 +1,15 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { _00ApplicationBootstrapDataMigration } from './data-migrations';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@ -0,0 +1,2 @@
export * from './normalize-hash-set';
export * from './secure-string';

View File

@ -0,0 +1,58 @@
interface Pairs {
hashKey: string;
hashValue: string;
}
export const normalizePairs = (pairs: Pairs[]): Record<string, string | number | boolean> => {
return pairs.reduce((acc, {hashKey, hashValue}) => {
acc[hashKey] = coerceTypeByPrefix(hashValue);
return acc;
}, {});
}
const coerceTypeByPrefix = (hashValue: string): string | number | boolean => {
if (hashValue.startsWith(Prefix.Number)) {
return Number(hashValue.substring(Prefix.Number.length));
}
if (hashValue.startsWith(Prefix.Boolean)) {
return Boolean(hashValue.substring(Prefix.Boolean.length));
}
if (hashValue.startsWith(Prefix.String)) {
return String(hashValue.substring(Prefix.String.length));
}
return hashValue;
}
export const denormalizeIntoPairs = (obj: Record<string, string | number | boolean>): Pairs[] => {
return Object.keys(obj).map(hashKey => {
const type = determineTypePrefix(obj[hashKey]);
return {
hashKey,
hashValue: `${type}:${obj[hashKey].toString()}`,
}
});
}
const determineTypePrefix = (hashValue: string | number | boolean): Prefix => {
if (typeof hashValue === 'number') {
return Prefix.Number;
}
if (typeof hashValue === 'boolean') {
return Prefix.Boolean;
}
return Prefix.String;
}
enum Prefix {
String = 'string:',
Boolean = 'boolean:',
Number = 'number:',
}

View File

@ -0,0 +1,117 @@
import * as bcrypt from 'bcrypt';
// https://passlib.readthedocs.io/en/stable/modular_crypt_format.html#mcf-identifiers
type OSDefinedHashPrefix = |
'$1$' |
'$2$' | '$2a$' | '$2x$' | '$2y$' | '$2b$' |
'$5$' |
'$6$'
;
enum NodeCryptoAlgs {
md5 = 'md5',
bcrypt = 'bcrypt',
sha256 = 'sha256',
sha512 = 'sha512',
}
const hashDictionary: Record<OSDefinedHashPrefix, NodeCryptoAlgs> = {
$1$: NodeCryptoAlgs.md5,
$2$: NodeCryptoAlgs.bcrypt,
$2a$: NodeCryptoAlgs.bcrypt,
$2x$: NodeCryptoAlgs.bcrypt,
$2y$: NodeCryptoAlgs.bcrypt,
$2b$: NodeCryptoAlgs.bcrypt,
$5$: NodeCryptoAlgs.sha256,
$6$: NodeCryptoAlgs.sha512,
}
const inversedHashDictionary: Record<NodeCryptoAlgs, OSDefinedHashPrefix> = {
[NodeCryptoAlgs.md5]: '$1$',
[NodeCryptoAlgs.bcrypt]: '$2$',
[NodeCryptoAlgs.sha256]: '$5$',
[NodeCryptoAlgs.sha512]: '$6$',
}
const saltStringLengthDictionary: Record<NodeCryptoAlgs, number> = {
[NodeCryptoAlgs.md5]: 23,
[NodeCryptoAlgs.bcrypt]: 23,
[NodeCryptoAlgs.sha256]: 23,
[NodeCryptoAlgs.sha512]: 23,
}
type HashingFunction = (rawString: string, workFactor: number, salt: string) => Promise<string>;
const algImplementationMap: Record<NodeCryptoAlgs, HashingFunction> = {
[NodeCryptoAlgs.md5]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
throw new Error('Function not implemented.');
},
[NodeCryptoAlgs.bcrypt]: async function (rawString: string, _workFactor: number, salt: string): Promise<string> {
return bcrypt.hash(rawString, salt);
},
[NodeCryptoAlgs.sha256]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
throw new Error('Function not implemented.');
},
[NodeCryptoAlgs.sha512]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
throw new Error('Function not implemented.');
}
}
export class SecureStringUtil {
private static readonly defaultWorkFactor = 15;
static async generateNewHash(rawString: string): Promise<string> {
const alg = NodeCryptoAlgs.bcrypt;
const workFactor = this.defaultWorkFactor;
const salt = await bcrypt.genSalt(workFactor);
return algImplementationMap[alg](rawString, workFactor, salt);
}
static async generateHash(rawString: string, alg: NodeCryptoAlgs, workFactor: number, salt: string): Promise<string> {
return algImplementationMap[alg](rawString, workFactor, salt);
}
static async compare(rawString: string, hashMetaSerialized: string): Promise<boolean> {
const { alg, salt, workFactor, hash: hashA} = HashMeta.deserialize(hashMetaSerialized);
const hashB = await this.generateHash(rawString, alg, workFactor, salt);
return hashA === hashB;
}
}
class HashMeta {
constructor(
public readonly alg: NodeCryptoAlgs,
public readonly workFactor: number,
public readonly salt: string,
public readonly hash: string,
) {}
static deserialize(hashMeta: string): HashMeta {
const [_, alg, workFactor, salthash] = hashMeta.split('$');
const saltStringLength = saltStringLengthDictionary[alg];
const salt = salthash.substring(0, saltStringLength);
const hash = salthash.substring(saltStringLength);
return new HashMeta(
hashDictionary[alg],
+workFactor,
salt,
hash
);
}
serialize(): string {
return [
'',
inversedHashDictionary[this.alg],
this.workFactor,
`${this.salt}${this.hash}`
].join('$');
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}