This commit is contained in:
Vyn 2025-06-11 09:50:52 +02:00
commit 61cbd57af1
168 changed files with 31208 additions and 0 deletions

12
server/.eslintrc.json Normal file
View file

@ -0,0 +1,12 @@
{
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"root": true,
"rules": {
"indent": ["error", "tab"],
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "error",
"object-curly-spacing": "error"
}
}

1
server/README.md Normal file
View file

@ -0,0 +1 @@
# awary-server

25
server/conf-example.env Normal file
View file

@ -0,0 +1,25 @@
ENV=production
SERVER_HOST=0.0.0.0
SERVER_PORT=8080
DB_HOST=127.0.0.1
DB_PORT=27017
DB_USER=replace-me
DB_PASSWORD=replace-me
DB_NAME=awary
# For the following variables, you might want some long random string (if you have openssl installed you can use `openssl rand -base64 64`)
JWT_SECRET=replace-me
API_ADMIN_AUTHORIZATION=replace-me
ENABLE_USER_REGISTRATION=true
RATE_LIMIT_ENABLED=false
# Uncomment to enable
#
# MAX_ACCOUNT=100
# MAX_PROJECT_PER_ACCOUNT=4
# MAX_METRIC_PER_PROJECT=5
# METRICS_MAX_UPDATE_PER_MINUTE=6
# METRICS_HISTORY_LENGTH=1000

24
server/conf-test.env Normal file
View file

@ -0,0 +1,24 @@
ENV=TEST
SERVER_HOST=0.0.0.0
SERVER_PORT=8081
DB_HOST=127.0.0.1
DB_PORT=27018
DB_USER=DevUser
DB_PASSWORD=DevPassword
DB_NAME=awary-test
JWT_SECRET=ujebxzQ1qZC/jAFBXnVk/KfKwqJd8U+zDo3ZX3ejWAC26XaV1J9kjxa4CGvkMpTxvhFaFBPZoL5DdKnWWEDfVQ==
API_ADMIN_AUTHORIZATION=cM6FB3X27THShaNniHwF4bewRc8QFGe0/CIOOPiwcJLa3jtRhIjcTEVW/zPOKDrR6e5kMv+j0kFgXmDjrM/hWA==
ENABLE_USER_REGISTRATION=true
RATE_LIMIT_ENABLED=false
MAX_ACCOUNT=100
MAX_PROJECT_PER_ACCOUNT=4
MAX_METRIC_PER_PROJECT=5
METRICS_MAX_UPDATE_PER_MINUTE=6
METRICS_HISTORY_LENGTH=1000

15
server/create-user.sh Normal file
View file

@ -0,0 +1,15 @@
#!/bin/sh
. ./conf.env
BASE_URL=$1
EMAIL=$2
PASSWORD=`openssl rand -base64 16 | sed -e "s/=//g"`
echo $API_ADMIN_AUTHORIZATION
echo $1
echo $PASSWORD
curl -X POST $BASE_URL/signup \
-H 'Content-Type: application/json' \
-d "{\"email\": \"$EMAIL\", \"password\": \"$PASSWORD\", \"adminToken\": \"$API_ADMIN_AUTHORIZATION\"}"

5240
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
server/package.json Normal file
View file

@ -0,0 +1,43 @@
{
"devDependencies": {
"@sinclair/typebox": "^0.23.5",
"@types/chai": "^4.3.1",
"@types/mocha": "^9.1.1",
"@types/node": "^18.0.0",
"@types/nodemailer": "^6.4.4",
"@types/sinon": "^10.0.11",
"@typescript-eslint/eslint-plugin": "^5.54.0",
"@typescript-eslint/parser": "^5.54.0",
"chai": "^4.3.6",
"env-cmd": "^10.1.0",
"eslint": "^8.35.0",
"mocha": "^10.2.0",
"mocha-better-spec-reporter": "^3.1.0",
"nodemon": "^2.0.16",
"sinon": "^14.0.0",
"ts-mocha": "^10.0.0",
"ts-node": "^10.8.1",
"ts-node-dev": "^2.0.0",
"tsc-alias": "^1.8.2",
"tsc-watch": "^6.0.0",
"tsconfig-paths": "^4.0.0",
"typescript": "^4.9.5"
},
"dependencies": {
"@fastify/cors": "^8.0.0",
"@fastify/jwt": "^6.1.0",
"@fastify/type-provider-typebox": "^1.0.0",
"argon2": "^0.28.5",
"axios": "^0.27.2",
"fastify": "^4.0.3",
"mongodb": "^4.7.0",
"nodemailer": "^6.7.5"
},
"scripts": {
"dev": "LOGGER_ENABLED=1 env-cmd -f conf.env nodemon --watch './**/*.ts' --exec node --inspect=0.0.0.0 -r ts-node/register src/index.ts",
"start:prod": "LOGGER_ENABLED=1 env-cmd -f conf.env node -r ts-node/register src/index.ts",
"test": "env-cmd -f conf-test.env --no-override npx mocha -r ts-node/register --exit 'src/**/*.test.ts'",
"test-file": "env-cmd -f conf-test.env --no-override npx mocha -r ts-node/register",
"lint": "eslint src"
}
}

View file

@ -0,0 +1,12 @@
source ./conf-test.env
mkdir -p ./db-test
podman stop awary_mongodb_test
podman rm awary_mongodb_test
podman run -dt -p 27018:27017 \
--name awary_mongodb_test \
--userns keep-id \
-e MONGO_INITDB_ROOT_USERNAME=${DB_USER} \
-e MONGO_INITDB_ROOT_PASSWORD=${DB_PASSWORD} \
-v ./db-test:/data/db \
docker.io/library/mongo:5.0

13
server/podman-mongodb.sh Normal file
View file

@ -0,0 +1,13 @@
source ./conf.env
mkdir -p ./db
mkdir -p ./backup
podman stop awary_mongodb
podman rm awary_mongodb
podman run -dt -p 27017:27017 \
--name awary_mongodb \
--userns keep-id \
-e MONGO_INITDB_ROOT_USERNAME=${DB_USER} \
-e MONGO_INITDB_ROOT_PASSWORD=${DB_PASSWORD} \
-v ./db:/data/db \
docker.io/library/mongo:5.0

View file

@ -0,0 +1,3 @@
API_ADMIN_AUTHORIZATION=replace-me ## Must match the conf.env
BEARER_TOKEN=random_string
HOST=127.0.0.1:8080

View file

@ -0,0 +1,36 @@
### LOGIN
POST http://{{HOST}}/login
Content-Type: application/json
{
"email": "user1@email.com",
"password": "123456"
}
{%
local body = context.json_decode(context.result.body)
context.set_env("BEARER_TOKEN", body.token)
%}
### SIGNUP
POST http://{{HOST}}/signup
Content-Type: application/json
{
"email": "user1@email.com",
"password": "123456"
}
### FORCE CHANGE PASSWORD
POST http://{{HOST}}/admin/change-user-password
Content-Type: application/json
Authorization: Bearer {{API_ADMIN_AUTHORIZATION}}
{
"email": "user1@email.com",
"newPassword": "1234567"
}

56
server/src/core/App.ts Normal file
View file

@ -0,0 +1,56 @@
import {Db} from "mongodb";
import {UserFeature} from "./features/users";
import {ProjectFeature} from "./features/projects";
import {MetricFeature} from "./features/metrics";
import {LogFeature} from "./features/logs";
import {ActivityLoggerFeature} from "./features/activityLogger";
import {ViewsFeature} from "./features/views";
import {Logger} from "@app/utils/logger";
import {ServerAdminFeature} from "./features/serverAdmin";
export class App {
db?: Db
userFeature: UserFeature
projectFeature: ProjectFeature
logFeature: LogFeature
metricFeature: MetricFeature
viewsFeature: ViewsFeature
activityLogger: ActivityLoggerFeature
serverAdminFeature: ServerAdminFeature
constructor(db: Db) {
const services = {db}
this.userFeature = new UserFeature(services);
this.projectFeature = new ProjectFeature(services, {
userFeature: this.userFeature
});
this.logFeature = new LogFeature(services, {
userFeature: this.userFeature,
projectFeature: this.projectFeature
})
this.metricFeature = new MetricFeature(services, {
userFeature: this.userFeature,
projectFeature: this.projectFeature
})
this.viewsFeature = new ViewsFeature(services, {
metricFeature: this.metricFeature
})
this.activityLogger = new ActivityLoggerFeature(services, {
userFeature: this.userFeature,
projectFeature: this.projectFeature,
logFeature: this.logFeature,
metricFeature: this.metricFeature
})
this.serverAdminFeature = new ServerAdminFeature(services, {
userFeature: this.userFeature,
projectFeature: this.projectFeature,
logFeature: this.logFeature,
metricFeature: this.metricFeature
})
}
async start(): Promise<void> {
Logger.info("Starting application")
}
}

View file

@ -0,0 +1,20 @@
import {Db} from "mongodb";
import {UserFeature} from "./features/users";
export type AppServices = {
db: Db
}
export type AppFeatures = {
userFeature: UserFeature
}
export abstract class Feature {
public abstract name: string
protected services: AppServices
constructor(services: AppServices) {
this.services = services
}
}

View file

@ -0,0 +1,11 @@
export class FeatureEvent<T> {
callbacks: ((data: T) => Promise<void>)[] = []
register(callback: (data: T) => Promise<void>) {
this.callbacks.push(callback)
}
async emit(data: T): Promise<void> {
await Promise.all(this.callbacks.map(callback => callback(data)))
}
}

View file

@ -0,0 +1,3 @@
export interface Identifiable {
id: string
}

View file

@ -0,0 +1,3 @@
export class LimitReached extends Error {
}

View file

@ -0,0 +1,65 @@
import {getAdminProjectId, getAdminSucessTagId} from "@app/utils";
import {LogsUseCases} from "../logs/LogsUseCases";
import {MetricFeature} from "../metrics";
import {onMetricCreatedData} from "../metrics/MetricsEvents";
import {ProjectFeature} from "../projects";
import {Caller, SystemCaller} from "../projects/entities/Caller";
import {ProjectContext} from "../projects/ProjectContext";
import {onProjectCreatedData} from "../projects/ProjectsEvents";
import {UserFeature} from "../users";
import {onUserCreatedData} from "../users/UsersEvents";
interface ActivityLoggerDependencies {
userFeature: UserFeature
projectFeature: ProjectFeature
logService: LogsUseCases
metricFeature: MetricFeature
}
export class ActivityLoggerService {
private _userFeature: UserFeature
private _projectFeature: ProjectFeature
private _logService: LogsUseCases
private _metricFeature: MetricFeature
private _systemCaller = new Caller(new SystemCaller())
constructor(dependencies: ActivityLoggerDependencies) {
this._userFeature = dependencies.userFeature
this._projectFeature = dependencies.projectFeature
this._logService = dependencies.logService
this._metricFeature = dependencies.metricFeature
this._metricFeature.events.onMetricCreated.register(data => this.logMetricCreation(data))
this._projectFeature.events.onProjectCreated.register(data => this.logProjectCreation(data))
this._userFeature.events.onMetricCreated.register(data => this.adminLogUserCreation(data))
}
private async adminLogUserCreation(data: onUserCreatedData): Promise<void> {
const adminProjectId = getAdminProjectId()
if (!adminProjectId)
return ;
const project = await this._projectFeature.projectRepository.findProjectById(adminProjectId)
if (!project)
return ;
const context = new ProjectContext(project, this._systemCaller)
const adminSucessTagId = getAdminSucessTagId()
await this._logService.addLog(context, {
title: `New user: ${data.user.email}`,
tags: adminSucessTagId ? [adminSucessTagId] : []
});
}
private async logProjectCreation(data: onProjectCreatedData): Promise<void> {
const context = new ProjectContext(data.project, this._systemCaller)
await this._logService.addLog(context, {
title: `Welcome to your new project: "${data.project.name}"`,
});
}
private async logMetricCreation(data: onMetricCreatedData): Promise<void> {
const context = new ProjectContext(data.project, this._systemCaller)
await this._logService.addLog(context, {
title: `Created metric "${data.metric.name}"`,
});
}
}

View file

@ -0,0 +1,31 @@
import {Feature, AppServices} from "@app/core/Feature";
import {LogFeature} from "../logs";
import {MetricFeature} from "../metrics";
import {ProjectFeature} from "../projects";
import {UserFeature} from "../users";
import {ActivityLoggerService} from "./ActivityLogger";
export type ActivityLoggerFeatureDependencies = {
userFeature: UserFeature
projectFeature: ProjectFeature
logFeature: LogFeature
metricFeature: MetricFeature
}
export class ActivityLoggerFeature extends Feature {
public name = "ActivityLogger"
public service: ActivityLoggerService
public dependencies: ActivityLoggerFeatureDependencies
constructor(services: AppServices, dependencies: ActivityLoggerFeatureDependencies) {
super(services);
this.dependencies = dependencies
this.service = new ActivityLoggerService({
userFeature: this.dependencies.userFeature,
projectFeature: this.dependencies.projectFeature,
logService: this.dependencies.logFeature.useCases,
metricFeature: this.dependencies.metricFeature
})
}
}

View file

@ -0,0 +1,71 @@
import {Collection, Db, ObjectId, WithId} from "mongodb";
import {Project} from "../projects/entities/Project";
import {LogDataOnCreation, Log} from "./entities";
export interface LogDocument {
projectId: ObjectId
title: string
content?: string
tags?: string[]
createdAt: number
}
function ConvertLogDocumentToEntity(document: WithId<LogDocument>): Log {
return new Log({
id: document._id.toString(),
projectId: document.projectId.toString(),
title: document.title,
tags: document.tags || [],
content: document.content,
createdAt: document.createdAt
})
}
export class LogsRepository {
private _projectsLogs: Collection<LogDocument>
constructor(db: Db) {
this._projectsLogs = db.collection("projectsLogs")
}
async create(project: Project, data: LogDataOnCreation): Promise<void> {
await this._projectsLogs.insertOne({
projectId: new ObjectId(project.id),
title: data.title,
content: data.content,
tags: data.tags,
createdAt: Date.now()
});
}
async findLogs(project: Project): Promise<Log[]> {
const results = await this._projectsLogs.find({
projectId: new ObjectId(project.id)
})
.sort({createdAt: -1})
.toArray();
const resultsWithId = results.map(value => ConvertLogDocumentToEntity(value));
return resultsWithId
}
async findLogById(logId: string): Promise<Log | null> {
const result = await this._projectsLogs.findOne({
_id: new ObjectId(logId),
});
if (!result) {
return result;
}
return ConvertLogDocumentToEntity(result)
}
async deleteLog(log: Log): Promise<void> {
await this._projectsLogs.deleteOne({
_id: new ObjectId(log.id),
});
}
async count(): Promise<number> {
return this._projectsLogs.countDocuments();
}
}

View file

@ -0,0 +1,95 @@
import {MissingResource} from "../projects/exceptions/MissingResource";
import {ProjectAuthorization, ProjectContext} from "../projects/ProjectContext";
import {ProjectsUseCases} from "../projects/ProjectsUseCases";
import {Log, LogDataOnCreation, Tag, TagOnCreation} from "./entities";
import {LogsRepository} from "./LogsRepository";
import {TagsRepository} from "./TagsRepository";
interface LogServiceDependencies {
projectService: ProjectsUseCases,
logsRepository: LogsRepository,
tagsRepository: TagsRepository
}
export class LogsUseCases {
private _projectService: ProjectsUseCases
private _logsRepository: LogsRepository
private _tagsRepository: TagsRepository
constructor(dependencies: LogServiceDependencies) {
this._projectService = dependencies.projectService
this._logsRepository = dependencies.logsRepository
this._tagsRepository = dependencies.tagsRepository
}
async addLog(context: ProjectContext, log: LogDataOnCreation): Promise<void> {
context.enforceAuthorizations([ProjectAuthorization.Write])
await this._logsRepository.create(context.project, {
title: log.title,
content: log.content,
tags: log.tags,
})
}
async getLogById(context: ProjectContext, logId: string): Promise<Log> {
context.enforceAuthorizations([ProjectAuthorization.Read])
const log = await this._logsRepository.findLogById(logId)
if (!log) {
throw new MissingResource("Log doesn't exist")
}
return log;
}
async getLogs(context: ProjectContext) {
context.enforceAuthorizations([ProjectAuthorization.Read])
return await this._logsRepository.findLogs(context.project);
}
async deleteLog(context: ProjectContext, log: Log) {
context.enforceAuthorizations([ProjectAuthorization.Write])
await this._logsRepository.deleteLog(log);
}
async createTag(context: ProjectContext, data: Omit<TagOnCreation, "projectId">): Promise<Tag> {
context.enforceAuthorizations([ProjectAuthorization.Write])
return this._tagsRepository.create(context.project, {
projectId: context.project.id,
name: data.name,
color: data.color,
})
}
async updateTag(context: ProjectContext, tag: Tag, data: Omit<TagOnCreation, "projectId">): Promise<void> {
context.enforceAuthorizations([ProjectAuthorization.Write])
await this._tagsRepository.updateTag(tag, {
name: data.name,
color: data.color,
})
}
async getTags(context: ProjectContext): Promise<Tag[]> {
context.enforceAuthorizations([ProjectAuthorization.Write])
return await this._tagsRepository.findByProject(context.project);
}
async getTag(context: ProjectContext, id: string): Promise<Tag> {
context.enforceAuthorizations([ProjectAuthorization.Write])
const tag = await this._tagsRepository.findById(context.project, id);
if (!tag) {
throw new MissingResource("Tag doesn't exist")
}
return tag;
}
async deleteTag(context: ProjectContext, tag: Tag): Promise<void> {
context.enforceAuthorizations([ProjectAuthorization.Write])
await this._tagsRepository.deleteTag(tag);
}
}

View file

@ -0,0 +1,80 @@
import {Collection, Db, ObjectId, WithId} from "mongodb";
import {Project} from "../projects/entities/Project";
import {Tag, TagOnCreation} from "./entities";
export interface TagDocument {
projectId: ObjectId
name: string
color: string
}
function ConvertTagDocumentToEntity(document: WithId<TagDocument>): Tag {
return {
id: document._id.toString(),
projectId: document.projectId.toString(),
name: document.name,
color: document.color
}
}
export class TagsRepository {
private _tags: Collection<TagDocument>
constructor(db: Db) {
this._tags = db.collection("tags")
}
async create(project: Project, data: TagOnCreation): Promise<Tag> {
const tag = await this._tags.insertOne({
projectId: new ObjectId(project.id),
name: data.name,
color: data.color
});
return ConvertTagDocumentToEntity({
_id: tag.insertedId,
projectId: new ObjectId(project.id),
name: data.name,
color: data.color
})
}
async updateTag(tag: Tag, data: Partial<TagDocument>): Promise<void> {
await this._tags.updateOne({_id: new ObjectId(tag.id)}, {
$set: {
name: data.name,
color: data.color
}
});
}
async findByProject(project: Project): Promise<Tag[]> {
const results = await this._tags.find({
projectId: new ObjectId(project.id)
})
.sort({createdAt: -1})
.toArray();
const resultsWithId = results.map(value => ConvertTagDocumentToEntity(value));
return resultsWithId
}
async findById(project: Project, id: string): Promise<Tag | null> {
const result = await this._tags.findOne({
projectId: new ObjectId(project.id),
_id: new ObjectId(id)
})
if (!result)
return null
return ConvertTagDocumentToEntity(result)
}
async deleteTag(tag: Tag): Promise<void> {
await this._tags.deleteOne({
_id: new ObjectId(tag.id),
});
}
async count(): Promise<number> {
return this._tags.countDocuments();
}
}

View file

@ -0,0 +1,35 @@
import {Identifiable} from "@app/core/Identifiable"
export interface LogDataOnCreation {
title: string
content?: string
tags?: string[]
}
export interface LogConstructor extends Identifiable {
id: string
projectId: string
title: string
content?: string
tags?: string[]
createdAt: number
}
export class Log {
public readonly id: string
public readonly projectId: string
public readonly title: string
public readonly content?: string
public readonly tags: string[]
public readonly createdAt: number
constructor(data: LogConstructor) {
this.id = data.id
this.projectId = data.projectId
this.title = data.title
this.content = data.content
this.tags = data.tags || []
this.createdAt = data.createdAt
}
}

View file

@ -0,0 +1,12 @@
export interface TagOnCreation {
projectId: string
name: string
color: string
}
export type Tag = {
id: string
projectId: string
name: string
color: string
}

View file

@ -0,0 +1,2 @@
export * from "./Log"
export * from "./Tag"

View file

@ -0,0 +1,32 @@
import {Feature, AppServices} from "@app/core/Feature";
import {ProjectFeature} from "../projects";
import {UserFeature} from "../users";
import {LogsRepository} from "./LogsRepository";
import {LogsUseCases} from "./LogsUseCases";
import {TagsRepository} from "./TagsRepository";
export type LogFeatureDependencies = {
userFeature: UserFeature
projectFeature: ProjectFeature
}
export class LogFeature extends Feature {
name = "Log"
logsRepository: LogsRepository
tagsRepository: TagsRepository
useCases: LogsUseCases
dependencies: LogFeatureDependencies
constructor(services: AppServices, dependencies: LogFeatureDependencies) {
super(services);
this.logsRepository = new LogsRepository(services.db)
this.tagsRepository = new TagsRepository(services.db)
this.dependencies = dependencies
this.useCases = new LogsUseCases({
projectService: this.dependencies.projectFeature.service,
logsRepository: this.logsRepository,
tagsRepository: this.tagsRepository
})
}
}

View file

@ -0,0 +1,14 @@
import {FeatureEvent} from "@app/core/FeatureEvent";
import {Caller} from "../projects/entities/Caller";
import {Project} from "../projects/entities/Project";
import {Metric} from ".";
export type onMetricCreatedData = {
project: Project
metric: Metric
caller: Caller
}
export class MetricEvents {
onMetricCreated: FeatureEvent<onMetricCreatedData> = new FeatureEvent()
}

View file

@ -0,0 +1,141 @@
import {Collection, Db, ObjectId, WithId} from "mongodb";
import {Project} from "../projects/entities";
import {Metric, MetricValue} from "./entities";
interface MetricDocument {
projectId: ObjectId
name: string
currentValue: undefined | number
}
interface MetricHistoryDocument {
metricId: ObjectId
date: number // timestamp
value: number
}
function MetricDocumentToEntity(document: WithId<MetricDocument>, values: WithId<MetricHistoryDocument>[] | null): Metric {
return new Metric({
id: document._id.toString(),
name: document.name,
projectId: document.projectId.toString(),
currentValue: document.currentValue,
history: values ? values.map(MetricHistoryDocumentToEntity) : null
})
}
function MetricHistoryDocumentToEntity(document: WithId<MetricHistoryDocument>): MetricValue {
return {
id: document._id.toString(),
metricId: document.metricId.toString(),
date: document.date,
value: document.value,
};
}
export class MetricsRepository {
private _metrics: Collection<MetricDocument>
private _metricsHistory: Collection<MetricHistoryDocument>
constructor(db: Db) {
this._metrics = db.collection("projectsMetrics")
this._metricsHistory = db.collection("projectsMetricsHistory")
}
async createMetric(project: Project, name: string): Promise<Metric> {
const metric = await this._metrics.insertOne({
projectId: new ObjectId(project.id),
name: name,
currentValue: undefined
});
return MetricDocumentToEntity({
_id: metric.insertedId,
projectId: new ObjectId(project.id),
name,
currentValue: undefined
}, null)
}
async updateMetric(metric: Metric, name: string): Promise<void> {
await this._metrics.updateOne({_id: new ObjectId(metric.id)}, {
$set: {
name: name,
}
});
}
async AddValueToHistory(metric: Metric, value: Omit<MetricValue, "id" | "metricId">): Promise<void> {
const metricQuery = {projectId: new ObjectId(metric.projectId), name: metric.name}
const newValueDocument = {...value, metricId: new ObjectId(metric.id)}
await this._metricsHistory.insertOne(newValueDocument);
await this._metrics.updateOne(metricQuery, {$set: {currentValue: value.value}})
}
async deleteHistoryRecord(metric: Metric, recordId: string): Promise<void> {
const deleteFilter = {metricId: new ObjectId(metric.id), _id: new ObjectId(recordId)}
await this._metricsHistory.deleteOne(deleteFilter);
}
async findAll(project: Project, fetchValues = false): Promise<Metric[]> {
const metric = await this._metrics.find({projectId: new ObjectId(project.id)}).toArray()
const metricIds = metric.map(metric => metric._id);
const allMetricValues = await this._metricsHistory.find({metricId: {$in: metricIds}}).sort({date: 1}).toArray()
return metric.map(thisMetric => {
if (!fetchValues) {
return MetricDocumentToEntity(thisMetric, null);
}
const values = allMetricValues.filter(value => value.metricId.equals(thisMetric._id))
return MetricDocumentToEntity(thisMetric, values);
})
}
async findOne(project: Project, name: string, fetchValues = false): Promise<Metric | null> {
const metric = await this._metrics.findOne({projectId: new ObjectId(project.id), name})
if (!metric) {
return null;
}
if (!fetchValues) {
return MetricDocumentToEntity(metric, null);
}
const values = await this._metricsHistory.find({metricId: metric._id}).sort({date: 1}).toArray()
return MetricDocumentToEntity(metric, values);
}
async findMetricById(project: Project, id: string, fetchValues = false): Promise<Metric | null> {
const metric = await this._metrics.findOne({projectId: new ObjectId(project.id),_id: new ObjectId(id)})
if (!metric) {
return null;
}
if (!fetchValues) {
return MetricDocumentToEntity(metric, null);
}
const values = await this._metricsHistory.find({metricId: metric._id}).sort({date: 1}).toArray()
return MetricDocumentToEntity(metric, values);
}
async MetricExists(project: Project, name: string): Promise<boolean> {
const metric = await this._metrics.findOne({projectId: new ObjectId(project.id), name});
return metric !== null
}
async deleteMetric(metric: Metric): Promise<void> {
const metricId = new ObjectId(metric.id);
await this._metricsHistory.deleteMany({metricId: metricId})
await this._metrics.deleteOne({_id: metricId})
}
async getHistoryLengthOfMetric(metric: Metric): Promise<number> {
return this._metricsHistory.countDocuments({metricId: new ObjectId(metric.id)});
}
async count(): Promise<number> {
return this._metrics.countDocuments();
}
async countHistory(): Promise<number> {
return this._metricsHistory.countDocuments();
}
}

View file

@ -0,0 +1,84 @@
import {LimitReached} from "@app/core/exceptions/LimitReached";
import {MissingResource} from "../projects/exceptions/MissingResource";
import {ProjectAuthorization, ProjectContext} from "../projects/ProjectContext";
import {ProjectsUseCases} from "../projects/ProjectsUseCases";
import {getMetricsHistoryLimit} from "../users/utils";
import {Metric, MetricCreationProperties} from "./entities";
import {MetricEvents} from "./MetricsEvents";
import {MetricsRepository} from "./MetricsRepository";
interface MetricServiceDependencies {
projectService: ProjectsUseCases,
metricRepository: MetricsRepository,
metricEvents: MetricEvents
}
export class MetricsUseCases {
private _projectService: ProjectsUseCases
private _metricRepository: MetricsRepository
private _metricEvents: MetricEvents
constructor(dependencies: MetricServiceDependencies) {
this._projectService = dependencies.projectService
this._metricRepository = dependencies.metricRepository
this._metricEvents = dependencies.metricEvents
}
async createMetric(context: ProjectContext, metricInfo: MetricCreationProperties): Promise<Metric> {
context.enforceAuthorizations([ProjectAuthorization.Write])
const {project, caller} = context;
const metric = await this._metricRepository.createMetric(project, metricInfo.name)
await this._metricEvents.onMetricCreated.emit({project, metric: metric, caller});
return metric
}
async updateMetric(context: ProjectContext, metric: Metric, metricInfo: MetricCreationProperties): Promise<void> {
context.enforceAuthorizations([ProjectAuthorization.Write])
await this._metricRepository.updateMetric(metric, metricInfo.name)
}
async getAllMetrics(context: ProjectContext, fetchValues?: boolean): Promise<Metric[]> {
context.enforceAuthorizations([ProjectAuthorization.Read])
const metric = await this._metricRepository.findAll(context.project, fetchValues);
return metric
}
async getMetricById(context: ProjectContext, metricId: string, fetchValues?: boolean): Promise<Metric> {
context.enforceAuthorizations([ProjectAuthorization.Read])
const metric = await this._metricRepository.findMetricById(context.project, metricId, fetchValues);
if (!metric) {
throw new MissingResource("Metric doesn't exist")
}
return metric
}
async deleteMetric(context: ProjectContext, metric: Metric): Promise<void> {
context.enforceAuthorizations([ProjectAuthorization.Write])
await this._metricRepository.deleteMetric(metric);
}
async deleteMetricHistoryRecord(context: ProjectContext, metric: Metric, recordId: string): Promise<void> {
context.enforceAuthorizations([ProjectAuthorization.Write])
await this._metricRepository.deleteHistoryRecord(metric, recordId);
}
async setMetricValue(context: ProjectContext, metric: Metric, value: number, date?: number) {
context.enforceAuthorizations([ProjectAuthorization.Write])
const currentHistoryLength = await this._metricRepository.getHistoryLengthOfMetric(metric)
if (currentHistoryLength >= getMetricsHistoryLimit()) {
throw new LimitReached("History limit reached (experimental feature)")
}
await this._metricRepository.AddValueToHistory(metric, {
date: date || Date.now(),
value
})
}
}

View file

@ -0,0 +1,40 @@
import {Identifiable} from "@app/core/Identifiable"
export interface MetricCreationProperties {
name: string
}
export interface MetricConstructor extends Identifiable {
projectId: string
name: string
history: MetricValue[] | null
currentValue: undefined | number
}
export interface MetricValue extends Identifiable {
metricId: string
date: number // timestamp
value: number
}
export class Metric implements MetricConstructor {
public readonly id: string
public readonly projectId: string
public readonly name: string
public readonly history: MetricValue[] | null
public readonly currentValue: undefined | number
constructor(data: MetricConstructor) {
this.id = data.id
this.projectId = data.projectId
this.name = data.name
this.history = data.history
this.currentValue = data.currentValue
}
hasValues(): boolean {
return this.history !== null
}
}

View file

@ -0,0 +1 @@
export * from "./Metric"

View file

@ -0,0 +1,35 @@
import {Feature, AppServices} from "@app/core/Feature";
import {ProjectFeature} from "../projects";
import {UserFeature} from "../users";
import {MetricEvents} from "./MetricsEvents";
import {MetricsRepository} from "./MetricsRepository";
import {MetricsUseCases} from "./MetricsUseCases";
export * from "./MetricsUseCases"
export * from "./entities"
export type MetricFeatureDependencies = {
userFeature: UserFeature
projectFeature: ProjectFeature
}
export class MetricFeature extends Feature {
name = "Metric"
events: MetricEvents = new MetricEvents()
dependencies: MetricFeatureDependencies
repository: MetricsRepository
useCases: MetricsUseCases
constructor(services: AppServices, dependencies: MetricFeatureDependencies) {
super(services);
this.repository = new MetricsRepository(services.db)
this.dependencies = dependencies
this.useCases = new MetricsUseCases({
projectService: this.dependencies.projectFeature.service,
metricRepository: this.repository,
metricEvents: this.events
})
}
}

View file

@ -0,0 +1,43 @@
import {Collection, Db, ObjectId} from "mongodb";
import {ApiKey} from "./entities/ApiKey";
import {ApiKeyData} from "./entities/ApiKeyData";
import {Project} from "./entities/Project";
export class ApiKeyRepository {
private _repository: Collection<Omit<ApiKeyData, "id">>
constructor(db: Db) {
this._repository = db.collection("projectsApiKeys")
}
async create(data: Omit<ApiKeyData, "id">): Promise<void> {
await this._repository.insertOne(data);
}
async delete(apiKey: ApiKey): Promise<void> {
await this._repository.deleteOne({_id: new ObjectId(apiKey.id)});
}
async findByKey(key: string): Promise<ApiKey | null> {
const result = await this._repository.findOne({key});
if (!result) {
return null;
}
return new ApiKey({...result, id: result._id.toString()})
}
async findById(id: string): Promise<ApiKey | null> {
const result = await this._repository.findOne({_id: new ObjectId(id)});
if (!result) {
return null;
}
return new ApiKey({...result, id: result._id.toString()})
}
async findAllByProject(project: Project): Promise<ApiKey[]> {
const results = await this._repository.find({projectId: project.id}).toArray();
const resultsWithId = results.map(value => new ApiKey(({...value, id: value._id.toString()})));
return resultsWithId;
}
}

View file

@ -0,0 +1,33 @@
import {Project, Caller} from "./entities";
import {MissingAuthorization} from "./exceptions/MissingAuthorization";
export enum ProjectAuthorization {
Read = 'Read',
Write = 'Write'
}
export class ProjectContext {
project: Project
caller: Caller
constructor(project: Project, caller: Caller) {
this.project = project
this.caller = caller
}
enforceAuthorizations(authorizations: string[]): void {
if (this.caller.isSystem())
return
if (this.caller.isUser() && this.caller.asUser().id === this.project.ownerId)
return
if (this.caller.isApiKey() && this.caller.asApiKey().projectId === this.project.id) {
const hasAuthorizations = authorizations.every(authorization =>
this.caller.asApiKey().hasAuthorization(authorization)
)
if (hasAuthorizations)
return;
}
throw new MissingAuthorization("Missing authorization");
}
}

View file

@ -0,0 +1,11 @@
import {FeatureEvent} from "@app/core/FeatureEvent";
import {Project, Caller} from "../projects/entities";
export type onProjectCreatedData = {
project: Project
caller: Caller
}
export class ProjectEvents {
onProjectCreated: FeatureEvent<onProjectCreatedData> = new FeatureEvent()
}

View file

@ -0,0 +1,73 @@
import {Project, ProjectData, Tag} from "./entities";
import {User} from "@app/core/features/users/entities/User";
import {Collection, Db, ObjectId} from "mongodb";
export class ProjectsRepository {
private _projects: Collection<Omit<ProjectData, "id">>
constructor(db: Db) {
this._projects = db.collection("projects")
}
async createProject(data: Omit<ProjectData, "id">): Promise<Project> {
const createProject = await this._projects.insertOne(data);
return this._format({...data, id: createProject.insertedId.toString()})
}
async saveProject(project: Project): Promise<void> {
await this._projects.updateOne(
{_id: new ObjectId(project.id)},
{$set: {...project.getState(), updatedAt: Date.now()}}
)
}
async addTag(project: Project, tag: Tag) : Promise<void> {
await this._projects.updateOne({_id: new ObjectId(project.id)}, {$push: {tags: tag}});
}
async findProjectsOfUser(user: User): Promise<Project[]> {
const results = await this._projects.find({ownerId: user.id}).toArray();
const resultsWithId = results.map(value => this._format({...value, id: value._id.toString()}));
return resultsWithId
}
async findProjectById(id: string): Promise<Project | null> {
const result = await this._projects.findOne({_id: new ObjectId(id)});
if (!result) {
return null;
}
return this._format({...result, id: result._id.toString()})
}
async count(): Promise<number> {
return this._projects.countDocuments();
}
/*async createLog(data: Omit<LogData, "id">): Promise<void> {
await this._projectsLogs.insertOne(data);
}
async findLogsOfProject(project: Project): Promise<LogData[]> {
const results = await this._projectsLogs.find({projectId: project.id}).toArray();
const resultsWithId = results.map(value => ({...value, id: value._id.toString()}));
return resultsWithId
}*/
private _format(data: Partial<ProjectData>): Project {
if (!data.id) {
throw Error("Id is undefined")
}
if (!data.ownerId) {
throw Error("ownerId is undefined")
}
return new Project ({
id: data.id,
ownerId: data.ownerId,
name: data.name || "Missing name",
tags: data.tags || [],
createdAt: data.createdAt || 0,
updatedAt: data.updatedAt || 0
})
}
}

View file

@ -0,0 +1,109 @@
import {LimitReached} from "@app/core/exceptions/LimitReached";
import {User} from "@app/core/features/users/entities/User";
import {randomBytes} from "crypto";
import {getAccountProjectsLimit} from "../users/utils";
import {ApiKeyRepository} from "./ApiKeyRepository";
import {ApiKey, Caller, Project} from "./entities";
import {MissingResource} from "./exceptions/MissingResource";
import {ProjectAuthorization, ProjectContext} from "./ProjectContext";
import {ProjectEvents} from "./ProjectsEvents";
import {ProjectsRepository} from "./ProjectsRepository";
interface ProjectServiceDependencies {
projectRepository: ProjectsRepository,
projectEvents: ProjectEvents
apiKeyRepository: ApiKeyRepository
}
export class ProjectsUseCases {
private _projectRepository: ProjectsRepository
private _projectEvents: ProjectEvents
private _apiKeyRepository: ApiKeyRepository
constructor(dependencies: ProjectServiceDependencies) {
this._projectRepository = dependencies.projectRepository
this._projectEvents = dependencies.projectEvents
this._apiKeyRepository = dependencies.apiKeyRepository
}
async createProject(owner: User, name: string): Promise<Project> {
const userProjects = await this._projectRepository.findProjectsOfUser(owner);
if (userProjects.length >= getAccountProjectsLimit()) {
throw new LimitReached(`Maximum account limit (${getAccountProjectsLimit()}) reached`)
}
const project = await this._projectRepository.createProject({
name,
ownerId: owner.id,
tags: [],
createdAt: Date.now(),
updatedAt: Date.now()
});
await this._projectEvents.onProjectCreated.emit({project, caller: new Caller(owner)})
return project;
}
async getProjectsOfUser(user: User): Promise<Project[]> {
const projects = await this._projectRepository.findProjectsOfUser(user);
return projects;
}
async getProjectById(caller: Caller, id: string): Promise<Project | null> {
const project = await this._projectRepository.findProjectById(id);
if (!project) {
throw new MissingResource("Project not found")
}
const context = new ProjectContext(project, caller)
context.enforceAuthorizations([ProjectAuthorization.Read])
return project;
}
async addTagToProject(context: ProjectContext, {name, color}: {name: string, color: string}): Promise<void> {
context.enforceAuthorizations([ProjectAuthorization.Write])
const {project} = context;
await this._projectRepository.addTag(project, {id: project.tags.length + 1, name, color});
}
async updateTagOfProject(context: ProjectContext, id: number, {name, color}: {name: string, color: string}): Promise<void> {
context.enforceAuthorizations([ProjectAuthorization.Write])
const {project} = context;
project.changeTag(id, name, color);
await this._projectRepository.saveProject(project);
}
async generateApiKey(context: ProjectContext, name: string): Promise<string> {
context.enforceAuthorizations([ProjectAuthorization.Write])
const apiKey = randomBytes(32).toString("base64").replace(/=/gi, "");
await this._apiKeyRepository.create({
key: apiKey,
projectId: context.project.id,
name,
createdAt: Date.now(),
updatedAt: Date.now()
});
return apiKey
}
async getApiKeysOfProject(context: ProjectContext): Promise<ApiKey[]> {
context.enforceAuthorizations([ProjectAuthorization.Read])
return this._apiKeyRepository.findAllByProject(context.project);
}
async deleteApiKeys(context: ProjectContext, apiKey: ApiKey): Promise<void> {
context.enforceAuthorizations([ProjectAuthorization.Write])
return this._apiKeyRepository.delete(apiKey);
}
async getApiKey(context: ProjectContext, apiKey: string): Promise<ApiKey | null> {
context.enforceAuthorizations([ProjectAuthorization.Read])
return this._apiKeyRepository.findByKey(apiKey);
}
async getApiKeyById(context: ProjectContext, keyId: string): Promise<ApiKey> {
context.enforceAuthorizations([ProjectAuthorization.Read])
const apiKey = await this._apiKeyRepository.findById(keyId);
if (!apiKey) {
throw new MissingResource("Api key not found")
}
return apiKey
}
}

View file

@ -0,0 +1,35 @@
import {ProjectAuthorization} from "../ProjectContext";
import {ApiKeyData} from "./ApiKeyData";
export interface ApiKey extends Readonly<ApiKeyData> {
id: string
}
export class ApiKey {
authorizations: string[]
constructor(data: ApiKeyData) {
Object.assign(this, data);
this.authorizations = [ProjectAuthorization.Read, ProjectAuthorization.Write]
}
hasAuthorization(authorization: string): boolean {
return this.authorizations.includes(authorization);
}
toString(): string {
return this.key
}
getState(): ApiKeyData {
return {
id: this.id,
key: this.key,
name: this.name,
projectId: this.projectId,
createdAt: this.createdAt,
updatedAt: this.updatedAt
}
}
}

View file

@ -0,0 +1,8 @@
export interface ApiKeyData {
id: string
key: string
projectId: string
name: string
createdAt: number
updatedAt: number
}

View file

@ -0,0 +1,73 @@
import {User} from "@app/core/features/users/entities";
import {ApiKey} from "./ApiKey";
import {Project} from "./Project";
export class SystemCaller {
}
export class AdminUser {
}
export class Caller {
private _user?: User
private _apiKey?: ApiKey
private _isSystem = false
private _isAdmin = false
constructor(caller: User | ApiKey | SystemCaller | AdminUser) {
if (caller instanceof User) {
this._user = caller
} else if (caller instanceof ApiKey) {
this._apiKey = caller
} else if (caller instanceof SystemCaller) {
this._isSystem = true
} else if (caller instanceof AdminUser) {
this._isAdmin = true
} else {
throw new Error("Wrong type for Caller constructor")
}
}
CanReadProject(project: Project): boolean {
if (this?._user && this._user.id === project.ownerId)
return true
if (this?._apiKey && this._apiKey.projectId === project.id)
return true
return false
}
CanWriteProject(project: Project): boolean {
return this.CanReadProject(project)
}
isApiKey(): boolean {
return !!this._apiKey
}
isUser(): boolean {
return !!this._user
}
isSystem(): boolean {
return this._isSystem;
}
isAdmin(): boolean {
return this._isAdmin;
}
asApiKey(): ApiKey {
if (!this.isApiKey())
throw new Error("Trying to get caller User as ApiKey")
return this._apiKey as ApiKey
}
asUser(): User {
if (!this.isUser())
throw new Error("Trying to get caller ApiKey as User")
return this._user as User
}
}

View file

@ -0,0 +1,45 @@
import {Tag} from "./Tag";
import {ProjectData} from "./ProjectData";
export class Project implements ProjectData {
id: string
name: string
ownerId: string
tags: Tag[]
createdAt: number
updatedAt: number
constructor(data: ProjectData) {
this.id = data.id
this.name = data.name
this.ownerId = data.ownerId
this.tags = data.tags
this.createdAt = data.createdAt
this.updatedAt = data.updatedAt
}
getState(): ProjectData {
return {
id: this.id,
ownerId: this.ownerId,
name: this.name,
tags: this.tags,
createdAt: this.createdAt,
updatedAt: this.updatedAt
}
}
changeTag(id: number, name: string, color: string): Tag {
const tag = this.tags.find(tag => tag.id === id)
if (!tag) {
throw new Error(`Tag '${id}' doesn't exist`);
}
tag.name = name;
tag.color = color;
return tag;
}
hasTag(name: string): boolean {
return this.tags.find(tag => tag.name === name) !== undefined
}
}

View file

@ -0,0 +1,10 @@
import {Tag} from "./Tag"
export interface ProjectData {
id: string
name: string
ownerId: string
tags: Tag[]
createdAt: number
updatedAt: number
}

View file

@ -0,0 +1,5 @@
export interface Tag {
id: number
name: string
color: string
}

View file

@ -0,0 +1,6 @@
export * from "./ApiKey"
export * from "./ApiKeyData"
export * from "./Caller"
export * from "./Project"
export * from "./ProjectData"
export * from "./Tag"

View file

@ -0,0 +1,3 @@
export class MissingAuthorization extends Error {
}

View file

@ -0,0 +1,3 @@
export class MissingResource extends Error {
}

View file

@ -0,0 +1,32 @@
import {Feature, AppServices} from "@app/core/Feature";
import {UserFeature} from "../users";
import {ApiKeyRepository} from "./ApiKeyRepository";
import {ProjectEvents} from "./ProjectsEvents";
import {ProjectsRepository} from "./ProjectsRepository";
import {ProjectsUseCases} from "./ProjectsUseCases";
export type ProjectFeatureDependencies = {
userFeature: UserFeature
}
export class ProjectFeature extends Feature {
name = "Project"
projectRepository: ProjectsRepository
apiKeyRepository: ApiKeyRepository
service: ProjectsUseCases
dependencies: ProjectFeatureDependencies
events: ProjectEvents = new ProjectEvents()
constructor(services: AppServices, dependencies: ProjectFeatureDependencies) {
super(services);
this.projectRepository = new ProjectsRepository(services.db)
this.apiKeyRepository = new ApiKeyRepository(services.db)
this.service = new ProjectsUseCases({
projectRepository: this.projectRepository,
projectEvents: this.events,
apiKeyRepository: this.apiKeyRepository
})
this.dependencies = dependencies
}
}

View file

@ -0,0 +1,56 @@
import {LogFeature} from "../logs";
import {MetricFeature} from "../metrics";
import {ProjectFeature} from "../projects";
import {Caller, SystemCaller} from "../projects/entities/Caller";
import {UserFeature} from "../users";
import {AuthenticationFailed} from "../users/exceptions/AuthenticationFailed";
interface ServerAdminDependencies {
userFeature: UserFeature
projectFeature: ProjectFeature
logFeature: LogFeature,
metricFeature: MetricFeature,
}
export class ServerAdminUseCases {
private _userFeature: UserFeature
private _projectFeature: ProjectFeature
private _logFeature: LogFeature
private _metricFeature: MetricFeature
private _systemCaller = new Caller(new SystemCaller())
constructor(dependencies: ServerAdminDependencies) {
this._userFeature = dependencies.userFeature
this._projectFeature = dependencies.projectFeature
this._logFeature = dependencies.logFeature
this._metricFeature = dependencies.metricFeature
}
async getGlobalStats(caller: Caller): Promise<GlobalStats> {
if (!caller.isAdmin()) {
throw new AuthenticationFailed("User needs to be Admin");
}
const userCount = await this._userFeature.repository.count();
const projectCount = await this._projectFeature.projectRepository.count();
const logCount = await this._logFeature.logsRepository.count();
const metricCount = await this._metricFeature.repository.count();
const metricHistoryEntryCount = await this._metricFeature.repository.countHistory();
return {
userCount,
projectCount,
logCount,
metricCount,
metricHistoryEntryCount
}
}
}
type GlobalStats = {
userCount: number
projectCount: number
logCount: number
metricCount: number
metricHistoryEntryCount: number
}

View file

@ -0,0 +1,31 @@
import {Feature, AppServices} from "@app/core/Feature";
import {LogFeature} from "../logs";
import {MetricFeature} from "../metrics";
import {ProjectFeature} from "../projects";
import {UserFeature} from "../users";
import {ServerAdminUseCases} from "./ServerAdminUseCases";
export type ServerAdminFeatureDependencies = {
userFeature: UserFeature
projectFeature: ProjectFeature
logFeature: LogFeature
metricFeature: MetricFeature
}
export class ServerAdminFeature extends Feature {
public name = "ActivityLogger"
public useCases: ServerAdminUseCases
public dependencies: ServerAdminFeatureDependencies
constructor(services: AppServices, dependencies: ServerAdminFeatureDependencies) {
super(services);
this.dependencies = dependencies
this.useCases = new ServerAdminUseCases({
userFeature: this.dependencies.userFeature,
projectFeature: this.dependencies.projectFeature,
logFeature: this.dependencies.logFeature,
metricFeature: this.dependencies.metricFeature
})
}
}

View file

@ -0,0 +1,12 @@
import {FeatureEvent} from "@app/core/FeatureEvent";
import {Caller} from "../projects/entities";
import {User} from "./entities";
export type onUserCreatedData = {
user: User
caller: Caller
}
export class UserEvents {
onMetricCreated: FeatureEvent<onUserCreatedData> = new FeatureEvent()
}

View file

@ -0,0 +1,46 @@
import {UserData} from "./entities/UserData";
import {Collection, Db, ObjectId} from "mongodb";
import { User } from "./entities";
export class UsersRepository {
private _users: Collection<Omit<UserData, "id">>
constructor(db: Db) {
this._users = db.collection("users")
}
async createUser(data: Omit<UserData, "id">): Promise<void> {
await this._users.insertOne(data);
}
async findUserById(id: string): Promise<UserData | null> {
const userData = await this._users.findOne({_id: new ObjectId(id)});
if (!userData) {
return null;
}
return {...userData, id: userData._id.toString()};
}
async findUserByEmail(email: string): Promise<UserData | null> {
const userData = await this._users.findOne({email});
if (!userData) {
return null;
}
return {...userData, id: userData._id.toString()};
}
async findAll(): Promise<UserData[]> {
const userData = await this._users.find().toArray();
return userData.map(data => ({...data, id: data._id.toString()}));
}
async count(): Promise<number> {
return this._users.countDocuments();
}
async updateUser(user: UserData, data: Partial<Omit<UserData, "id">>): Promise<boolean> {
const res = await this._users.updateOne({_id: new ObjectId(user.id)}, {$set: data})
return res.matchedCount > 0
}
}

View file

@ -0,0 +1,109 @@
import {User} from "./entities/User";
import {UsersRepository} from "./UsersRepository";
import argon2 from "argon2";
import {AuthenticationFailed} from "./exceptions/AuthenticationFailed";
import {getAccountCreationLimit, isRegistrationEnabled} from "./utils";
import {SignupFailed, SignupFailedCode} from "./exceptions/SignupFailed";
import {UserEvents} from "./UsersEvents";
import {Caller, SystemCaller} from "../projects/entities/Caller";
interface UsersUseCasesDependencies {
repository: UsersRepository,
events: UserEvents
}
export class UsersUseCases {
private _repository: UsersRepository
private _events: UserEvents
constructor(dependencies: UsersUseCasesDependencies) {
this._repository = dependencies.repository
this._events = dependencies.events
}
// [TODO] This function doesn't really belong here, make a new feature (ex: reporter)
async getGlobalInfo(adminToken: string) {
if (adminToken !== process.env.API_ADMIN_AUTHORIZATION) {
return null;
}
return {
users: (await this._repository.findAll()).map(user => ({
...user,
createdAt: new Date(user.createdAt),
password: undefined
}))
}
}
async adminChangeUserPassword(caller: Caller, email: string, newPassword: string) {
if (!caller.isAdmin()) {
return null;
}
const user = await this._repository.findUserByEmail(email);
if (!user) {
throw new SignupFailed("Target user not found", SignupFailedCode.Unknown)
}
return this._repository.updateUser(user, {password: await argon2.hash(newPassword)})
}
async signupUser(email: string, password: string, adminToken?: string) {
if (!isRegistrationEnabled(adminToken)) {
throw new SignupFailed("User signup not enabled", SignupFailedCode.NotEnabled)
}
if (await this._repository.count() >= getAccountCreationLimit()) {
throw new SignupFailed(`Maximum account limit (${getAccountCreationLimit()}) reached`, SignupFailedCode.LimitReached)
}
const user = await this._repository.findUserByEmail(email);
if (user) {
throw new SignupFailed("Email is already taken", SignupFailedCode.EmailAlreadyInUse)
}
const passwordHash = await argon2.hash(password);
await this._repository.createUser({
email,
password: passwordHash,
createdAt: Date.now(),
updatedAt: Date.now()
});
const createdUser = await this._repository.findUserByEmail(email)
if (!createdUser) {
throw new SignupFailed("Error in user creation", SignupFailedCode.Unknown)
}
const caller = new Caller(new SystemCaller())
await this._events.onMetricCreated.emit({user: new User(createdUser), caller})
/*if (!process.env.TEST) {*/
/*await mailService.SendMail(*/
/*`Confirm your email on Cronarium : https://my.awary.com/signup-email-confirmation?email=${email}&emailConfirmationToken=${createdUser?.GetDocument().emailConfirmationToken}`,*/
/*[email]);*/
/*}*/
}
async logUser(email: string, password: string): Promise<User> {
const userData = await this._repository.findUserByEmail(email);
if (!userData) {
throw new AuthenticationFailed("Wrong email or password")
}
const user = new User(userData);
if (!await user.verifyPassword(password)) {
throw new AuthenticationFailed("Wrong email or password")
}
return user;
}
async getUserByEmail(email: string): Promise<User | null> {
const userData = await this._repository.findUserByEmail(email);
if (!userData) {
return null;
}
return new User(userData);
}
}

View file

@ -0,0 +1,26 @@
import {UserData} from "./UserData";
import argon2 from "argon2"
export interface User extends Readonly<UserData> {
id: string
}
export class User {
constructor(data: UserData) {
Object.assign(this, data);
}
async verifyPassword(password: string): Promise<boolean> {
return this.password ? argon2.verify(this.password, password) : false;
}
getState(): UserData {
return {
id: this.id,
email: this.email,
password: this.password,
createdAt: this.createdAt,
updatedAt: this.updatedAt
}
}
}

View file

@ -0,0 +1,7 @@
export interface UserData {
id: string
email: string
password: string
createdAt: number
updatedAt: number
}

View file

@ -0,0 +1,2 @@
export * from "./User"
export * from "./UserData"

View file

@ -0,0 +1,3 @@
export class AuthenticationFailed extends Error {
}

View file

@ -0,0 +1,12 @@
export enum SignupFailedCode {
NotEnabled,
EmailAlreadyInUse,
LimitReached,
Unknown
}
export class SignupFailed extends Error {
constructor(message: string, public errorCode: SignupFailedCode) {
super(message)
}
}

View file

@ -0,0 +1,19 @@
import {Feature, AppServices} from "@app/core/Feature";
import {UserEvents} from "./UsersEvents";
import {UsersRepository} from "./UsersRepository";
import {UsersUseCases} from "./UsersUseCases";
export class UserFeature extends Feature {
name = "User"
repository: UsersRepository
useCases: UsersUseCases
events: UserEvents
constructor(services: AppServices) {
super(services);
this.repository = new UsersRepository(services.db)
this.events = new UserEvents()
this.useCases = new UsersUseCases({repository: this.repository, events: this.events})
}
}

View file

@ -0,0 +1,22 @@
export function isRegistrationEnabled(adminToken?: string): boolean {
if (adminToken && adminToken === process.env.API_ADMIN_AUTHORIZATION) {
return true;
}
return process.env.ENABLE_USER_REGISTRATION === "true";
}
export function getAccountCreationLimit(): number {
return process.env.MAX_ACCOUNT ? parseInt(process.env.MAX_ACCOUNT) : 9999999;
}
export function getAccountProjectsLimit(): number {
return process.env.MAX_PROJECT_PER_ACCOUNT ? parseInt(process.env.MAX_PROJECT_PER_ACCOUNT) : 9999999;
}
export function getMetricsUpdateLimit(): number {
return process.env.METRICS_MAX_UPDATE_PER_MINUTE ? parseInt(process.env.METRICS_MAX_UPDATE_PER_MINUTE) : 9999999;
}
export function getMetricsHistoryLimit(): number {
return process.env.METRICS_HISTORY_LENGTH ? parseInt(process.env.METRICS_HISTORY_LENGTH) : 9999999;
}

View file

@ -0,0 +1,81 @@
import {Collection, Db, ObjectId, WithId} from "mongodb";
import {Project} from "../projects/entities";
import {View, ViewProvider} from "./entities";
interface ViewDocument {
projectId: ObjectId
type: string
name: string
provider: string
config: unknown
}
interface ViewProperties {
projectId: string
type: string
name: string
provider: string
config: unknown
}
function viewDocumentToEntity(document: WithId<ViewDocument>): View {
return new View({
id: document._id.toString(),
projectId: document.projectId.toString(),
type: document.type,
name: document.name,
provider: document.provider as ViewProvider,
config: document.config
})
}
export class ViewsRepository {
private _views: Collection<ViewDocument>
constructor(db: Db) {
this._views = db.collection("projectsViews")
}
async createView(viewData: ViewProperties): Promise<View> {
const viewDocument = await this._views.insertOne({
...viewData,
projectId: new ObjectId(viewData.projectId)
});
return viewDocumentToEntity({
_id: viewDocument.insertedId,
projectId: new ObjectId(viewData.projectId),
type: viewData.type,
name: viewData.name,
provider: viewData.provider,
config: viewData.config
})
}
async updateView(view: View, properties: Partial<ViewProperties>): Promise<void> {
await this._views.updateOne({_id: new ObjectId(view.id)}, {
$set: {
name: properties.name,
config: properties.config
}
});
}
async findAll(project: Project, filter: Partial<ViewProperties> = {}): Promise<View[]> {
const viewDocuments = await this._views.find({...filter, projectId: new ObjectId(project.id)}).toArray()
return viewDocuments.map(viewDocumentToEntity)
}
async findOne(id: string): Promise<View | null> {
const viewDocument = await this._views.findOne({_id: new ObjectId(id)})
if (!viewDocument) {
return null;
}
return viewDocumentToEntity(viewDocument);
}
async deleteView(view: View): Promise<void> {
await this._views.deleteOne({_id: new ObjectId(view.id)})
}
}

View file

@ -0,0 +1,58 @@
import {ProjectAuthorization, ProjectContext} from "../projects/ProjectContext";
import {View, ViewProvider} from "./entities";
import {ViewsRepository} from "./ViewsRepository";
interface ViewsUseCasesDependencies {
viewsRepository: ViewsRepository
}
interface ViewDataUpdate {
name: string
config: unknown
}
export interface ViewCreationProperties {
type: string
name: string
provider: ViewProvider
config: unknown
}
export class ViewsUseCases {
private _viewsRepository: ViewsRepository
constructor(dependencies: ViewsUseCasesDependencies) {
this._viewsRepository = dependencies.viewsRepository
}
async createView(context: ProjectContext, viewData: ViewCreationProperties): Promise<View> {
context.enforceAuthorizations([ProjectAuthorization.Write])
const {project} = context;
const view = await this._viewsRepository.createView({
projectId: project.id,
name: viewData.name,
type: viewData.type,
provider: viewData.provider,
config: viewData.config
})
return view
}
async updateView(context: ProjectContext, view: View, data: ViewDataUpdate): Promise<void> {
context.enforceAuthorizations([ProjectAuthorization.Write])
await this._viewsRepository.updateView(view, data)
}
async getAllViews(context: ProjectContext): Promise<View[]> {
context.enforceAuthorizations([ProjectAuthorization.Read])
return await this._viewsRepository.findAll(context.project);
}
async getViewsByType(context: ProjectContext, type: string): Promise<View[]> {
context.enforceAuthorizations([ProjectAuthorization.Read])
return await this._viewsRepository.findAll(context.project, {type});
}
}

View file

@ -0,0 +1,53 @@
import {Identifiable} from "@app/core/Identifiable"
export enum ViewProvider {
Web = "Web",
Custom = "Custom"
}
export interface ViewCreationProperties {
projectId: string
type: string
name: string
provider: ViewProvider
config: unknown
}
export interface ViewConstructor extends Identifiable {
projectId: string
type: string
name: string
provider: ViewProvider
config: unknown
}
export interface WebViewProvider {
panels: {
name: string
order: number
metrics: {
metricId: string
order: number
config: unknown
}[]
}[]
}
export class View implements ViewConstructor {
readonly id: string
readonly projectId: string
readonly name: string
readonly type: string
readonly provider: ViewProvider
readonly config: unknown
constructor(data: ViewConstructor) {
this.id = data.id
this.projectId = data.projectId
this.type = data.type
this.name = data.name
this.provider = data.provider
this.config = data.config
}
}

View file

@ -0,0 +1 @@
export * from "./View"

View file

@ -0,0 +1,28 @@
import {Feature, AppServices} from "@app/core/Feature";
import {MetricFeature} from "../metrics";
import {ViewsRepository} from "./ViewsRepository";
import {ViewsUseCases} from "./ViewsUseCases";
export * from "./entities"
export type ViewsFeatureDependencies = {
metricFeature: MetricFeature
}
export class ViewsFeature extends Feature {
name = "Views"
dependencies:ViewsFeatureDependencies
repository: ViewsRepository
useCases: ViewsUseCases
constructor(services: AppServices, dependencies: ViewsFeatureDependencies) {
super(services);
this.dependencies = dependencies
this.repository = new ViewsRepository(services.db)
this.useCases = new ViewsUseCases({
viewsRepository: this.repository
})
}
}

1
server/src/core/index.ts Normal file
View file

@ -0,0 +1 @@
export * from "./App"

View file

@ -0,0 +1,143 @@
import fastifyCors from "@fastify/cors";
import fastifyJwt from "@fastify/jwt";
import {TypeBoxTypeProvider} from "@fastify/type-provider-typebox";
import {App} from "@app/core";
import {Caller} from "@app/core/features/projects/entities/Caller";
import {Project} from "@app/core/features/projects/entities/Project";
import {MissingAuthorization} from "@app/core/features/projects/exceptions/MissingAuthorization";
import {MissingResource} from "@app/core/features/projects/exceptions/MissingResource";
import {ProjectContext} from "@app/core/features/projects/ProjectContext";
import {User} from "@app/core/features/users/entities/User";
import {AuthenticationFailed} from "@app/core/features/users/exceptions/AuthenticationFailed";
import {SignupFailed, SignupFailedCode} from "@app/core/features/users/exceptions/SignupFailed";
import fastify, {FastifyInstance, FastifyLoggerInstance, InjectOptions, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault} from "fastify";
import {Logger} from "utils/logger";
import {projectsRoutes, usersRoutes, logsRoutes, metricsRoutes} from "./routes";
import {viewsRoutes} from "./routes/views.routes";
import {LimitReached} from "@app/core/exceptions/LimitReached";
import {adminRoutes} from "./routes/admin.routes";
export type FastifyTypebox = FastifyInstance<
RawServerDefault,
RawRequestDefaultExpression<RawServerDefault>,
RawReplyDefaultExpression<RawServerDefault>,
FastifyLoggerInstance,
TypeBoxTypeProvider
>;
declare module "@fastify/jwt" {
interface FastifyJWT {
payload: { email: string } // payload type is used for signing and verifying
user: { email: string }
}
}
declare module "fastify" {
interface FastifyRequest {
data: { // [TODO] This file shouldn't know about these classes
user: User
caller: Caller
project: Project
context: ProjectContext
},
}
}
export class HttpServer {
server: FastifyTypebox
app: App
constructor(app: App) {
this.app = app;
this.server = fastify({
ajv: {
customOptions: {
strict: 'log',
keywords: ['kind', 'modifier']
}
}
}).withTypeProvider<TypeBoxTypeProvider>();
}
async listen() {
this.server.listen({host: process.env.SERVER_HOST || "0.0.0.0", port: parseInt(process.env.SERVER_PORT || "8080")}, (err, address) => {
if (err) {
console.error(err);
process.exit(1);
}
console.log(`Server listening at ${address}`);
});
}
async inject(data: InjectOptions) {
return this.server.inject(data);
}
async setup() {
if (!process.env.JWT_SECRET) {
throw Error("Missing JWT_SECRET env var");
}
await this.server.register(fastifyCors);
await this.server.register(fastifyJwt, {secret: process.env.JWT_SECRET});
this.server.addHook("onSend", (req, reply, payload, done) => {
if (req.method === "OPTIONS") {
done();
return ;
}
if (reply.statusCode >= 200 && reply.statusCode < 300)
Logger.success(`${req.method} ${reply.statusCode} ${req.url}`);
else if (reply.statusCode >= 500)
Logger.error(`${req.method} ${reply.statusCode} ${req.url}`);
else
Logger.warn(`${req.method} ${reply.statusCode} ${req.url}`);
done();
});
this.server.setErrorHandler((err, req, reply) => {
let statusCode
if (err instanceof AuthenticationFailed) {
statusCode = 401
} else if (err instanceof SignupFailed) {
statusCode = 500
if (err.errorCode === SignupFailedCode.EmailAlreadyInUse) {
statusCode = 409
} else if (err.errorCode === SignupFailedCode.NotEnabled) {
statusCode = 401
}
} else if (err instanceof MissingAuthorization) {
statusCode = 401
} else if (err instanceof MissingResource) {
statusCode = 404;
} else if (err instanceof LimitReached) {
statusCode = 422;
}
if (statusCode) {
reply.status(statusCode).send({error: err.message})
} else {
reply.send(err)
}
})
this.server.addHook("onError", (req, reply, error, done) => {
if (!error.validation) {
Logger.error(error);
}
done();
});
this.server.register(usersRoutes(this.app));
this.server.register(projectsRoutes(this.app));
this.server.register(logsRoutes(this.app));
this.server.register(metricsRoutes(this.app));
this.server.register(viewsRoutes(this.app));
this.server.register(adminRoutes(this.app));
return this.server;
}
}

1
server/src/http/index.ts Normal file
View file

@ -0,0 +1 @@
export * from "./HttpServer"

View file

@ -0,0 +1,20 @@
import {FastifyInstance} from "fastify";
import {App} from "@app/core";
import {AppData, withData} from "http/utils";
export function adminRoutes(app: App) {
const adminUseCases = app.serverAdminFeature.useCases
return async (server: FastifyInstance) => {
server.get("/admin/server-stats",
{
preValidation: [withData(app, [AppData.Caller])]
},
async function (request) {
const {caller} = request.data;
return adminUseCases.getGlobalStats(caller);
}
);
}
}

View file

@ -0,0 +1,146 @@
import {ADMIN_TOKEN, getAdminSucessTagId} from "@app/utils";
import {expect} from "chai";
import {HttpServer} from "http/HttpServer";
import {buildTestServer, deleteDatabase, setupNewUsers, TestUser} from "testUtils/apiTestHelper";
describe("Admin", function () {
let server: HttpServer;
let users: TestUser[];
let project1Id: string
beforeEach(async function () {
await deleteDatabase();
server = await buildTestServer();
users = await setupNewUsers(server.server);
users[2].SetAuthorization(ADMIN_TOKEN);
});
it ("Return error 401 if not using the ADMIN_TOKEN", async function() {
const resGet = await users[1].Get(`/admin/server-stats`);
expect(resGet.statusCode).to.equals(401);
});
it ("Return 0 projects, 3 users, 0 logs, 0 metrics on initial state", async function() {
const resGet = await users[2].Get(`/admin/server-stats`);
expect(resGet.statusCode).to.equals(200);
expect(resGet.body).to.deep.equals({
userCount: 3,
projectCount: 0,
logCount: 0,
metricCount: 0,
metricHistoryEntryCount: 0
})
});
describe("Server stats", function() {
beforeEach(async function () {
// Create basic projects
const resAddProject1 = await users[0].Post("/projects", {name: "p1"});
await users[0].Post("/projects", {name: "p2"}); // Yep, don't need all projects
await users[1].Post("/projects", {name: "p3"});
project1Id = resAddProject1.body.id;
});
it ("Return 3 projects, 3 users, 3 logs, 0 metrics", async function() {
const resGet = await users[2].Get(`/admin/server-stats`);
expect(resGet.statusCode).to.equals(200);
expect(resGet.body).to.deep.equals({
userCount: 3,
projectCount: 3,
logCount: 3,
metricCount: 0,
metricHistoryEntryCount: 0
})
});
it ("Return 3 projects, 3 users, 4 logs, 1 metrics", async function() {
await users[0].Post(`/projects/${project1Id}/metrics`, {
name: "metric 1"
})
const resGet = await users[2].Get(`/admin/server-stats`);
expect(resGet.statusCode).to.deep.equals(200);
expect(resGet.body).to.deep.equals({
userCount: 3,
projectCount: 3,
logCount: 4,
metricCount: 1,
metricHistoryEntryCount: 0
})
});
it ("Return 3 projects, 3 users, 5 logs, 2 metrics, 3 metric history entries", async function() {
await users[0].Post(`/projects/${project1Id}/metrics`, {
name: "metric 1"
})
const resAddMetric = await users[0].Post(`/projects/${project1Id}/metrics`, {
name: "metric 2"
})
const metricId = resAddMetric.body.id;
await users[0].Post(`/projects/${project1Id}/metrics/${metricId}`, {value: 1});
await users[0].Post(`/projects/${project1Id}/metrics/${metricId}`, {value: 2});
await users[0].Post(`/projects/${project1Id}/metrics/${metricId}`, {value: 3});
const resGet = await users[2].Get(`/admin/server-stats`);
expect(resGet.statusCode).to.deep.equals(200);
expect(resGet.body).to.deep.equals({
userCount: 3,
projectCount: 3,
logCount: 5,
metricCount: 2,
metricHistoryEntryCount: 3
})
});
})
describe("Internal log to admin project", function() {
beforeEach(async function () {
const resAddProject1 = await users[0].Post("/projects", {name: "p1"});
project1Id = resAddProject1.body.id;
const resAddTag = await users[0].Post(`/projects/${project1Id}/tags`, {
name: "Success",
color: "#00ff00"
});
process.env.ADMIN_PROJECT_ID = project1Id
process.env.ADMIN_SUCCESS_TAG_ID = resAddTag.body.id
});
it("Add log when an user is created", async function() {
const resGetLogs1 = await users[0].Get(`/projects/${project1Id}/logs`);
expect(resGetLogs1.statusCode).to.equals(200);
expect(resGetLogs1.body).to.have.length(1);
const userEmail = "user.email@something.happy"
const resAddUser = await server.inject({
method: "POST",
url: "/signup",
payload: {
email: userEmail,
password: "SomePassword",
}
});
expect(resAddUser.statusCode).to.equals(201);
const resGetLogs2 = await users[0].Get(`/projects/${project1Id}/logs`);
expect(resGetLogs2.statusCode).to.equals(200);
expect(resGetLogs2.body).to.have.length(2);
expect(resGetLogs2.body[0].title).to.equals(`New user: ${userEmail}`);
expect(resGetLogs2.body[0].tags).to.have.length(1);
expect(resGetLogs2.body[0].tags[0].id).to.equals(getAdminSucessTagId());
});
afterEach(function () {
delete process.env.ADMIN_PROJECT_ID
delete process.env.ADMIN_SUCCESS_TAG_ID
})
})
});

View file

@ -0,0 +1,4 @@
export * from "./users.routes";
export * from "./projects.routes";
export * from "./logs.routes";
export * from "./metrics.routes";

View file

@ -0,0 +1,36 @@
import {Type} from "@sinclair/typebox";
export const CreateLogBody = Type.Object({
title: Type.String(),
content: Type.String(),
tags: Type.Array(Type.String({minLength: 24, maxLength: 24}), {maxItems: 2})
});
export const CreateLogParams = Type.Object({
projectId: Type.String(),
});
export const DeleteLogParams = Type.Object({
projectId: Type.String(),
logId: Type.String(),
});
export const CreateTagBody = Type.Object({
name: Type.String(),
color: Type.String(),
});
export const UpdateTagBody = Type.Object({
name: Type.String(),
color: Type.String(),
});
export const UpdateTagParams = Type.Object({
projectId: Type.String(),
tagId: Type.String(),
});
export const DeleteTagParams = Type.Object({
projectId: Type.String(),
tagId: Type.String(),
});

View file

@ -0,0 +1,121 @@
import {FastifyInstance} from "fastify";
import {Static} from "@sinclair/typebox";
import {CreateLogBody, CreateLogParams, CreateTagBody, DeleteLogParams, DeleteTagParams, UpdateTagBody, UpdateTagParams} from "./logs.def";
import {LogsUseCases} from "@app/core/features/logs/LogsUseCases";
import {ProjectsUseCases} from "@app/core/features/projects/ProjectsUseCases";
import {UsersUseCases} from "@app/core/features/users/UsersUseCases";
import {App} from "@app/core";
import {AppData, rateLimit, withData} from "http/utils";
export type MetricRoutesDependencies = {
logService: LogsUseCases,
projectService: ProjectsUseCases,
userService: UsersUseCases
}
export function logsRoutes(app: App) {
const logUseCases = app.logFeature.useCases
return async (server: FastifyInstance) => {
server.get("/projects/:projectId/logs",
{
preValidation: [withData(app, [AppData.Context])]
},
async function (request, reply) {
const {context} = request.data;
const logs = await logUseCases.getLogs(context);
const tags = await logUseCases.getTags(context);
const formattedLogs = logs.map(log => ({
...log,
tags: log.tags.map(logTag => tags.find(tag => tag.id === logTag)).filter(t => t)
}))
reply.status(200).send(formattedLogs);
}
);
server.post<{Body: Static<typeof CreateLogBody>, Params: Static<typeof CreateLogParams>}>(
"/projects/:projectId/logs",
{
preValidation: [rateLimit(1, 1000), withData(app, [AppData.Context])],
schema: {body: CreateLogBody, params: CreateLogParams}
},
async function (request, reply) {
const {context} = request.data;
const {title, content, tags} = request.body;
await logUseCases.addLog(context, {title, content, tags})
reply.status(201).send({});
}
);
server.delete<{Params: Static<typeof DeleteLogParams>}>(
"/projects/:projectId/logs/:logId",
{
preValidation: [withData(app, [AppData.Context])],
schema: {params: DeleteLogParams}
},
async function (request, reply) {
const {context} = request.data;
const {logId} = request.params
const log = await logUseCases.getLogById(context, logId)
await logUseCases.deleteLog(context, log);
reply.status(200).send();
}
);
server.get("/projects/:projectId/tags",
{
preValidation: [withData(app, [AppData.Context])]
},
async function (request, reply) {
const {context} = request.data;
const tags = await logUseCases.getTags(context);
reply.status(200).send(tags);
}
);
server.post<{Body: Static<typeof CreateTagBody>}>(
"/projects/:projectId/tags",
{
preValidation: [withData(app, [AppData.Context])],
schema: {body: CreateTagBody}
},
async function (request, reply) {
const {context} = request.data;
const {name, color} = request.body;
const tag = await logUseCases.createTag(context, {name, color})
reply.status(201).send({id: tag.id});
}
);
server.put<{Body: Static<typeof UpdateTagBody>, Params: Static<typeof UpdateTagParams>}>(
"/projects/:projectId/tags/:tagId",
{
preValidation: [withData(app, [AppData.Context])],
schema: {body: UpdateTagBody, params: UpdateTagParams}
},
async function (request, reply) {
const {context} = request.data;
const {name, color} = request.body;
const {tagId} = request.params;
const tag = await logUseCases.getTag(context, tagId);
await logUseCases.updateTag(context, tag, {name, color})
reply.status(200).send({});
}
);
server.delete<{Params: Static<typeof DeleteTagParams>}>(
"/projects/:projectId/tags/:tagId",
{
preValidation: [withData(app, [AppData.Context])],
schema: {params: DeleteTagParams}
},
async function (request, reply) {
const {context} = request.data;
const {tagId} = request.params
const tag = await logUseCases.getTag(context, tagId)
await logUseCases.deleteTag(context, tag);
reply.status(200).send();
}
);
}
}

View file

@ -0,0 +1,129 @@
import {expect} from "chai";
import {HttpServer} from "http/HttpServer";
import {buildTestServer, deleteDatabase, setupNewUsers, TestApiKey, TestUser} from "testUtils/apiTestHelper";
describe("Logs", function () {
let server: HttpServer;
let users: TestUser[];
let project1: Record<string, unknown>
let project1ApiKey1: TestApiKey
let project2: Record<string, unknown>
let project2ApiKey1: TestApiKey
beforeEach(async function () {
// Setup the server and app
await deleteDatabase();
server = await buildTestServer();
// Create basic users
users = await setupNewUsers(server.server);
// Create basic projects
await users[0].Post("/projects", {name: "p1"});
await users[0].Post("/projects", {name: "p2"});
await users[1].Post("/projects", {name: "p3"});
let response = await users[0].Get("/projects");
expect(response.statusCode).to.equals(200);
expect(response.body).to.be.a("array");
expect(response.body).to.have.length(2);
project1 = response.body[0];
project2 = response.body[1];
// Add 2 Api keys for project 1 and 1 Api ket for project 2
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key1"});
expect(response.statusCode).to.equals(201);
expect(response.body).to.be.a("object");
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key2"});
expect(response.statusCode).to.equals(201);
expect(response.body).to.be.a("object");
response = await users[0].Post(`/projects/${project2.id}/apiKeys`, {name: "key3"});
expect(response.statusCode).to.equals(201);
expect(response.body).to.be.a("object");
response = await users[0].Get(`/projects/${project1.id}/apiKeys`);
expect(response.statusCode).to.equals(200);
expect(response.body).to.be.a("array");
expect(response.body).to.have.length(2);
expect(response.body[0].name).to.equals("key1")
expect(response.body[0].projectId).to.equals(project1.id)
expect(response.body[0].key).to.not.be.undefined
expect(response.body[1].name).to.equals("key2")
expect(response.body[1].projectId).to.equals(project1.id)
expect(response.body[1].key).to.not.be.undefined
project1ApiKey1 = new TestApiKey(server.server, response.body[0].key)
new TestApiKey(server.server, response.body[1].key)
response = await users[0].Get(`/projects/${project2.id}/apiKeys`);
expect(response.statusCode).to.equals(200);
expect(response.body).to.be.a("array");
expect(response.body).to.have.length(1);
expect(response.body[0].name).to.equals("key3")
expect(response.body[0].projectId).to.equals(project2.id)
expect(response.body[0].key).to.not.be.undefined
project2ApiKey1 = new TestApiKey(server.server, response.body[0].key)
});
it ("[User 1] Get logs with just the welcome log", async function() {
const response = await users[0].Get(`/projects/${project1.id}/logs`);
expect(response.statusCode).to.equals(200);
expect(response.body).to.be.a("array");
expect(response.body).to.have.length(1);
});
it ("[K1-1 > P1] Get logs with just the welcome log", async function() {
const response = await project1ApiKey1.Get(`/projects/${project1.id}/logs`);
expect(response.statusCode).to.equals(200);
expect(response.body).to.be.a("array");
expect(response.body).to.have.length(1);
});
it ("[K1-1 > P1] Add log to p1", async function() {
const resAddLog = await project1ApiKey1.Post(`/projects/${project1.id}/logs`, {
title: "title1",
content: "content1",
tags: []
});
expect(resAddLog.statusCode).to.equals(201);
const resGetLogs = await users[0].Get(`/projects/${project1.id}/logs`);
expect(resGetLogs.statusCode).to.equals(200);
expect(resGetLogs.body).to.be.a("array");
expect(resGetLogs.body).to.have.length(2);
});
it ("[K1-1 > P1] Get logs with 2 entries", async function() {
await project1ApiKey1.Post(`/projects/${project1.id}/logs`, {
title: "title1",
content: "content1",
tags: []
});
const resGetLogs = await project1ApiKey1.Get(`/projects/${project1.id}/logs`);
expect(resGetLogs.statusCode).to.equals(200);
expect(resGetLogs.body).to.be.a("array");
expect(resGetLogs.body).to.have.length(2);
});
it ("[K2-1 > P1] Return error (401)", async function() {
const response = await project2ApiKey1.Get(`/projects/${project1.id}/logs`);
expect(response.statusCode).to.equals(401);
});
it ("[K2-1 > P2] Get logs with just the welcome log", async function() {
const response = await project2ApiKey1.Get(`/projects/${project2.id}/logs`);
expect(response.statusCode).to.equals(200);
expect(response.body).to.be.a("array");
expect(response.body).to.have.length(1);
});
});

View file

@ -0,0 +1,34 @@
import {Type} from "@sinclair/typebox";
export const CreateMetricBody = Type.Object({
name: Type.String(),
});
export const CreateMetricParams = Type.Object({
projectId: Type.String(),
});
export const UpdateMetricBody = Type.Object({
name: Type.String(),
});
export const UpdateMetricParams = Type.Object({
projectId: Type.String(),
metricId: Type.String(),
});
export const SetMetricValueBody = Type.Object({
value: Type.Number(),
date: Type.Optional(Type.Number())
});
export const SetMetricValueParams = Type.Object({
projectId: Type.String(),
metricId: Type.String()
});
export const DeleteHistoryRecordParams = Type.Object({
projectId: Type.String(),
metricId: Type.String(),
recordId: Type.String()
});

View file

@ -0,0 +1,124 @@
import {FastifyInstance} from "fastify";
import {Static} from "@sinclair/typebox";
import {App} from "@app/core";
import {SetMetricValueBody, SetMetricValueParams, CreateMetricBody, CreateMetricParams, UpdateMetricBody, UpdateMetricParams, DeleteHistoryRecordParams} from "./metrics.def";
import {ProjectsUseCases} from "@app/core/features/projects/ProjectsUseCases";
import {UsersUseCases} from "@app/core/features/users/UsersUseCases";
import {AppData, rateLimit, withData} from "http/utils";
import {MetricsUseCases} from "@app/core/features/metrics";
export type MetricRouteDependencies = {
metricService: MetricsUseCases,
projectService: ProjectsUseCases,
userService: UsersUseCases
}
export function metricsRoutes(app: App) {
const metricsUseCases = app.metricFeature.useCases
return async (server: FastifyInstance) => {
server.get("/projects/:projectId/metrics",
{
preValidation: [withData(app, [AppData.Context])]
},
async function (request, reply) {
const {context} = request.data;
const metrics = await metricsUseCases.getAllMetrics(context, true);
reply.status(200).send(metrics);
}
);
server.post<{Body: Static<typeof CreateMetricBody>, Params: Static<typeof CreateMetricParams>}>(
"/projects/:projectId/metrics",
{
preValidation: [withData(app, [AppData.Context])],
schema: {body: CreateMetricBody, params: CreateMetricParams}
},
async function (request, reply) {
const {context} = request.data;
const {name} = request.body;
const metric = await metricsUseCases.createMetric(context, {name})
reply.status(201).send({id: metric.id});
}
);
server.put<{Body: Static<typeof UpdateMetricBody>, Params: Static<typeof UpdateMetricParams>}>(
"/projects/:projectId/metrics/:metricId",
{
preValidation: [withData(app, [AppData.Context])],
schema: {body: UpdateMetricBody, params: UpdateMetricParams}
},
async function (request, reply) {
const {context} = request.data;
const {metricId} = request.params
const {name} = request.body;
const metric = await metricsUseCases.getMetricById(context, metricId)
if (!metric) {
return reply.status(404).send();
}
await metricsUseCases.updateMetric(context, metric, {name});
reply.status(200).send({});
}
);
server.get<{Params: Static<typeof SetMetricValueParams>}>(
"/projects/:projectId/metrics/:metricId",
{
preValidation: [withData(app, [AppData.Context])],
schema: {params: SetMetricValueParams}
},
async function (request, reply) {
const {context} = request.data;
const {metricId} = request.params
const metric = await metricsUseCases.getMetricById(context, metricId, true)
reply.status(200).send(metric);
}
);
server.post<{Body: Static<typeof SetMetricValueBody>, Params: Static<typeof SetMetricValueParams>}>(
"/projects/:projectId/metrics/:metricId",
{
preValidation: [rateLimit(1, 1000), withData(app, [AppData.Context])],
schema: {body: SetMetricValueBody, params: SetMetricValueParams}
},
async function (request, reply) {
const {context} = request.data;
const {metricId} = request.params
const {value, date} = request.body;
const metric = await metricsUseCases.getMetricById(context, metricId)
await metricsUseCases.setMetricValue(context, metric, value, date);
reply.status(201).send({});
}
);
server.delete<{Params: Static<typeof SetMetricValueParams>}>(
"/projects/:projectId/metrics/:metricId",
{
preValidation: [withData(app, [AppData.Context])],
schema: {params: SetMetricValueParams}
},
async function (request, reply) {
const {context} = request.data;
const {metricId} = request.params
const metric = await metricsUseCases.getMetricById(context, metricId)
await metricsUseCases.deleteMetric(context, metric)
reply.status(200).send();
}
);
server.delete<{Params: Static<typeof DeleteHistoryRecordParams>}>(
"/projects/:projectId/metrics/:metricId/history/:recordId",
{
preValidation: [withData(app, [AppData.Context])],
schema: {params: DeleteHistoryRecordParams}
},
async function (request, reply) {
const {context} = request.data;
const {metricId, recordId} = request.params
const metric = await metricsUseCases.getMetricById(context, metricId)
await metricsUseCases.deleteMetricHistoryRecord(context, metric, recordId);
reply.status(200).send({});
}
);
}
}

View file

@ -0,0 +1,166 @@
import {sleep} from "@app/utils";
import {expect} from "chai";
import {HttpServer} from "http/HttpServer";
import {buildTestServer, deleteDatabase, setupNewUsers, TestApiKey, TestUser} from "testUtils/apiTestHelper";
describe("Metrics", function () {
let server: HttpServer;
let users: TestUser[];
let project1: Record<string, unknown>
let project1ApiKey1: TestApiKey
let project2: Record<string, unknown>
beforeEach(async function () {
// Setup the server and app
await deleteDatabase();
server = await buildTestServer();
// Create basic users
users = await setupNewUsers(server.server);
// Create basic projects
await users[0].Post("/projects", {name: "p1"});
await users[0].Post("/projects", {name: "p2"});
await users[1].Post("/projects", {name: "p3"});
let response = await users[0].Get("/projects");
expect(response.statusCode).to.equals(200);
expect(response.body).to.be.a("array");
expect(response.body).to.have.length(2);
project1 = response.body[0];
project2 = response.body[1];
// Add 2 Api keys for project 1 and 1 Api ket for project 2
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key1"});
expect(response.statusCode).to.equals(201);
expect(response.body).to.be.a("object");
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key2"});
expect(response.statusCode).to.equals(201);
expect(response.body).to.be.a("object");
response = await users[0].Post(`/projects/${project2.id}/apiKeys`, {name: "key3"});
expect(response.statusCode).to.equals(201);
expect(response.body).to.be.a("object");
response = await users[0].Get(`/projects/${project1.id}/apiKeys`);
expect(response.statusCode).to.equals(200);
expect(response.body).to.be.a("array");
expect(response.body).to.have.length(2);
expect(response.body[0].name).to.equals("key1")
expect(response.body[0].projectId).to.equals(project1.id)
expect(response.body[0].key).to.not.be.undefined
expect(response.body[1].name).to.equals("key2")
expect(response.body[1].projectId).to.equals(project1.id)
expect(response.body[1].key).to.not.be.undefined
project1ApiKey1 = new TestApiKey(server.server, response.body[0].key)
new TestApiKey(server.server, response.body[1].key)
response = await users[0].Get(`/projects/${project2.id}/apiKeys`);
expect(response.statusCode).to.equals(200);
expect(response.body).to.be.a("array");
expect(response.body).to.have.length(1);
expect(response.body[0].name).to.equals("key3")
expect(response.body[0].projectId).to.equals(project2.id)
expect(response.body[0].key).to.not.be.undefined
});
it ("Return empty metric list", async function() {
const resGet = await users[0].Get(`/projects/${project1.id}/metrics`);
expect(resGet.statusCode).to.equals(200);
expect(resGet.body).to.be.a("array");
expect(resGet.body).to.have.length(0);
});
it ("Return error 400 on bad payload", async function() {
let resGet = await users[0].Post(`/projects/${project1.id}/metrics`, {});
expect(resGet.statusCode).to.equals(400);
resGet = await users[0].Post(`/projects/${project1.id}/metrics`, {
type: "numeric"
});
expect(resGet.statusCode).to.equals(400);
});
it ("Add a metric", async function() {
const resAdd = await users[0].Post(`/projects/${project1.id}/metrics`, {
name: "metric 1",
type: "numeric"
});
expect(resAdd.statusCode).to.equals(201);
const resGet = await users[0].Get(`/projects/${project1.id}/metrics`);
expect(resGet.statusCode).to.equals(200);
expect(resGet.body).to.be.a("array");
expect(resGet.body).to.have.length(1);
});
it ("Get metrics with [Api Key 1]", async function() {
await users[0].Post(`/projects/${project1.id}/metrics`, {
name: "metric 1",
type: "numeric"
});
const response = await project1ApiKey1.Get(`/projects/${project1.id}/metrics`);
expect(response.statusCode).to.equals(200);
expect(response.body).to.be.a("array");
expect(response.body).to.have.length(1);
});
it ("Set value of a metric as [Api Key 1]", async function() {
const resAddMetric = await users[0].Post(`/projects/${project1.id}/metrics`, {
name: "metric 1",
type: "numeric"
});
const metricId = resAddMetric.body.id
const resSetValue = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
value: 42
});
expect(resSetValue.statusCode).to.equals(201);
const resGet = await users[0].Get(`/projects/${project1.id}/metrics`);
expect(resGet.statusCode).to.equals(200);
expect(resGet.body).to.be.a("array");
expect(resGet.body).to.have.length(1);
expect(resGet.body[0].history).to.have.length(1);
expect(resGet.body[0].history[0].value).to.equals(42);
});
it ("Simple rate limit test for [Api Key 1]", async function() {
process.env.RATE_LIMIT_ENABLED = 'true'
const resAddMetric = await users[0].Post(`/projects/${project1.id}/metrics`, {
name: "metric 1",
type: "numeric"
});
const metricId = resAddMetric.body.id
const response1 = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
value: 42
});
expect(response1.statusCode).to.equals(201);
const response2 = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
value: 43
});
expect(response2.statusCode).to.equals(429);
await sleep(1100)
const response3 = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
value: 44
});
expect(response3.statusCode).to.equals(201);
process.env.RATE_LIMIT_ENABLED = 'false'
});
});

View file

@ -0,0 +1,18 @@
import {Type} from "@sinclair/typebox";
export const CreateProjectBody = Type.Object({
name: Type.String(),
});
export const CreateApiKeyBody = Type.Object({
name: Type.String(),
});
export const CreateApiKeyParams = Type.Object({
projectId: Type.String(),
});
export const DeleteApiKeyParams = Type.Object({
projectId: Type.String(),
apiKeyId: Type.String(),
});

View file

@ -0,0 +1,90 @@
import {FastifyInstance} from "fastify";
import {Static} from "@sinclair/typebox";
import {CreateApiKeyBody, CreateApiKeyParams, CreateProjectBody, DeleteApiKeyParams} from "./projects.def";
import {App} from "@app/core";
import {AppData, withData} from "http/utils";
import {ProjectAuthorization} from "@app/core/features/projects/ProjectContext";
export function projectsRoutes(app: App) {
const projectService = app.projectFeature.service
return async (server: FastifyInstance) => {
server.post<{Body: Static<typeof CreateProjectBody>}>("/projects",
{
preValidation: [withData(app, [AppData.Caller])],
schema: {body: CreateProjectBody}
},
async (request, reply) => {
const {name} = request.body;
const {caller} = request.data;
const project = await projectService.createProject(caller.asUser(), name);
reply.status(201).send({id: project.id});
}
);
server.get("/projects",
{
preValidation: [withData(app, [AppData.Caller])]
},
async function (request) {
const {caller} = request.data;
const projects = await projectService.getProjectsOfUser(caller.asUser());
return projects.map(project => project.getState())
}
);
server.get("/projects/:projectId",
{
preValidation: [withData(app, [AppData.Context])]
},
async function (request) {
const {context} = request.data;
context.enforceAuthorizations([ProjectAuthorization.Read])
return context.project.getState();
});
server.get("/projects/:projectId/apiKeys",
{
preValidation: [withData(app, [AppData.Context])]
},
async function (request) {
const {context} = request.data;
const apiKeys = projectService.getApiKeysOfProject(context);
return apiKeys
}
);
server.post<{Body: Static<typeof CreateApiKeyBody>, Params: Static<typeof CreateApiKeyParams>}>(
"/projects/:projectId/apiKeys",
{
preValidation: [withData(app, [AppData.Context])],
schema: {body: CreateApiKeyBody, params: CreateApiKeyParams}
},
async function (request, reply) {
const {context} = request.data;
const {name} = request.body;
const apiKey = await projectService.generateApiKey(context, name);
reply.status(201).send({apiKey});
}
);
server.delete<{Params: Static<typeof DeleteApiKeyParams>}>(
"/projects/:projectId/apiKeys/:apiKeyId",
{
preValidation: [withData(app, [AppData.Context])],
schema: {params: DeleteApiKeyParams}
},
async function (request, reply) {
const {context} = request.data;
const {apiKeyId} = request.params;
const apiKey = await projectService.getApiKeyById(context, apiKeyId);
await projectService.deleteApiKeys(context, apiKey);
reply.status(200).send({apiKey});
}
);
}
}

View file

@ -0,0 +1,118 @@
import {expect} from "chai";
import {HttpServer} from "http/HttpServer";
import {buildTestServer, deleteDatabase, setupNewUsers, TestUser} from "testUtils/apiTestHelper";
describe("Projects", function () {
let server: HttpServer;
let users: TestUser[];
beforeEach(async function () {
await deleteDatabase();
server = await buildTestServer();
users = await setupNewUsers(server.server);
});
it ("Return empty project list", async function() {
const responseUser1 = await users[0].Get("/projects");
expect(responseUser1.statusCode).to.equals(200);
expect(responseUser1.body).to.be.a("array");
expect(responseUser1.body).to.have.length(0)
const responseUser2 = await users[1].Get("/projects");
expect(responseUser2.statusCode).to.equals(200);
expect(responseUser2.body).to.be.a("array");
expect(responseUser2.body).to.have.length(0)
});
it ("Add 1 project", async function() {
const addRes = await users[0].Post("/projects", {name: "p1"});
expect(addRes.statusCode).to.equals(201);
const getRes = await users[0].Get("/projects");
expect(getRes.statusCode).to.equals(200);
expect(getRes.body).to.be.a("array");
expect(getRes.body).to.have.length(1)
});
it ("Add 2 projects", async function() {
const addRes1 = await users[0].Post("/projects", {name: "p1"});
const addRes2 = await users[0].Post("/projects", {name: "p2"});
expect(addRes1.statusCode).to.equals(201);
expect(addRes2.statusCode).to.equals(201);
const getUser1Res = await users[0].Get("/projects");
expect(getUser1Res.statusCode).to.equals(200);
expect(getUser1Res.body).to.be.a("array");
expect(getUser1Res.body).to.have.length(2)
// I just want to be sure the 2nd user cannot get them
const getUser2Res = await users[1].Get("/projects");
expect(getUser2Res.statusCode).to.equals(200);
expect(getUser2Res.body).to.be.a("array");
expect(getUser2Res.body).to.have.length(0)
});
it ("Check projects properties", async function() {
await users[0].Post("/projects", {name: "p1"});
await users[0].Post("/projects", {name: "p2"});
const getRes = await users[0].Get("/projects");
expect(getRes.statusCode).to.equals(200);
expect(getRes.body).to.be.a("array");
expect(getRes.body).to.have.length(2)
expect(getRes.body[0].name).to.equals("p1")
expect(getRes.body[1].name).to.equals("p2")
});
it ("Fail to add project (no valid authorization token)", async function() {
await users[0].Logout();
const addRes = await users[0].Post("/projects", {name: "p1"});
expect(addRes.statusCode).to.equals(401);
});
it ("Fail to get projects (no valid authorization token)", async function() {
await users[0].Logout();
const getRes = await users[0].Get("/projects");
expect(getRes.statusCode).to.equals(401);
});
it ("Projects have no Api key when created", async function() {
const addRes1 = await users[0].Post("/projects", {name: "p1"});
const addRes2 = await users[0].Post("/projects", {name: "p2"});
const project1Id = addRes1.body.id;
const project2Id = addRes2.body.id;
let response = await users[0].Get("/projects");
expect(response.statusCode).to.equals(200);
expect(response.body).to.be.a("array");
expect(response.body).to.have.length(2);
response = await users[0].Get(`/projects/${project1Id}/apiKeys`);
expect(response.statusCode).to.equals(200);
expect(response.body).to.be.a("array");
expect(response.body).to.have.length(0);
response = await users[0].Get(`/projects/${project2Id}/apiKeys`);
expect(response.statusCode).to.equals(200);
expect(response.body).to.be.a("array");
expect(response.body).to.have.length(0);
});
it ("Add Api key to project", async function() {
const addRes1 = await users[0].Post("/projects", {name: "p1"});
const project1Id = addRes1.body.id;
const response = await users[0].Post(`/projects/${project1Id}/apiKeys`, {name: "key1"});
expect(response.statusCode).to.equals(201);
expect(response.body).to.be.a("object");
const resGetKeys = await users[0].Get(`/projects/${project1Id}/apiKeys`);
expect(resGetKeys.statusCode).to.equals(200);
expect(resGetKeys.body).to.be.a("array");
expect(resGetKeys.body).to.have.length(1);
expect(resGetKeys.body[0].name).to.equals("key1");
});
});

View file

@ -0,0 +1,26 @@
import {Type} from "@sinclair/typebox";
export const InfoBody = Type.Object({
adminToken: Type.String()
});
export const SignupBody = Type.Object({
email: Type.String({format: "email"}),
password: Type.String({minLength: 6}),
adminToken: Type.Optional(Type.String())
});
export const LoginBody= Type.Object({
email: Type.String(),
password: Type.String(),
});
export const VerifyEmailQuerystring= Type.Object({
email: Type.String(),
emailConfirmationToken: Type.String(),
});
export const AdminChangeUserPasswordBody = Type.Object({
email: Type.String(),
newPassword: Type.String()
});

View file

@ -0,0 +1,87 @@
import {FastifyInstance} from "fastify";
import {Static} from "@sinclair/typebox";
import {AdminChangeUserPasswordBody, InfoBody, LoginBody, SignupBody} from "./users.def";
import {App} from "@app/core";
import {AppData, rateLimit, withData} from "http/utils";
import {isRegistrationEnabled} from "@app/core/features/users/utils";
export function usersRoutes(app: App) {
return async (server: FastifyInstance) => {
const usersUseCases = app.userFeature.useCases
server.post<{Body: Static<typeof SignupBody>}>("/signup",
{
schema: {body: SignupBody}
},
async (request, reply) => {
const {email, password, adminToken} = request.body;
await usersUseCases.signupUser(email, password, adminToken)
reply.status(201).send({success: true});
}
);
server.post<{Body: Static<typeof LoginBody>}>("/login",
{
schema: {body: LoginBody}
},
async (request) => {
const {email, password} = request.body;
const user = await usersUseCases.logUser(email, password)
const token = server.jwt.sign({email});
return {...user.getState(), password: undefined, token};
}
);
/*server.get<{Querystring: Static<typeof VerifyEmailQuerystring>}>*/
/*("/signup-email-confirmation", {schema: {querystring: VerifyEmailQuerystring}}, async function (request, reply) {*/
/*[>const {email, emailConfirmationToken} = request.query;<]*/
/*[>const user = await app.userService.getUser({email});<]*/
/*[>if (!user || !await user.verifyEmail(email, emailConfirmationToken)) {<]*/
/*[>reply.status(401);<]*/
/*[>return {};<]*/
/*[>}<]*/
/*reply.send({success: true});*/
/*});*/
server.post<{Body: Static<typeof InfoBody>}>("/info",
{
schema: {body: InfoBody},
preValidation: [rateLimit(1, 1000)],
},
async (request) => {
const {adminToken} = request.body;
return usersUseCases.getGlobalInfo(adminToken)
}
);
server.post<{Body: Static<typeof AdminChangeUserPasswordBody>}>("/admin/change-user-password",
{
schema: {body: AdminChangeUserPasswordBody},
preValidation: [withData(app, [AppData.Caller]), rateLimit(1, 60000)],
},
async (request) => {
const {email, newPassword} = request.body;
const {caller} = request.data
return usersUseCases.adminChangeUserPassword(caller, email, newPassword)
}
);
server.get("/auth",
{
preValidation: [withData(app, [AppData.Caller])]
},
async function (request) {
const {caller} = request.data;
return {...caller.asUser().getState(), password: undefined};
}
);
server.get("/isUserRegistrationEnabled", async function () {
return {enabled: isRegistrationEnabled()};
});
}
}

View file

@ -0,0 +1,294 @@
import { ADMIN_TOKEN } from "@app/utils";
import {expect} from "chai";
import {LightMyRequestResponse} from "fastify";
import {HttpServer} from "http/HttpServer";
import {buildTestServer, deleteDatabase} from "testUtils/apiTestHelper";
const users = [
{
email: "user1@test.com",
password: "user1_password"
},
{
email: "user2@test.com",
password: "user2_password"
},
{
email: "user3@test.com",
password: "user3_password"
}
];
type TestRequestBody = {
method: string,
url: string,
payload: Record<string, unknown>
}
async function TestRequiredBody(server: HttpServer, OriginalRequest: TestRequestBody) {
const payloadKeys = Object.keys(OriginalRequest.payload);
for (const key of payloadKeys) {
const request = JSON.parse(JSON.stringify(OriginalRequest));
delete request.payload[key];
const response = await server.inject(request);
expect(response.statusCode).to.equals(400);
}
}
let server: HttpServer;
async function logReq(email: string, password: string): Promise<LightMyRequestResponse> {
return server.inject({
method: "POST",
url: "/login",
payload: {
email: email,
password: password,
}
});
}
async function signupReq(email: string, password: string): Promise<LightMyRequestResponse> {
return server.inject({
method: "POST",
url: "/signup",
payload: {
email: email,
password: password,
}
});
}
describe("Users", function () {
beforeEach(async function () {
await deleteDatabase();
server = await buildTestServer();
});
describe("Signup", function() {
it ("User registration is enabled", async function() {
const response = await server.inject({
method: "GET",
url: "/isUserRegistrationEnabled",
});
expect(response.statusCode).to.equals(200);
expect(response.json().enabled).to.equals(true)
});
it ("Fail get current user (401) because no Bearer token is provided", async function() {
const response = await server.inject({
method: "GET",
url: "/auth",
});
expect(response.statusCode).to.equals(401);
});
it("Fail to sign up because email has wrong format", async function () {
const res = await signupReq("wrong", users[1].password)
expect(res.statusCode).to.equals(400);
});
it("Fail to sign up because email is an empty string", async function () {
const res = await signupReq("", users[1].password)
expect(res.statusCode).to.equals(400);
});
it("Fail to sign up because password is an empty string", async function () {
const res = await signupReq(users[1].email, "")
expect(res.statusCode).to.equals(400);
});
it("Fail to sign up because email is already taken", async function () {
const resSignup1 = await signupReq(users[0].email, users[0].password)
expect(resSignup1.statusCode).to.equals(201);
const resSignup2 = await signupReq(users[0].email, users[1].password)
expect(resSignup2.statusCode).to.equals(409);
});
it("Succeed to sign up a new user", async function () {
const res = await signupReq(users[0].email, users[0].password)
expect(res.statusCode).to.equals(201);
});
})
/*it("Should fail to log in with the new user because email is not verified", async function () {*/
/*const response = await server.inject({*/
/*method: "POST",*/
/*url: "/login",*/
/*payload: {*/
/*email: users[0].email,*/
/*password: users[0].password,*/
/*}*/
/*});*/
/*expect(response.statusCode).to.equals(403);*/
/*});*/
/*it("Fail to verify the email (wrong token)", async function () {*/
/*const response = await server.inject({*/
/*method: "GET",*/
/*url: `/signup-email-confirmation?email=${users[0].email}&emailConfirmationToken=this_is_a_random_string`,*/
/*});*/
/*expect(response.statusCode).to.equals(401);*/
/*});*/
/*it("Verify the email", async function () {*/
/*const user = await server.app.managers.userManager.FetchUserByEmail(users[0].email);*/
/*const response = await server.inject({*/
/*method: "GET",*/
/*url: `/signup-email-confirmation?email=${users[0].email}&emailConfirmationToken=${user?.GetDocument().emailConfirmationToken}`,*/
/*});*/
/*expect(response.statusCode).to.equals(200);*/
/*});*/
describe("Login", function() {
beforeEach(async function() {
const response = await server.inject({
method: "POST",
url: "/signup",
payload: {
email: users[0].email,
password: users[0].password,
}
});
expect(response.statusCode).to.equals(201);
})
it ("Fail (400) when a required property is missing", async function() {
await TestRequiredBody(server, {
method: "POST",
url: "/login",
payload: {
email: users[0].email,
password: users[0].password,
}
});
});
it("Fail to log in with wrong credentials", async function () {
const res = await logReq("abc", "def")
expect(res.statusCode).to.equals(401);
});
it ("Fail to get current authenticated user because of wrong jwt token", async function() {
const response = await server.inject({
method: "GET",
url: "/auth",
headers: {
["Authorization"]: "Bearer abcd"
}
});
expect(response.statusCode).to.equals(401);
});
it("Fail to log in with password of another user", async function () {
const res = await logReq(users[1].email, users[0].password)
expect(res.statusCode).to.equals(401);
});
it("Fail to log in with a total random password", async function () {
const res = await logReq(users[1].email, "qwertyasdfgzxcvb")
expect(res.statusCode).to.equals(401);
});
it("Fail to log in with an unknown email but existing password", async function () {
const res = await logReq("this_email@doesnt.exist", users[0].password)
expect(res.statusCode).to.equals(401);
});
it("Fail to log in with an unknown email and unknown password", async function () {
const res = await logReq("this_email@doesnt.exist", "this_password_doesnt_exist")
expect(res.statusCode).to.equals(401);
});
it("Succeed to log in", async function () {
const res = await logReq(users[0].email, users[0].password)
expect(res.statusCode).to.equals(200);
expect(res.json().token).to.not.be.undefined;
expect(res.json().password).to.be.undefined;
});
it ("Succeed to get current authenticated user", async function() {
const resLogin = await logReq(users[0].email, users[0].password)
expect(resLogin.statusCode).to.equals(200);
const token = resLogin.json().token
const resAuth = await server.inject({
method: "GET",
url: "/auth",
headers: {
["Authorization"]: `Bearer ${token}`
}
});
expect(resAuth.statusCode).to.equals(200);
expect(resAuth.json().password).to.be.undefined;
});
})
describe("Password reset", function() {
beforeEach(async function() {
const response = await server.inject({
method: "POST",
url: "/signup",
payload: {
email: users[0].email,
password: users[0].password,
}
});
expect(response.statusCode).to.equals(201);
})
it ("Admin succeed to force change an user password", async function() {
const newPassword = "my new password"
const resWrongPassword = await logReq(users[0].email, newPassword)
expect(resWrongPassword.statusCode).to.equals(401);
const resChangePassword = await server.inject({
method: "post",
url: "/admin/change-user-password",
headers: {
["Authorization"]: `Bearer ${ADMIN_TOKEN}`
},
payload: {
email: users[0].email,
newPassword: newPassword
}
});
expect(resChangePassword.statusCode).to.equals(200);
const resGoodPassword = await logReq(users[0].email, newPassword)
expect(resGoodPassword.statusCode).to.equals(200);
});
it ("Non Admin fail to force change an user password", async function() {
const newPassword = "my new password"
const resWrongPassword = await logReq(users[0].email, newPassword)
expect(resWrongPassword.statusCode).to.equals(401);
const resChangePassword = await server.inject({
method: "post",
url: "/admin/change-user-password",
headers: {
["Authorization"]: "Something random"
},
payload: {
password: newPassword
}
});
expect(resChangePassword.statusCode).to.equals(401);
const resGoodPassword = await logReq(users[0].email, newPassword)
expect(resGoodPassword.statusCode).to.equals(401);
});
})
});

View file

@ -0,0 +1,29 @@
import {ViewProvider} from "@app/core/features/views";
import {Type} from "@sinclair/typebox";
export const CreateViewBody = Type.Object({
type: Type.String(),
name: Type.String(),
provider: Type.Enum(ViewProvider),
config: Type.String(),
});
export const CreateViewParams = Type.Object({
projectId: Type.String(),
});
export const UpdateViewBody = Type.Object({
name: Type.String(),
config: Type.String(),
});
export const UpdateViewParams = Type.Object({
projectId: Type.String(),
viewId: Type.String(),
});
export const GetViewsQuerystring = Type.Object({
type: Type.Optional(Type.String()),
name: Type.Optional(Type.String()),
});

View file

@ -0,0 +1,60 @@
import {FastifyInstance} from "fastify";
import {Static} from "@sinclair/typebox";
import {App} from "@app/core";
import {AppData, withData} from "http/utils";
import {CreateViewBody, CreateViewParams, GetViewsQuerystring, UpdateViewBody, UpdateViewParams} from "./views.def";
export function viewsRoutes(app: App) {
const viewsUseCases = app.viewsFeature.useCases
return async (server: FastifyInstance) => {
server.get<{Querystring: Static<typeof GetViewsQuerystring>}>("/projects/:projectId/views",
{
preValidation: [withData(app, [AppData.Context])],
schema: {querystring: GetViewsQuerystring}
},
async function (request, reply) {
const {context} = request.data;
const {type} = request.query
let views = []
if (type) {
views = await viewsUseCases.getViewsByType(context, type)
} else {
views = await viewsUseCases.getAllViews(context)
}
reply.status(200).send(views);
}
);
server.post<{Body: Static<typeof CreateViewBody>, Params: Static<typeof CreateViewParams>}>(
"/projects/:projectId/views",
{
preValidation: [withData(app, [AppData.Context])],
schema: {body: CreateViewBody, params: CreateViewParams}
},
async function (request, reply) {
const {context} = request.data;
await viewsUseCases.createView(context, request.body)
reply.status(201).send({});
}
);
server.put<{Body: Static<typeof UpdateViewBody>, Params: Static<typeof UpdateViewParams>}>(
"/projects/:projectId/views/:viewId",
{
preValidation: [withData(app, [AppData.Context])],
schema: {body: UpdateViewBody, params: UpdateViewParams}
},
async function (request, reply) {
const {context} = request.data;
const {viewId} = request.params
const view = await app.viewsFeature.repository.findOne(viewId)
if (!view) {
return reply.status(404).send();
}
await viewsUseCases.updateView(context, view, request.body)
reply.status(201).send({});
}
);
}
}

109
server/src/http/utils.ts Normal file
View file

@ -0,0 +1,109 @@
import {App} from "@app/core";
import {AdminUser, Caller} from "@app/core/features/projects/entities/Caller";
import {Project} from "@app/core/features/projects/entities/Project";
import {ProjectContext} from "@app/core/features/projects/ProjectContext";
import {User} from "@app/core/features/users/entities";
import {AuthenticationFailed} from "@app/core/features/users/exceptions/AuthenticationFailed";
import {ADMIN_TOKEN} from "@app/utils";
import {FastifyReply, FastifyRequest} from "fastify";
export function rateLimit(count: number, ms: number) {
let rateLimitCache: { [callerId: string]: { [url: string]: number } } = {}
setInterval(() => { rateLimitCache = {} }, ms)
return async (request: FastifyRequest, reply: FastifyReply) => {
if (process.env.RATE_LIMIT_ENABLED !== 'true') {
return ;
}
let callerId = "";
try {
await request.jwtVerify();
callerId = request.user.email
} catch (e) {
callerId = request.headers.authorization?.split(' ')[1] || ""
}
if (!rateLimitCache[callerId]) {
rateLimitCache[callerId] = {}
}
if (!rateLimitCache[callerId][request.url]) {
rateLimitCache[callerId][request.url] = 0
}
if (rateLimitCache[callerId][request.url] >= count) {
return reply.status(429).send({message: "Rate limit reached"});
}
rateLimitCache[callerId][request.url] += 1
}
}
export enum AppData {
Caller,
Project,
Context
}
export function withData(app: App, required: AppData[]) {
const projectRepository = app.projectFeature.projectRepository
const userRepository = app.userFeature.repository
const apiKeyRepository = app.projectFeature.apiKeyRepository
return async (request: FastifyRequest) => {
let user
let caller
let project
let context
if (required.includes(AppData.Caller) || required.includes(AppData.Context)) {
try {
await request.jwtVerify();
const userData = await userRepository.findUserByEmail(request.user.email);
if (userData) {
user = new User(userData)
caller = new Caller(new User(userData))
}
} catch (e) {
const token = request.headers.authorization?.split(' ')[1]
if (typeof token !== "string") {
throw new AuthenticationFailed("Unknown authorization token");
}
if (token === ADMIN_TOKEN) {
caller = new Caller(new AdminUser())
} else {
const apiKey = await apiKeyRepository.findByKey(token);
if (!apiKey) {
throw new AuthenticationFailed("Unknown authorization token");
}
caller = new Caller(apiKey)
}
}
if (!caller) {
throw new AuthenticationFailed("Unknown authorization token");
}
}
if (required.includes(AppData.Project) || required.includes(AppData.Context)) {
const params = request.params as Record<string, unknown>
const projectId = params.projectId as string
project = await projectRepository.findProjectById(projectId);
if (!project) {
throw new Error("Unknown project id");
}
}
if (required.includes(AppData.Context)) {
if (!caller || !project) {
throw new Error("Missing information to create a project Context")
}
context = new ProjectContext(project, caller)
}
request.data = {
user: user as User,
caller: caller as Caller,
project: project as Project,
context: context as ProjectContext
}
return request.data
}
}

24
server/src/index.ts Normal file
View file

@ -0,0 +1,24 @@
import {App} from "@app/core";
import {MongoClient, MongoClientOptions} from "mongodb";
import {HttpServer} from "./http";
(async () => {
console.log(`node version: ${process.version}`)
const {
DB_HOST,
DB_PORT,
DB_USER,
DB_PASSWORD,
DB_NAME,
} = process.env;
const mongoUrl = `mongodb://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}`;
const mongoOptions: MongoClientOptions = {ignoreUndefined: true}
const client = await MongoClient.connect(mongoUrl, mongoOptions);
const db = client.db(DB_NAME)
const app = new App(db);
await app.start();
const server = new HttpServer(app);
server.setup();
server.listen();
})()

View file

@ -0,0 +1,241 @@
import {expect} from "chai";
import {App} from "@app/core";
import {LightMyRequestResponse} from "fastify";
import {FastifyTypebox, HttpServer} from "../http";
import {MongoClient} from "mongodb";
const {
DB_HOST,
DB_PORT,
DB_USER,
DB_PASSWORD,
DB_NAME,
} = process.env;
const mongoUrl = `mongodb://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}?authSource=admin`;
const mongoDbName = DB_NAME as string;
export async function deleteDatabase(): Promise<boolean> {
const client = await MongoClient.connect(mongoUrl);
return client.db(mongoDbName).dropDatabase();
}
const usersCredientials = [
{
email: "user1@test.com",
password: "user1_password"
},
{
email: "user2@test.com",
password: "user2_password"
},
{
email: "user3@test.com",
password: "user3_password"
}
];
export async function buildTestServer(): Promise<HttpServer> {
await deleteDatabase();
const client = await MongoClient.connect(mongoUrl);
const db = client.db(DB_NAME)
const app = new App(db);
await app.start();
const server = new HttpServer(app);
server.setup();
return server;
}
export interface ResponseHelper {
response: LightMyRequestResponse,
/* eslint-disable-next-line */
body: any,
statusCode: number
}
export class TestApiKey {
private _server: FastifyTypebox;
private _authorization = "";
public get authorization() {return this._authorization;}
constructor(server: FastifyTypebox, apiKey: string) {
this._server = server;
this._authorization = apiKey;
}
async Get(url: string): Promise<ResponseHelper> {
const response = await this._server.inject({
method: "GET",
url,
headers: {
["Authorization"]: `Bearer ${this._authorization}`,
}
});
return {response, body: response.json(), statusCode: response.statusCode};
}
async Post(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
const response = await this._server.inject({
method: "POST",
url,
headers: {
["Authorization"]: `Bearer ${this._authorization}`,
},
payload
});
return {response, body: response.json(), statusCode: response.statusCode};
}
async Put(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
const response = await this._server.inject({
method: "PUT",
url,
headers: {
["Authorization"]: `Bearer ${this._authorization}`,
},
payload
});
return {response, body: response.json(), statusCode: response.statusCode};
}
async Delete(url: string): Promise<ResponseHelper> {
const response = await this._server.inject({
method: "DELETE",
url,
headers: {
["Authorization"]: `Bearer ${this._authorization}`,
}
});
return {response, body: response.json(), statusCode: response.statusCode};
}
}
export class TestUser {
private _server: FastifyTypebox;
private _email = "";
private _password = "";
private _authorization = "";
public get authorization() {return this._authorization;}
constructor(server: FastifyTypebox,) {
this._server = server;
}
async Login(email: string, password: string): Promise<boolean> {
this._email = email;
this._password = password;
/*const user = await this._app.userService.getUser({email});*/
let response = await this._server.inject({
method: "POST",
url: "/signup",
payload: {
email: email,
password: password,
}
});
/*response = await this._server.inject({*/
/*method: "GET",*/
/*url: `/signup-email-confirmation?email=${email}&emailConfirmationToken=${user?.getData().emailConfirmationToken}`,*/
/*});*/
response = await this._server.inject({
method: "POST",
url: "/login",
payload: {
email: email,
password: password,
}
});
const body = JSON.parse(response.body);
this._authorization = body.token;
return true;
}
async Logout() {
this._authorization = ""
}
async SetAuthorization(authorization: string) {
this._authorization = authorization;
}
async Get(url: string): Promise<ResponseHelper> {
const response = await this._server.inject({
method: "GET",
url,
headers: {
["Authorization"]: `Bearer ${this._authorization}`,
}
});
return {response, body: response.json(), statusCode: response.statusCode};
}
async Post(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
const response = await this._server.inject({
method: "POST",
url,
headers: {
["Authorization"]: `Bearer ${this._authorization}`,
},
payload
});
return {response, body: response.json(), statusCode: response.statusCode};
}
async Put(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
const response = await this._server.inject({
method: "PUT",
url,
headers: {
["Authorization"]: `Bearer ${this._authorization}`,
},
payload
});
return {response, body: response.json(), statusCode: response.statusCode};
}
async Delete(url: string): Promise<ResponseHelper> {
const response = await this._server.inject({
method: "DELETE",
url,
headers: {
["Authorization"]: `Bearer ${this._authorization}`,
}
});
return {response, body: response.json(), statusCode: response.statusCode};
}
}
export async function setupNewUsers(server: FastifyTypebox): Promise<TestUser[]> {
const usersWithAuthorization = await Promise.all(usersCredientials.map(async (userCredientials) =>{
const user = new TestUser(server);
await user.Login(userCredientials.email, userCredientials.password);
return user;
}));
return usersWithAuthorization;
}
export async function TestRequiredBody(method: string, url: string, user: TestUser, originalPayload: Record<string, unknown>) {
const payloadKeys = Object.keys(originalPayload);
for (const key of payloadKeys) {
const payload = JSON.parse(JSON.stringify(originalPayload));
delete payload[key];
if (method === "POST") {
const {response} = await user.Post(url, payload);
expect(response.statusCode).to.equals(400, `Key '${key}' in '${method} ${url}' is required but the server did not return a bad request error`);
}
}
}

19
server/src/utils/index.ts Normal file
View file

@ -0,0 +1,19 @@
export function sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export const ADMIN_TOKEN = process.env.API_ADMIN_AUTHORIZATION as string;
export function getAdminProjectId(): string | undefined {
return process.env.ADMIN_PROJECT_ID;
}
export function getAdminSucessTagId(): string | undefined {
return process.env.ADMIN_SUCCESS_TAG_ID;
}
export function getAdminFailTagId(): string | undefined {
return process.env.ADMIN_FAIL_TAG_ID;
}

View file

@ -0,0 +1,44 @@
const consoleColors = {
RESET: "\x1b[0m",
SUCCESS: "\x1b[32m",
WARNING: "\x1b[33m",
ERROR: "\x1b[31m",
DEBUG: "\x1b[36m"
};
export class Logger {
static info(message: unknown) {
if (!process.env.LOGGER_ENABLED) {
return;
}
console.log(message);
}
static debug(message: unknown) {
if (!process.env.LOGGER_ENABLED) {
return;
}
console.log(`${consoleColors.DEBUG}${message}${consoleColors.RESET}`);
}
static success(message: unknown) {
if (!process.env.LOGGER_ENABLED) {
return;
}
console.error(`${consoleColors.SUCCESS}${message}${consoleColors.RESET}`);
}
static warn(message: unknown) {
if (!process.env.LOGGER_ENABLED) {
return;
}
console.error(`${consoleColors.WARNING}${message}${consoleColors.RESET}`);
}
static error(message: unknown) {
if (!process.env.LOGGER_ENABLED) {
return;
}
console.error(`${consoleColors.ERROR}${message}${consoleColors.RESET}`);
}
}

111
server/tsconfig.json Normal file
View file

@ -0,0 +1,111 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
"rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
"baseUrl": ".", /* Specify the base directory to resolve non-relative module names. */
"paths": {
"@app/*": ["./src/*"],
"utils/*": ["./src/utils/*"],
"http/*": ["./src/http/*"],
"testUtils/*": ["./src/testUtils/*"]
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"ts-node": {
"require": ["tsconfig-paths/register"]
}
}