From bdd4f705bee079d052c17bc5fb1222c73d8aef47 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 15:23:29 +0100 Subject: [PATCH 01/36] Adjusted return type, since async is no longer needed here (thanks to db relations) ref #56 --- src/models/entities/Donation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/entities/Donation.ts b/src/models/entities/Donation.ts index 100f0bb..3ddc272 100644 --- a/src/models/entities/Donation.ts +++ b/src/models/entities/Donation.ts @@ -31,5 +31,5 @@ export abstract class Donation { * The donation's amount in cents (or whatever your currency's smallest unit is.). * The exact implementation may differ for each type of donation. */ - abstract amount: number | Promise; + abstract amount: number; } \ No newline at end of file From 1b7424f7501075ede10cc91e3f4de096065b4533 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 15:25:40 +0100 Subject: [PATCH 02/36] Added stats endpoint with some basic stats (more to come) - to be tested ref #56 --- src/controllers/StatsController.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/controllers/StatsController.ts diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts new file mode 100644 index 0000000..580b2bd --- /dev/null +++ b/src/controllers/StatsController.ts @@ -0,0 +1,28 @@ +import { Get, JsonController } from 'routing-controllers'; +import { OpenAPI } from 'routing-controllers-openapi'; +import { getConnection } from 'typeorm'; +import { Donation } from '../models/entities/Donation'; +import { Runner } from '../models/entities/Runner'; +import { Scan } from '../models/entities/Scan'; +import { User } from '../models/entities/User'; + +@JsonController('/stats') +export class StatsController { + + @Get() + @OpenAPI({ description: "A very basic stats endpoint providing basic counters for a dashboard or simmilar" }) + async get() { + let connection = getConnection(); + let runners = await connection.getRepository(Runner).find({ relations: ["scans"] }); + let users = await connection.getRepository(User).find(); + let scans = await connection.getRepository(Scan).find(); + let donations = await connection.getRepository(Donation).find({ relations: ["runner", "runner.scans"] }); + return { + "total_runners": runners.length, + "total_users": users.length, + "total_scans": scans.length, + "total_distance": runners.reduce((sum, current) => sum + current.distance, 0), + "total_donation_amount": donations.reduce((sum, current) => sum + current.amount, 0), + }; + } +} \ No newline at end of file From 6a762f570d8f58c70413974daa2f4d20729af814 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 16:08:50 +0100 Subject: [PATCH 03/36] Added team and org stats ref #56 --- src/controllers/StatsController.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts index 580b2bd..477ba19 100644 --- a/src/controllers/StatsController.ts +++ b/src/controllers/StatsController.ts @@ -3,6 +3,8 @@ import { OpenAPI } from 'routing-controllers-openapi'; import { getConnection } from 'typeorm'; import { Donation } from '../models/entities/Donation'; import { Runner } from '../models/entities/Runner'; +import { RunnerOrganisation } from '../models/entities/RunnerOrganisation'; +import { RunnerTeam } from '../models/entities/RunnerTeam'; import { Scan } from '../models/entities/Scan'; import { User } from '../models/entities/User'; @@ -14,13 +16,17 @@ export class StatsController { async get() { let connection = getConnection(); let runners = await connection.getRepository(Runner).find({ relations: ["scans"] }); + let teams = await connection.getRepository(RunnerTeam).find(); + let orgs = await connection.getRepository(RunnerOrganisation).find(); let users = await connection.getRepository(User).find(); let scans = await connection.getRepository(Scan).find(); let donations = await connection.getRepository(Donation).find({ relations: ["runner", "runner.scans"] }); return { "total_runners": runners.length, + "total_teams": teams.length, + "total_orgs": orgs.length, "total_users": users.length, - "total_scans": scans.length, + "total_scans": scans.filter(scan => { scan.valid === true }).length, "total_distance": runners.reduce((sum, current) => sum + current.distance, 0), "total_donation_amount": donations.reduce((sum, current) => sum + current.amount, 0), }; From a738c19316355343d4a458903de3209f0fbd8daa Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 19:29:16 +0100 Subject: [PATCH 04/36] Added the new statsClient class for stats api auth ref #56 --- src/models/entities/StatsClient.ts | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/models/entities/StatsClient.ts diff --git a/src/models/entities/StatsClient.ts b/src/models/entities/StatsClient.ts new file mode 100644 index 0000000..2c6bf11 --- /dev/null +++ b/src/models/entities/StatsClient.ts @@ -0,0 +1,33 @@ +import { IsInt, IsOptional, IsString } from "class-validator"; +import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; +/** + * Defines the StatsClient entity. + * StatsClients can be used to access the protected parts of the stats api (top runners, donators and so on). +*/ +@Entity() +export abstract class StatsClient { + /** + * Autogenerated unique id (primary key). + */ + @PrimaryGeneratedColumn() + @IsInt() + id: number; + + /** + * The clients's description. + * Mostly for better UX when traceing back stuff. + */ + @Column({ nullable: true }) + @IsOptional() + @IsString() + description?: string; + + /** + * The client's api key. + * This is used to authorize a statsClient against the api. + * It only grants access to the /stats/** routes. + */ + @Column() + @IsString() + key: string; +} \ No newline at end of file From ce55dce011bb44c65b1ed1e3a60123cb5edb7b38 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 19:32:20 +0100 Subject: [PATCH 05/36] Removed abstract flag from class ref #56 --- src/models/entities/StatsClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/entities/StatsClient.ts b/src/models/entities/StatsClient.ts index 2c6bf11..cbc9e9e 100644 --- a/src/models/entities/StatsClient.ts +++ b/src/models/entities/StatsClient.ts @@ -5,7 +5,7 @@ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; * StatsClients can be used to access the protected parts of the stats api (top runners, donators and so on). */ @Entity() -export abstract class StatsClient { +export class StatsClient { /** * Autogenerated unique id (primary key). */ From e2cc0c0b800a66a8696525dd7a8f7e4b3d456c7c Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 19:34:14 +0100 Subject: [PATCH 06/36] Added Create action for the statsclients ref #56 --- src/models/actions/CreateStatsClient.ts | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/models/actions/CreateStatsClient.ts diff --git a/src/models/actions/CreateStatsClient.ts b/src/models/actions/CreateStatsClient.ts new file mode 100644 index 0000000..83fa199 --- /dev/null +++ b/src/models/actions/CreateStatsClient.ts @@ -0,0 +1,26 @@ +import { IsOptional, IsString } from 'class-validator'; +import crypto from "crypto"; +import { StatsClient } from '../entities/StatsClient'; +/** + * This classed is used to create a new StatsClient entity from a json body (post request). + */ +export class CreateStatsClient { + /** + * The new clients's description. + */ + @IsString() + @IsOptional() + description?: string; + + /** + * Converts this to a StatsClient entity. + */ + public async toStatsClient(): Promise { + let newClient: StatsClient = new StatsClient(); + + newClient.description = this.description; + newClient.key = crypto.randomBytes(20).toString('hex'); + + return newClient; + } +} \ No newline at end of file From 4c3d2643c111dece23a38a565cd4cb156e55a917 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 19:37:55 +0100 Subject: [PATCH 07/36] Added enabled flag for the stats clients ref #56 --- src/models/actions/CreateStatsClient.ts | 11 ++++++++++- src/models/entities/StatsClient.ts | 10 +++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/models/actions/CreateStatsClient.ts b/src/models/actions/CreateStatsClient.ts index 83fa199..436264c 100644 --- a/src/models/actions/CreateStatsClient.ts +++ b/src/models/actions/CreateStatsClient.ts @@ -6,12 +6,19 @@ import { StatsClient } from '../entities/StatsClient'; */ export class CreateStatsClient { /** - * The new clients's description. + * The new client's description. */ @IsString() @IsOptional() description?: string; + /** + * Is the new client enabled. + */ + @IsString() + @IsOptional() + enabled?: boolean; + /** * Converts this to a StatsClient entity. */ @@ -20,6 +27,8 @@ export class CreateStatsClient { newClient.description = this.description; newClient.key = crypto.randomBytes(20).toString('hex'); + if (this.enabled === undefined || this.enabled === null) { newClient.enabled = true; } + else { newClient.enabled = this.enabled } return newClient; } diff --git a/src/models/entities/StatsClient.ts b/src/models/entities/StatsClient.ts index cbc9e9e..5b32249 100644 --- a/src/models/entities/StatsClient.ts +++ b/src/models/entities/StatsClient.ts @@ -1,4 +1,4 @@ -import { IsInt, IsOptional, IsString } from "class-validator"; +import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator"; import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; /** * Defines the StatsClient entity. @@ -22,6 +22,14 @@ export class StatsClient { @IsString() description?: string; + /** + * Is the client enabled (for fraud and setup reasons)? + * Default: true + */ + @Column() + @IsBoolean() + enabled: boolean = true; + /** * The client's api key. * This is used to authorize a statsClient against the api. From 2b3804427117ab36aea31986f18928ee49f9fdcb Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 19:45:30 +0100 Subject: [PATCH 08/36] Created a response for the statsClient ref #56 --- src/models/responses/ResponseStatsClient.ts | 52 +++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/models/responses/ResponseStatsClient.ts diff --git a/src/models/responses/ResponseStatsClient.ts b/src/models/responses/ResponseStatsClient.ts new file mode 100644 index 0000000..2288d99 --- /dev/null +++ b/src/models/responses/ResponseStatsClient.ts @@ -0,0 +1,52 @@ +import { + IsBoolean, + + IsInt, + + IsOptional, + IsString +} from "class-validator"; +import { StatsClient } from '../entities/StatsClient'; + +/** + * Defines the statsClient response. +*/ +export class ResponseStatsClient { + /** + * The client's id. + */ + @IsInt() + id: number; + + /** + * The client's description. + */ + @IsString() + @IsOptional() + description?: string; + + /** + * Is the client enabled? + */ + @IsBoolean() + enabled: boolean; + + /** + * The client's api key. + * Only visible on creation or regeneration. + */ + @IsString() + @IsOptional() + key: string; + + /** + * Creates a ResponseStatsClient object from a statsClient. + * @param client The statsClient the response shall be build for. + */ + public constructor(client: StatsClient) { + this.id = client.id; + this.description = client.description; + this.enabled = client.enabled; + this.key = "Only visible on creation/update."; + } +} From b6043744a9ce1ec9daf04aefd965659f8df26750 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 19:48:35 +0100 Subject: [PATCH 09/36] Added STATSCLIENT as a new permission target ref #56 --- src/models/enums/PermissionTargets.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/models/enums/PermissionTargets.ts b/src/models/enums/PermissionTargets.ts index d31ce37..526aaa3 100644 --- a/src/models/enums/PermissionTargets.ts +++ b/src/models/enums/PermissionTargets.ts @@ -8,5 +8,6 @@ export enum PermissionTarget { TRACK = 'TRACK', USER = 'USER', USERGROUP = 'USERGROUP', - PERMISSION = 'PERMISSION' + PERMISSION = 'PERMISSION', + STATSCLIENT = 'STATSCLIENT' } \ No newline at end of file From e3ea83bb4782565e773e97019a92aa71f92c2809 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 19:57:19 +0100 Subject: [PATCH 10/36] Removed async flag, b/c this doesn't need to perform anything async ref #56 --- src/models/actions/CreateStatsClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/actions/CreateStatsClient.ts b/src/models/actions/CreateStatsClient.ts index 436264c..6bef901 100644 --- a/src/models/actions/CreateStatsClient.ts +++ b/src/models/actions/CreateStatsClient.ts @@ -22,7 +22,7 @@ export class CreateStatsClient { /** * Converts this to a StatsClient entity. */ - public async toStatsClient(): Promise { + public toStatsClient(): StatsClient { let newClient: StatsClient = new StatsClient(); newClient.description = this.description; From 641466a7315fe6869e9b41cdb855cc52cf5487f9 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 20:00:31 +0100 Subject: [PATCH 11/36] Added basic errors for stats clients ref #56 --- src/errors/StatsClientErrors.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/errors/StatsClientErrors.ts diff --git a/src/errors/StatsClientErrors.ts b/src/errors/StatsClientErrors.ts new file mode 100644 index 0000000..ab7f1ef --- /dev/null +++ b/src/errors/StatsClientErrors.ts @@ -0,0 +1,25 @@ +import { IsString } from 'class-validator'; +import { NotAcceptableError, NotFoundError } from 'routing-controllers'; + +/** + * Error to throw, when a non-existant stats client get's loaded. + */ +export class StatsClientNotFoundError extends NotFoundError { + @IsString() + name = "StatsClientNotFoundError" + + @IsString() + message = "The stats client you provided couldn't be located in the system. \n Please check your request." +} + +/** + * Error to throw when two stats clients' ids don't match. + * Usually occurs when a user tries to change a stats client's id. + */ +export class StatsClientIdsNotMatchingError extends NotAcceptableError { + @IsString() + name = "StatsClientIdsNotMatchingError" + + @IsString() + message = "The ids don't match! \n And if you wanted to change a stats client's id: This isn't allowed!" +} \ No newline at end of file From 500b94b44afc27df2bbbaab50390fdf7e7fb7d14 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 20:01:40 +0100 Subject: [PATCH 12/36] Added a controller for stats clients (todo: put) ref #56 --- src/controllers/StatsClientController.ts | 97 ++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/controllers/StatsClientController.ts diff --git a/src/controllers/StatsClientController.ts b/src/controllers/StatsClientController.ts new file mode 100644 index 0000000..7ba6360 --- /dev/null +++ b/src/controllers/StatsClientController.ts @@ -0,0 +1,97 @@ +import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post } from 'routing-controllers'; +import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; +import { getConnectionManager, Repository } from 'typeorm'; +import { StatsClientNotFoundError } from '../errors/StatsClientErrors'; +import { TrackNotFoundError } from "../errors/TrackErrors"; +import { CreateStatsClient } from '../models/actions/CreateStatsClient'; +import { StatsClient } from '../models/entities/StatsClient'; +import { ResponseEmpty } from '../models/responses/ResponseEmpty'; +import { ResponseStatsClient } from '../models/responses/ResponseStatsClient'; + +@JsonController('/statsclients') +@OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) +export class TrackController { + private clientRepository: Repository; + + /** + * Gets the repository of this controller's model/entity. + */ + constructor() { + this.clientRepository = getConnectionManager().get().getRepository(StatsClient); + } + + @Get() + @Authorized("STATSCLIENT:GET") + @ResponseSchema(ResponseStatsClient, { isArray: true }) + @OpenAPI({ description: 'Lists all stats clients. Please remember that the key can only be viewed on creation and update.' }) + async getAll() { + let responseClients: ResponseStatsClient[] = new Array(); + const clients = await this.clientRepository.find(); + clients.forEach(clients => { + responseClients.push(new ResponseStatsClient(clients)); + }); + return responseClients; + } + + @Get('/:id') + @Authorized("STATSCLIENT:GET") + @ResponseSchema(ResponseStatsClient) + @ResponseSchema(StatsClientNotFoundError, { statusCode: 404 }) + @OnUndefined(StatsClientNotFoundError) + @OpenAPI({ description: "Lists all information about the stats client whose id got provided. Please remember that the key can only be viewed on creation and update" }) + async getOne(@Param('id') id: number) { + let client = await this.clientRepository.findOne({ id: id }); + if (!client) { throw new TrackNotFoundError(); } + return new ResponseStatsClient(client); + } + + @Post() + @Authorized("STATSCLIENT:CREATE") + @ResponseSchema(ResponseStatsClient) + @OpenAPI({ description: "Create a new stats client.
Please remember that the client\'s key will be generated automaticly and that it can only be viewed on creation and update." }) + async post( + @Body({ validate: true }) + client: CreateStatsClient + ) { + let newClient = await this.clientRepository.save(client.toStatsClient()); + let responseClient = new ResponseStatsClient(newClient); + responseClient.key = newClient.key; + return responseClient; + } + + + // @Put('/:id') + // @Authorized("STATSCLIENT:UPDATE") + // @ResponseSchema(ResponseStatsClient) + // @ResponseSchema(StatsClientNotFoundError, { statusCode: 404 }) + // @ResponseSchema(StatsClientIdsNotMatchingError, { statusCode: 406 }) + // @OpenAPI({ description: "Update the stats client whose id you provided.
Please remember that ids can't be changed." }) + // async put(@Param('id') id: number, @EntityFromBody() track: Track) { + // let oldTrack = await this.trackRepository.findOne({ id: id }); + + // if (!oldTrack) { + // throw new StatsClientNotFoundError(); + // } + + // if (oldTrack.id != track.id) { + // throw new StatsClientIdsNotMatchingError(); + // } + + // await this.trackRepository.save(track); + // return new ResponseTrack(track); + // } + + @Delete('/:id') + @Authorized("STATSCLIENT:DELETE") + @ResponseSchema(ResponseStatsClient) + @ResponseSchema(ResponseEmpty, { statusCode: 204 }) + @OnUndefined(204) + @OpenAPI({ description: "Delete the stats client whose id you provided.
If no client with this id exists it will just return 204(no content)." }) + async remove(@Param("id") id: number) { + let client = await this.clientRepository.findOne({ id: id }); + if (!client) { return null; } + + await this.clientRepository.delete(client); + return new ResponseStatsClient(client); + } +} \ No newline at end of file From b7cbe2a0b485d341c7a556d460d585e0be834056 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 20:05:35 +0100 Subject: [PATCH 13/36] Adjusted the validation type ref #56 --- src/models/actions/CreateStatsClient.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/actions/CreateStatsClient.ts b/src/models/actions/CreateStatsClient.ts index 6bef901..b46819d 100644 --- a/src/models/actions/CreateStatsClient.ts +++ b/src/models/actions/CreateStatsClient.ts @@ -1,4 +1,4 @@ -import { IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsOptional, IsString } from 'class-validator'; import crypto from "crypto"; import { StatsClient } from '../entities/StatsClient'; /** @@ -15,7 +15,7 @@ export class CreateStatsClient { /** * Is the new client enabled. */ - @IsString() + @IsBoolean() @IsOptional() enabled?: boolean; From 1b74b214202217edfcb5ab4202a713cfb14130c1 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 20:07:43 +0100 Subject: [PATCH 14/36] Renamed class ref #56 --- src/controllers/StatsClientController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/StatsClientController.ts b/src/controllers/StatsClientController.ts index 7ba6360..80266bc 100644 --- a/src/controllers/StatsClientController.ts +++ b/src/controllers/StatsClientController.ts @@ -10,7 +10,7 @@ import { ResponseStatsClient } from '../models/responses/ResponseStatsClient'; @JsonController('/statsclients') @OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) -export class TrackController { +export class StatsClientController { private clientRepository: Repository; /** From bb24ed53a4b4601c2cce9d0d5ecdc23e5db18f6d Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 20:20:59 +0100 Subject: [PATCH 15/36] Switched to hased tokens based on uuid (to be canged) ref #56 --- src/models/actions/CreateStatsClient.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/models/actions/CreateStatsClient.ts b/src/models/actions/CreateStatsClient.ts index b46819d..c649426 100644 --- a/src/models/actions/CreateStatsClient.ts +++ b/src/models/actions/CreateStatsClient.ts @@ -1,6 +1,8 @@ +import * as argon2 from "argon2"; import { IsBoolean, IsOptional, IsString } from 'class-validator'; -import crypto from "crypto"; +import * as uuid from 'uuid'; import { StatsClient } from '../entities/StatsClient'; + /** * This classed is used to create a new StatsClient entity from a json body (post request). */ @@ -22,11 +24,11 @@ export class CreateStatsClient { /** * Converts this to a StatsClient entity. */ - public toStatsClient(): StatsClient { + public async toStatsClient(): Promise { let newClient: StatsClient = new StatsClient(); newClient.description = this.description; - newClient.key = crypto.randomBytes(20).toString('hex'); + newClient.key = await argon2.hash(uuid.v4()); if (this.enabled === undefined || this.enabled === null) { newClient.enabled = true; } else { newClient.enabled = this.enabled } From c4270b0839cb90be2be7ed498605eedb0f6e4d4d Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 20:21:45 +0100 Subject: [PATCH 16/36] Adapted the new async behaviour ref #56 --- src/controllers/StatsClientController.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/StatsClientController.ts b/src/controllers/StatsClientController.ts index 80266bc..b291c41 100644 --- a/src/controllers/StatsClientController.ts +++ b/src/controllers/StatsClientController.ts @@ -1,7 +1,7 @@ import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post } from 'routing-controllers'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { getConnectionManager, Repository } from 'typeorm'; -import { StatsClientNotFoundError } from '../errors/StatsClientErrors'; +import { StatsClientIdsNotMatchingError, StatsClientNotFoundError } from '../errors/StatsClientErrors'; import { TrackNotFoundError } from "../errors/TrackErrors"; import { CreateStatsClient } from '../models/actions/CreateStatsClient'; import { StatsClient } from '../models/entities/StatsClient'; @@ -53,7 +53,7 @@ export class StatsClientController { @Body({ validate: true }) client: CreateStatsClient ) { - let newClient = await this.clientRepository.save(client.toStatsClient()); + let newClient = await this.clientRepository.save(await client.toStatsClient()); let responseClient = new ResponseStatsClient(newClient); responseClient.key = newClient.key; return responseClient; From 04813173e4c6ff57702950ad5d8126a1ad7b47f3 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 20:49:45 +0100 Subject: [PATCH 17/36] Updated the method of api key creation. ref #56 --- src/models/actions/CreateStatsClient.ts | 8 +++++++- src/models/entities/StatsClient.ts | 21 ++++++++++++++++++--- src/models/responses/ResponseStatsClient.ts | 10 ++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/models/actions/CreateStatsClient.ts b/src/models/actions/CreateStatsClient.ts index c649426..27c99e3 100644 --- a/src/models/actions/CreateStatsClient.ts +++ b/src/models/actions/CreateStatsClient.ts @@ -1,5 +1,6 @@ import * as argon2 from "argon2"; import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import crypto from 'crypto'; import * as uuid from 'uuid'; import { StatsClient } from '../entities/StatsClient'; @@ -28,7 +29,12 @@ export class CreateStatsClient { let newClient: StatsClient = new StatsClient(); newClient.description = this.description; - newClient.key = await argon2.hash(uuid.v4()); + + let newUUID = uuid.v4().toUpperCase(); + newClient.prefix = crypto.createHash("sha3-512").update(newUUID).digest('hex').substring(0, 7).toUpperCase(); + newClient.key = await argon2.hash(newClient.prefix + "." + newUUID); + newClient.cleartextkey = newClient.prefix + "." + newUUID; + if (this.enabled === undefined || this.enabled === null) { newClient.enabled = true; } else { newClient.enabled = this.enabled } diff --git a/src/models/entities/StatsClient.ts b/src/models/entities/StatsClient.ts index 5b32249..0dff112 100644 --- a/src/models/entities/StatsClient.ts +++ b/src/models/entities/StatsClient.ts @@ -31,11 +31,26 @@ export class StatsClient { enabled: boolean = true; /** - * The client's api key. - * This is used to authorize a statsClient against the api. - * It only grants access to the /stats/** routes. + * The client's api key prefix. + * This is used identitfy a client by it's api key. + */ + @Column({ unique: true }) + @IsString() + prefix: string; + + /** + * The client's api key hash. + * The api key can be used to authenticate against the /stats/** routes. */ @Column() @IsString() key: string; + + /** + * The client's api key in plain text. + * This will only be used to display the full key on creation and updates. + */ + @IsString() + @IsOptional() + cleartextkey?: string; } \ No newline at end of file diff --git a/src/models/responses/ResponseStatsClient.ts b/src/models/responses/ResponseStatsClient.ts index 2288d99..b9ae535 100644 --- a/src/models/responses/ResponseStatsClient.ts +++ b/src/models/responses/ResponseStatsClient.ts @@ -3,6 +3,8 @@ import { IsInt, + IsNotEmpty, + IsOptional, IsString } from "class-validator"; @@ -39,6 +41,13 @@ export class ResponseStatsClient { @IsOptional() key: string; + /** + * The client's api key prefix. + */ + @IsString() + @IsNotEmpty() + prefix: string; + /** * Creates a ResponseStatsClient object from a statsClient. * @param client The statsClient the response shall be build for. @@ -47,6 +56,7 @@ export class ResponseStatsClient { this.id = client.id; this.description = client.description; this.enabled = client.enabled; + this.prefix = client.prefix; this.key = "Only visible on creation/update."; } } From b53b5cf91f073a30736fe941ded9d63a1816423f Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 21:00:43 +0100 Subject: [PATCH 18/36] Update: keys cant be updated (for security reasons) ref #56 --- src/controllers/StatsClientController.ts | 32 ++++----------------- src/models/actions/CreateStatsClient.ts | 12 +------- src/models/entities/StatsClient.ts | 10 +------ src/models/responses/ResponseStatsClient.ts | 10 +------ 4 files changed, 8 insertions(+), 56 deletions(-) diff --git a/src/controllers/StatsClientController.ts b/src/controllers/StatsClientController.ts index b291c41..1aa46a2 100644 --- a/src/controllers/StatsClientController.ts +++ b/src/controllers/StatsClientController.ts @@ -1,7 +1,7 @@ import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post } from 'routing-controllers'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { getConnectionManager, Repository } from 'typeorm'; -import { StatsClientIdsNotMatchingError, StatsClientNotFoundError } from '../errors/StatsClientErrors'; +import { StatsClientNotFoundError } from '../errors/StatsClientErrors'; import { TrackNotFoundError } from "../errors/TrackErrors"; import { CreateStatsClient } from '../models/actions/CreateStatsClient'; import { StatsClient } from '../models/entities/StatsClient'; @@ -23,7 +23,7 @@ export class StatsClientController { @Get() @Authorized("STATSCLIENT:GET") @ResponseSchema(ResponseStatsClient, { isArray: true }) - @OpenAPI({ description: 'Lists all stats clients. Please remember that the key can only be viewed on creation and update.' }) + @OpenAPI({ description: 'Lists all stats clients. Please remember that the key can only be viewed on creation.' }) async getAll() { let responseClients: ResponseStatsClient[] = new Array(); const clients = await this.clientRepository.find(); @@ -38,7 +38,7 @@ export class StatsClientController { @ResponseSchema(ResponseStatsClient) @ResponseSchema(StatsClientNotFoundError, { statusCode: 404 }) @OnUndefined(StatsClientNotFoundError) - @OpenAPI({ description: "Lists all information about the stats client whose id got provided. Please remember that the key can only be viewed on creation and update" }) + @OpenAPI({ description: "Lists all information about the stats client whose id got provided. Please remember that the key can only be viewed on creation." }) async getOne(@Param('id') id: number) { let client = await this.clientRepository.findOne({ id: id }); if (!client) { throw new TrackNotFoundError(); } @@ -48,39 +48,17 @@ export class StatsClientController { @Post() @Authorized("STATSCLIENT:CREATE") @ResponseSchema(ResponseStatsClient) - @OpenAPI({ description: "Create a new stats client.
Please remember that the client\'s key will be generated automaticly and that it can only be viewed on creation and update." }) + @OpenAPI({ description: "Create a new stats client.
Please remember that the client\'s key will be generated automaticly and that it can only be viewed on creation." }) async post( @Body({ validate: true }) client: CreateStatsClient ) { let newClient = await this.clientRepository.save(await client.toStatsClient()); let responseClient = new ResponseStatsClient(newClient); - responseClient.key = newClient.key; + responseClient.key = newClient.cleartextkey; return responseClient; } - - // @Put('/:id') - // @Authorized("STATSCLIENT:UPDATE") - // @ResponseSchema(ResponseStatsClient) - // @ResponseSchema(StatsClientNotFoundError, { statusCode: 404 }) - // @ResponseSchema(StatsClientIdsNotMatchingError, { statusCode: 406 }) - // @OpenAPI({ description: "Update the stats client whose id you provided.
Please remember that ids can't be changed." }) - // async put(@Param('id') id: number, @EntityFromBody() track: Track) { - // let oldTrack = await this.trackRepository.findOne({ id: id }); - - // if (!oldTrack) { - // throw new StatsClientNotFoundError(); - // } - - // if (oldTrack.id != track.id) { - // throw new StatsClientIdsNotMatchingError(); - // } - - // await this.trackRepository.save(track); - // return new ResponseTrack(track); - // } - @Delete('/:id') @Authorized("STATSCLIENT:DELETE") @ResponseSchema(ResponseStatsClient) diff --git a/src/models/actions/CreateStatsClient.ts b/src/models/actions/CreateStatsClient.ts index 27c99e3..40172e5 100644 --- a/src/models/actions/CreateStatsClient.ts +++ b/src/models/actions/CreateStatsClient.ts @@ -1,5 +1,5 @@ import * as argon2 from "argon2"; -import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { IsOptional, IsString } from 'class-validator'; import crypto from 'crypto'; import * as uuid from 'uuid'; import { StatsClient } from '../entities/StatsClient'; @@ -15,13 +15,6 @@ export class CreateStatsClient { @IsOptional() description?: string; - /** - * Is the new client enabled. - */ - @IsBoolean() - @IsOptional() - enabled?: boolean; - /** * Converts this to a StatsClient entity. */ @@ -35,9 +28,6 @@ export class CreateStatsClient { newClient.key = await argon2.hash(newClient.prefix + "." + newUUID); newClient.cleartextkey = newClient.prefix + "." + newUUID; - if (this.enabled === undefined || this.enabled === null) { newClient.enabled = true; } - else { newClient.enabled = this.enabled } - return newClient; } } \ No newline at end of file diff --git a/src/models/entities/StatsClient.ts b/src/models/entities/StatsClient.ts index 0dff112..493a8da 100644 --- a/src/models/entities/StatsClient.ts +++ b/src/models/entities/StatsClient.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator"; +import { IsInt, IsOptional, IsString } from "class-validator"; import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; /** * Defines the StatsClient entity. @@ -22,14 +22,6 @@ export class StatsClient { @IsString() description?: string; - /** - * Is the client enabled (for fraud and setup reasons)? - * Default: true - */ - @Column() - @IsBoolean() - enabled: boolean = true; - /** * The client's api key prefix. * This is used identitfy a client by it's api key. diff --git a/src/models/responses/ResponseStatsClient.ts b/src/models/responses/ResponseStatsClient.ts index b9ae535..4028e2a 100644 --- a/src/models/responses/ResponseStatsClient.ts +++ b/src/models/responses/ResponseStatsClient.ts @@ -1,5 +1,4 @@ import { - IsBoolean, IsInt, @@ -27,12 +26,6 @@ export class ResponseStatsClient { @IsOptional() description?: string; - /** - * Is the client enabled? - */ - @IsBoolean() - enabled: boolean; - /** * The client's api key. * Only visible on creation or regeneration. @@ -55,8 +48,7 @@ export class ResponseStatsClient { public constructor(client: StatsClient) { this.id = client.id; this.description = client.description; - this.enabled = client.enabled; this.prefix = client.prefix; - this.key = "Only visible on creation/update."; + this.key = "Only visible on creation."; } } From 7c5a3893efd98bcd1b0684e3c0571a5206485bf0 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 21:32:45 +0100 Subject: [PATCH 19/36] Added basic status api key checking middleware ref #56 --- src/middlewares/StatsAuth.ts | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/middlewares/StatsAuth.ts diff --git a/src/middlewares/StatsAuth.ts b/src/middlewares/StatsAuth.ts new file mode 100644 index 0000000..e9e4aaf --- /dev/null +++ b/src/middlewares/StatsAuth.ts @@ -0,0 +1,41 @@ +import * as argon2 from "argon2"; +import { Request, Response } from 'express'; +import { getConnectionManager } from 'typeorm'; +import { StatsClient } from '../models/entities/StatsClient'; + +/** + * This middleware handels the authentification of stats client api tokens. + * The tokens have to be provided via Bearer auth header. + * @param req Express request object. + * @param res Express response object. + * @param next Next function to call on success. + */ +const StatsAuth = async (req: Request, res: Response, next: () => void) => { + let provided_token: string = req.headers["authorization"]; + if (provided_token == "" || provided_token === undefined || provided_token === null) { + res.status(401).send("No api token provided."); + return; + } + + let prefix = ""; + try { + provided_token = provided_token.replace("Bearer ", ""); + prefix = provided_token.split(".")[0]; + } catch (error) { + res.status(401).send("Api token non-existant or invalid syntax."); + return; + } + + const client = await getConnectionManager().get().getRepository(StatsClient).findOne({ prefix: prefix }); + if (!client) { + res.status(401).send("Api token non-existant or invalid syntax."); + return; + } + if (!(await argon2.verify(client.key, provided_token))) { + res.status(401).send("Api token invalid."); + return; + } + + next(); +} +export default StatsAuth; \ No newline at end of file From 345851bf1d8dc06c2cdcefe90135dea3470898e6 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 21:34:49 +0100 Subject: [PATCH 20/36] Added example endpoint for stats auth --- src/controllers/StatsController.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts index 477ba19..ea402e5 100644 --- a/src/controllers/StatsController.ts +++ b/src/controllers/StatsController.ts @@ -1,6 +1,7 @@ -import { Get, JsonController } from 'routing-controllers'; +import { Get, JsonController, UseBefore } from 'routing-controllers'; import { OpenAPI } from 'routing-controllers-openapi'; import { getConnection } from 'typeorm'; +import StatsAuth from '../middlewares/StatsAuth'; import { Donation } from '../models/entities/Donation'; import { Runner } from '../models/entities/Runner'; import { RunnerOrganisation } from '../models/entities/RunnerOrganisation'; @@ -31,4 +32,26 @@ export class StatsController { "total_donation_amount": donations.reduce((sum, current) => sum + current.amount, 0), }; } + + @Get("/authorized") + @UseBefore(StatsAuth) + @OpenAPI({ description: "A demo endpoint for authorized stats." }) + async getAuthorized() { + let connection = getConnection(); + let runners = await connection.getRepository(Runner).find({ relations: ["scans"] }); + let teams = await connection.getRepository(RunnerTeam).find(); + let orgs = await connection.getRepository(RunnerOrganisation).find(); + let users = await connection.getRepository(User).find(); + let scans = await connection.getRepository(Scan).find(); + let donations = await connection.getRepository(Donation).find({ relations: ["runner", "runner.scans"] }); + return { + "total_runners": runners.length, + "total_teams": teams.length, + "total_orgs": orgs.length, + "total_users": users.length, + "total_scans": scans.filter(scan => { scan.valid === true }).length, + "total_distance": runners.reduce((sum, current) => sum + current.distance, 0), + "total_donation_amount": donations.reduce((sum, current) => sum + current.amount, 0), + }; + } } \ No newline at end of file From 9675e79441e623821402902768bd1cbd9c6ef951 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 21:38:48 +0100 Subject: [PATCH 21/36] Added openapi scheme for the stats api tokens. ref #56 --- src/controllers/StatsController.ts | 2 +- src/loaders/openapi.ts | 5 +++++ src/openapi_export.ts | 5 +++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts index ea402e5..f6d9a15 100644 --- a/src/controllers/StatsController.ts +++ b/src/controllers/StatsController.ts @@ -35,7 +35,7 @@ export class StatsController { @Get("/authorized") @UseBefore(StatsAuth) - @OpenAPI({ description: "A demo endpoint for authorized stats." }) + @OpenAPI({ description: "A demo endpoint for authorized stats.", security: [{ "StatsApiToken": [] }] }) async getAuthorized() { let connection = getConnection(); let runners = await connection.getRepository(Runner).find({ relations: ["scans"] }); diff --git a/src/loaders/openapi.ts b/src/loaders/openapi.ts index afe2165..5ab892c 100644 --- a/src/loaders/openapi.ts +++ b/src/loaders/openapi.ts @@ -35,6 +35,11 @@ export default async (app: Application) => { "in": "cookie", "name": "lfk_backend__refresh_token", description: "A cookie containing a JWT based refreh token. Attention: Doesn't work in swagger-ui. Use /api/auth/login or /api/auth/refresh to get one." + }, + "StatsApiToken": { + "type": "http", + "scheme": "bearer", + description: "Api token that can be obtained by creating a new stats client (post to /api/statsclients)." } } }, diff --git a/src/openapi_export.ts b/src/openapi_export.ts index d5465a7..e6f2a3c 100644 --- a/src/openapi_export.ts +++ b/src/openapi_export.ts @@ -44,6 +44,11 @@ const spec = routingControllersToSpec( "in": "cookie", "name": "lfk_backend__refresh_token", description: "A cookie containing a JWT based refreh token. Attention: Doesn't work in swagger-ui. Use /api/auth/login or /api/auth/refresh to get one." + }, + "StatsApiToken": { + "type": "http", + "scheme": "bearer", + description: "Api token that can be obtained by creating a new stats client (post to /api/statsclients)." } } }, From 555e37eaf71456d4b46ec8343622ccd1d5ea2f27 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 21:48:21 +0100 Subject: [PATCH 22/36] Added authed stats routes ref #56 --- src/controllers/StatsController.ts | 56 +++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts index f6d9a15..9d147c0 100644 --- a/src/controllers/StatsController.ts +++ b/src/controllers/StatsController.ts @@ -33,25 +33,47 @@ export class StatsController { }; } - @Get("/authorized") + @Get("/runners/distance") @UseBefore(StatsAuth) - @OpenAPI({ description: "A demo endpoint for authorized stats.", security: [{ "StatsApiToken": [] }] }) - async getAuthorized() { + @OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }] }) + async getTopRunnersByDistance() { let connection = getConnection(); let runners = await connection.getRepository(Runner).find({ relations: ["scans"] }); - let teams = await connection.getRepository(RunnerTeam).find(); - let orgs = await connection.getRepository(RunnerOrganisation).find(); - let users = await connection.getRepository(User).find(); - let scans = await connection.getRepository(Scan).find(); - let donations = await connection.getRepository(Donation).find({ relations: ["runner", "runner.scans"] }); - return { - "total_runners": runners.length, - "total_teams": teams.length, - "total_orgs": orgs.length, - "total_users": users.length, - "total_scans": scans.filter(scan => { scan.valid === true }).length, - "total_distance": runners.reduce((sum, current) => sum + current.distance, 0), - "total_donation_amount": donations.reduce((sum, current) => sum + current.amount, 0), - }; + return runners.sort((runner1, runner2) => runner1.distance - runner2.distance).slice(0, 9); + } + + @Get("/runners/donations") + @UseBefore(StatsAuth) + @OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }] }) + async getTopRunnersByDonations() { + throw new Error("Not implemented yet.") + } + + @Get("/teams/distance") + @UseBefore(StatsAuth) + @OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }] }) + async getTopTeamsByDistance() { + throw new Error("Not implemented yet.") + } + + @Get("/teams/donations") + @UseBefore(StatsAuth) + @OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }] }) + async getTopTeamsByDonations() { + throw new Error("Not implemented yet.") + } + + @Get("/organisations/distance") + @UseBefore(StatsAuth) + @OpenAPI({ description: "Returns the top ten organisations by distance.", security: [{ "StatsApiToken": [] }] }) + async getTopOrgsByDistance() { + throw new Error("Not implemented yet.") + } + + @Get("/organisations/donations") + @UseBefore(StatsAuth) + @OpenAPI({ description: "Returns the top ten organisations by donations.", security: [{ "StatsApiToken": [] }] }) + async getTopOrgsByDonations() { + throw new Error("Not implemented yet.") } } \ No newline at end of file From 6e121a3ce29ba858eafe3d8c6314c865cd05621c Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Tue, 29 Dec 2020 22:17:29 +0100 Subject: [PATCH 23/36] Implemented more stats endpoints ref #56 --- src/controllers/StatsController.ts | 13 ++++++++----- src/models/entities/Runner.ts | 8 ++++++++ src/models/entities/RunnerGroup.ts | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts index 9d147c0..5df9126 100644 --- a/src/controllers/StatsController.ts +++ b/src/controllers/StatsController.ts @@ -30,6 +30,7 @@ export class StatsController { "total_scans": scans.filter(scan => { scan.valid === true }).length, "total_distance": runners.reduce((sum, current) => sum + current.distance, 0), "total_donation_amount": donations.reduce((sum, current) => sum + current.amount, 0), + "average_distance": runners.reduce((sum, current) => sum + current.distance, 0) / runners.length, }; } @@ -37,8 +38,7 @@ export class StatsController { @UseBefore(StatsAuth) @OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }] }) async getTopRunnersByDistance() { - let connection = getConnection(); - let runners = await connection.getRepository(Runner).find({ relations: ["scans"] }); + let runners = await getConnection().getRepository(Runner).find({ relations: ["scans"] }); return runners.sort((runner1, runner2) => runner1.distance - runner2.distance).slice(0, 9); } @@ -46,21 +46,24 @@ export class StatsController { @UseBefore(StatsAuth) @OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }] }) async getTopRunnersByDonations() { - throw new Error("Not implemented yet.") + let runners = await getConnection().getRepository(Runner).find({ relations: ["scans", "distanceDonations"] }); + return runners.sort((runner1, runner2) => runner1.distanceDonationAmount - runner2.distanceDonationAmount).slice(0, 9); } @Get("/teams/distance") @UseBefore(StatsAuth) @OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }] }) async getTopTeamsByDistance() { - throw new Error("Not implemented yet.") + let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ["runners", "runners.scans"] }); + return teams.sort((team1, team2) => team1.distance - team2.distance).slice(0, 9); } @Get("/teams/donations") @UseBefore(StatsAuth) @OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }] }) async getTopTeamsByDonations() { - throw new Error("Not implemented yet.") + let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ["runners", "runners.scans", "runners.distanceDonations"] }); + return teams.sort((team1, team2) => team1.distanceDonationAmount - team2.distanceDonationAmount).slice(0, 9); } @Get("/organisations/distance") diff --git a/src/models/entities/Runner.ts b/src/models/entities/Runner.ts index def167d..1c509d6 100644 --- a/src/models/entities/Runner.ts +++ b/src/models/entities/Runner.ts @@ -58,4 +58,12 @@ export class Runner extends Participant { public get distance(): number { return this.validScans.reduce((sum, current) => sum + current.distance, 0); } + + /** + * Returns the total donations a runner has collected based on his linked donations and distance ran. + */ + @IsInt() + public get distanceDonationAmount(): number { + return this.distanceDonations.reduce((sum, current) => sum + current.amountPerDistance, 0) * this.distance; + } } \ No newline at end of file diff --git a/src/models/entities/RunnerGroup.ts b/src/models/entities/RunnerGroup.ts index 4256f6e..b5bcfcd 100644 --- a/src/models/entities/RunnerGroup.ts +++ b/src/models/entities/RunnerGroup.ts @@ -44,4 +44,20 @@ export abstract class RunnerGroup { */ @OneToMany(() => Runner, runner => runner.group, { nullable: true }) runners: Runner[]; + + /** + * Returns the total distance ran by this group's runners based on all their valid scans. + */ + @IsInt() + public get distance(): number { + return this.runners.reduce((sum, current) => sum + current.distance, 0); + } + + /** + * Returns the total donations a runner has collected based on his linked donations and distance ran. + */ + @IsInt() + public get distanceDonationAmount(): number { + return this.runners.reduce((sum, current) => sum + current.distanceDonationAmount, 0); + } } \ No newline at end of file From b5f9cf201d09c32ff10017eb7956cf41d6167540 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 30 Dec 2020 14:01:37 +0100 Subject: [PATCH 24/36] Moved the authchecker to the middleware folder (b/c it pretty much is a glolified middleware) ref #56 --- src/app.ts | 2 +- src/{ => middlewares}/authchecker.ts | 8 ++++---- src/openapi_export.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename src/{ => middlewares}/authchecker.ts (93%) diff --git a/src/app.ts b/src/app.ts index 9adc9b3..44060da 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,9 +1,9 @@ import consola from "consola"; import "reflect-metadata"; import { createExpressServer } from "routing-controllers"; -import authchecker from "./authchecker"; import { config, e as errors } from './config'; import loaders from "./loaders/index"; +import authchecker from "./middlewares/authchecker"; import { ErrorHandler } from './middlewares/ErrorHandler'; const CONTROLLERS_FILE_EXTENSION = process.env.NODE_ENV === 'production' ? 'js' : 'ts'; diff --git a/src/authchecker.ts b/src/middlewares/authchecker.ts similarity index 93% rename from src/authchecker.ts rename to src/middlewares/authchecker.ts index 54ef4d7..61ca231 100644 --- a/src/authchecker.ts +++ b/src/middlewares/authchecker.ts @@ -2,10 +2,10 @@ import cookie from "cookie"; import * as jwt from "jsonwebtoken"; import { Action } from "routing-controllers"; import { getConnectionManager } from 'typeorm'; -import { config } from './config'; -import { IllegalJWTError, NoPermissionError, UserDisabledError, UserNonexistantOrRefreshtokenInvalidError } from './errors/AuthError'; -import { JwtCreator, JwtUser } from './jwtcreator'; -import { User } from './models/entities/User'; +import { config } from '../config'; +import { IllegalJWTError, NoPermissionError, UserDisabledError, UserNonexistantOrRefreshtokenInvalidError } from '../errors/AuthError'; +import { JwtCreator, JwtUser } from '../jwtcreator'; +import { User } from '../models/entities/User'; /** * Handels authorisation verification via jwt's for all api endpoints using the @Authorized decorator. diff --git a/src/openapi_export.ts b/src/openapi_export.ts index e6f2a3c..49647b5 100644 --- a/src/openapi_export.ts +++ b/src/openapi_export.ts @@ -4,8 +4,8 @@ import fs from "fs"; import "reflect-metadata"; import { createExpressServer, getMetadataArgsStorage } from "routing-controllers"; import { routingControllersToSpec } from 'routing-controllers-openapi'; -import authchecker from "./authchecker"; import { config } from './config'; +import authchecker from "./middlewares/authchecker"; import { ErrorHandler } from './middlewares/ErrorHandler'; const CONTROLLERS_FILE_EXTENSION = process.env.NODE_ENV === 'production' ? 'js' : 'ts'; From 43e256f38c216b0136dd9b6fb41a73f98047d110 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 30 Dec 2020 14:19:54 +0100 Subject: [PATCH 25/36] Impelemented stats api auth via token or the usual auth (jwt with get for runners, teams and orgs). ref #56 --- src/middlewares/StatsAuth.ts | 44 ++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/middlewares/StatsAuth.ts b/src/middlewares/StatsAuth.ts index e9e4aaf..990206b 100644 --- a/src/middlewares/StatsAuth.ts +++ b/src/middlewares/StatsAuth.ts @@ -2,6 +2,7 @@ import * as argon2 from "argon2"; import { Request, Response } from 'express'; import { getConnectionManager } from 'typeorm'; import { StatsClient } from '../models/entities/StatsClient'; +import authchecker from './authchecker'; /** * This middleware handels the authentification of stats client api tokens. @@ -17,25 +18,48 @@ const StatsAuth = async (req: Request, res: Response, next: () => void) => { return; } - let prefix = ""; try { provided_token = provided_token.replace("Bearer ", ""); - prefix = provided_token.split(".")[0]; } catch (error) { - res.status(401).send("Api token non-existant or invalid syntax."); + res.status(401).send("No valid jwt or api token provided."); return; } + let prefix = ""; + try { + prefix = provided_token.split(".")[0]; + } + finally { + if (prefix == "" || prefix == undefined || prefix == null) { + res.status(401).send("Api token non-existant or invalid syntax."); + return; + } + } + const client = await getConnectionManager().get().getRepository(StatsClient).findOne({ prefix: prefix }); if (!client) { - res.status(401).send("Api token non-existant or invalid syntax."); - return; - } - if (!(await argon2.verify(client.key, provided_token))) { - res.status(401).send("Api token invalid."); - return; + let user_authorized = false; + try { + let action = { request: req, response: res, context: null, next: next } + user_authorized = await authchecker(action, ["RUNNER:GET", "TEAM:GET", "ORGANISATION:GET"]); + } + finally { + if (user_authorized == false) { + res.status(401).send("Api token non-existant or invalid syntax."); + return; + } + else { + next(); + } + } } + else { + if (!(await argon2.verify(client.key, provided_token))) { + res.status(401).send("Api token invalid."); + return; + } - next(); + next(); + } } export default StatsAuth; \ No newline at end of file From d850650aeb632576114a0f7d726533585e0fd3bb Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 30 Dec 2020 14:30:31 +0100 Subject: [PATCH 26/36] Added response class for the runner stats ref #56 --- src/controllers/StatsController.ts | 19 +++++-- src/models/responses/ResponseStatsRunner.ts | 60 +++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 src/models/responses/ResponseStatsRunner.ts diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts index 5df9126..83b0b95 100644 --- a/src/controllers/StatsController.ts +++ b/src/controllers/StatsController.ts @@ -8,6 +8,7 @@ import { RunnerOrganisation } from '../models/entities/RunnerOrganisation'; import { RunnerTeam } from '../models/entities/RunnerTeam'; import { Scan } from '../models/entities/Scan'; import { User } from '../models/entities/User'; +import { ResponseStatsRunner } from '../models/responses/ResponseStatsRunner'; @JsonController('/stats') export class StatsController { @@ -38,16 +39,26 @@ export class StatsController { @UseBefore(StatsAuth) @OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }] }) async getTopRunnersByDistance() { - let runners = await getConnection().getRepository(Runner).find({ relations: ["scans"] }); - return runners.sort((runner1, runner2) => runner1.distance - runner2.distance).slice(0, 9); + let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group'] }); + let topRunners = runners.sort((runner1, runner2) => runner1.distance - runner2.distance).slice(0, 9); + let responseRunners: ResponseStatsRunner[] = new Array(); + topRunners.forEach(runner => { + responseRunners.push(new ResponseStatsRunner(runner)); + }); + return responseRunners; } @Get("/runners/donations") @UseBefore(StatsAuth) @OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }] }) async getTopRunnersByDonations() { - let runners = await getConnection().getRepository(Runner).find({ relations: ["scans", "distanceDonations"] }); - return runners.sort((runner1, runner2) => runner1.distanceDonationAmount - runner2.distanceDonationAmount).slice(0, 9); + let runners = await getConnection().getRepository(Runner).find({ relations: ["scans", "distanceDonations", 'group'] }); + let topRunners = runners.sort((runner1, runner2) => runner1.distanceDonationAmount - runner2.distanceDonationAmount).slice(0, 9); + let responseRunners: ResponseStatsRunner[] = new Array(); + topRunners.forEach(runner => { + responseRunners.push(new ResponseStatsRunner(runner)); + }); + return responseRunners; } @Get("/teams/distance") diff --git a/src/models/responses/ResponseStatsRunner.ts b/src/models/responses/ResponseStatsRunner.ts new file mode 100644 index 0000000..652916b --- /dev/null +++ b/src/models/responses/ResponseStatsRunner.ts @@ -0,0 +1,60 @@ +import { + IsInt, + IsObject, + IsString +} from "class-validator"; +import { Runner } from '../entities/Runner'; +import { RunnerGroup } from '../entities/RunnerGroup'; + +/** + * Defines the runner response. +*/ +export class ResponseStatsRunner { + /** + * The participant's id. + */ + @IsInt() + id: number; + + /** + * The participant's first name. + */ + @IsString() + firstname: string; + + /** + * The participant's middle name. + */ + @IsString() + middlename?: string; + + /** + * The participant's last name. + */ + @IsString() + lastname: string; + + /** + * The runner's currently ran distance in meters. + */ + @IsInt() + distance: number; + + /** + * The runner's group. + */ + @IsObject() + group: RunnerGroup; + + /** + * Creates a ResponseRunner object from a runner. + * @param runner The user the response shall be build for. + */ + public constructor(runner: Runner) { + this.firstname = runner.firstname; + this.middlename = runner.middlename; + this.lastname = runner.lastname; + this.distance = runner.scans.filter(scan => { scan.valid === true }).reduce((sum, current) => sum + current.distance, 0); + this.group = runner.group; + } +} From a9ecfccfd26bcd47c902c7ddd81b3049384e12bc Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 30 Dec 2020 14:31:07 +0100 Subject: [PATCH 27/36] Added response schemas ref #56 --- src/controllers/StatsController.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts index 83b0b95..3139dc4 100644 --- a/src/controllers/StatsController.ts +++ b/src/controllers/StatsController.ts @@ -1,5 +1,5 @@ import { Get, JsonController, UseBefore } from 'routing-controllers'; -import { OpenAPI } from 'routing-controllers-openapi'; +import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { getConnection } from 'typeorm'; import StatsAuth from '../middlewares/StatsAuth'; import { Donation } from '../models/entities/Donation'; @@ -37,6 +37,7 @@ export class StatsController { @Get("/runners/distance") @UseBefore(StatsAuth) + @ResponseSchema(ResponseStatsRunner, { isArray: true }) @OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }] }) async getTopRunnersByDistance() { let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group'] }); @@ -50,6 +51,7 @@ export class StatsController { @Get("/runners/donations") @UseBefore(StatsAuth) + @ResponseSchema(ResponseStatsRunner, { isArray: true }) @OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }] }) async getTopRunnersByDonations() { let runners = await getConnection().getRepository(Runner).find({ relations: ["scans", "distanceDonations", 'group'] }); From 35dbfeb5e7302dd1865d41c561dbdfb2f0823603 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 30 Dec 2020 14:34:10 +0100 Subject: [PATCH 28/36] Added donation amount to the stats runner response ref #56 --- src/models/responses/ResponseStatsRunner.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/models/responses/ResponseStatsRunner.ts b/src/models/responses/ResponseStatsRunner.ts index 652916b..f4d2d2a 100644 --- a/src/models/responses/ResponseStatsRunner.ts +++ b/src/models/responses/ResponseStatsRunner.ts @@ -40,6 +40,12 @@ export class ResponseStatsRunner { @IsInt() distance: number; + /** + * The runner's currently collected donations. + */ + @IsInt() + donationAmount: number; + /** * The runner's group. */ @@ -55,6 +61,7 @@ export class ResponseStatsRunner { this.middlename = runner.middlename; this.lastname = runner.lastname; this.distance = runner.scans.filter(scan => { scan.valid === true }).reduce((sum, current) => sum + current.distance, 0); + this.donationAmount = runner.distanceDonationAmount; this.group = runner.group; } } From ec64ec3d6326c0afcdff64f782944554c2760b78 Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 30 Dec 2020 14:41:07 +0100 Subject: [PATCH 29/36] Added a response class for team stats ref #56 --- src/controllers/StatsController.ts | 15 +++++- src/models/responses/ResponseStatsRunner.ts | 14 +++--- src/models/responses/ResponseStatsTeam.ts | 55 +++++++++++++++++++++ 3 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 src/models/responses/ResponseStatsTeam.ts diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts index 3139dc4..ef4d508 100644 --- a/src/controllers/StatsController.ts +++ b/src/controllers/StatsController.ts @@ -9,6 +9,7 @@ import { RunnerTeam } from '../models/entities/RunnerTeam'; import { Scan } from '../models/entities/Scan'; import { User } from '../models/entities/User'; import { ResponseStatsRunner } from '../models/responses/ResponseStatsRunner'; +import { ResponseStatsTeam } from '../models/responses/ResponseStatsTeam'; @JsonController('/stats') export class StatsController { @@ -68,7 +69,12 @@ export class StatsController { @OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }] }) async getTopTeamsByDistance() { let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ["runners", "runners.scans"] }); - return teams.sort((team1, team2) => team1.distance - team2.distance).slice(0, 9); + let topTeams = teams.sort((team1, team2) => team1.distance - team2.distance).slice(0, 9); + let responseTeams: ResponseStatsTeam[] = new Array(); + topTeams.forEach(team => { + responseTeams.push(new ResponseStatsTeam(team)); + }); + return responseTeams; } @Get("/teams/donations") @@ -76,7 +82,12 @@ export class StatsController { @OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }] }) async getTopTeamsByDonations() { let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ["runners", "runners.scans", "runners.distanceDonations"] }); - return teams.sort((team1, team2) => team1.distanceDonationAmount - team2.distanceDonationAmount).slice(0, 9); + let topTeams = teams.sort((team1, team2) => team1.distanceDonationAmount - team2.distanceDonationAmount).slice(0, 9); + let responseTeams: ResponseStatsTeam[] = new Array(); + topTeams.forEach(team => { + responseTeams.push(new ResponseStatsTeam(team)); + }); + return responseTeams; } @Get("/organisations/distance") diff --git a/src/models/responses/ResponseStatsRunner.ts b/src/models/responses/ResponseStatsRunner.ts index f4d2d2a..a219264 100644 --- a/src/models/responses/ResponseStatsRunner.ts +++ b/src/models/responses/ResponseStatsRunner.ts @@ -7,29 +7,30 @@ import { Runner } from '../entities/Runner'; import { RunnerGroup } from '../entities/RunnerGroup'; /** - * Defines the runner response. + * Defines the runner stats response. + * This differs from the normal runner responce. */ export class ResponseStatsRunner { /** - * The participant's id. + * The runner's id. */ @IsInt() id: number; /** - * The participant's first name. + * The runner's first name. */ @IsString() firstname: string; /** - * The participant's middle name. + * The runner's middle name. */ @IsString() middlename?: string; /** - * The participant's last name. + * The runner's last name. */ @IsString() lastname: string; @@ -57,10 +58,11 @@ export class ResponseStatsRunner { * @param runner The user the response shall be build for. */ public constructor(runner: Runner) { + this.id = runner.id; this.firstname = runner.firstname; this.middlename = runner.middlename; this.lastname = runner.lastname; - this.distance = runner.scans.filter(scan => { scan.valid === true }).reduce((sum, current) => sum + current.distance, 0); + this.distance = runner.distance; this.donationAmount = runner.distanceDonationAmount; this.group = runner.group; } diff --git a/src/models/responses/ResponseStatsTeam.ts b/src/models/responses/ResponseStatsTeam.ts new file mode 100644 index 0000000..df48362 --- /dev/null +++ b/src/models/responses/ResponseStatsTeam.ts @@ -0,0 +1,55 @@ +import { + IsInt, + IsObject, + IsString +} from "class-validator"; +import { RunnerGroup } from '../entities/RunnerGroup'; +import { RunnerTeam } from '../entities/RunnerTeam'; + +/** + * Defines the team stats response. + * This differs from the normal team responce. +*/ +export class ResponseStatsTeam { + /** + * The team's id. + */ + @IsInt() + id: number; + + /** + * The team's name. + */ + @IsString() + name: string; + + /** + * The teams's currently ran distance in meters. + */ + @IsInt() + distance: number; + + /** + * The teams's currently collected donations. + */ + @IsInt() + donationAmount: number; + + /** + * The teams's parent group. + */ + @IsObject() + parent: RunnerGroup; + + /** + * Creates a ResponseRunner object from a runner. + * @param runner The user the response shall be build for. + */ + public constructor(team: RunnerTeam) { + this.name = team.name; + this.id = team.id; + this.parent = team.parentGroup; + this.distance = team.distance; + this.donationAmount = team.distanceDonationAmount; + } +} From dd48ee2f7edd38af803f735567e1aadeeb7c655d Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 30 Dec 2020 15:07:13 +0100 Subject: [PATCH 30/36] Added ResponseSchemas and fixed donation resolution bug ref #56 --- src/controllers/StatsController.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts index ef4d508..bab4560 100644 --- a/src/controllers/StatsController.ts +++ b/src/controllers/StatsController.ts @@ -41,7 +41,7 @@ export class StatsController { @ResponseSchema(ResponseStatsRunner, { isArray: true }) @OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }] }) async getTopRunnersByDistance() { - let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group'] }); + let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations'] }); let topRunners = runners.sort((runner1, runner2) => runner1.distance - runner2.distance).slice(0, 9); let responseRunners: ResponseStatsRunner[] = new Array(); topRunners.forEach(runner => { @@ -55,7 +55,7 @@ export class StatsController { @ResponseSchema(ResponseStatsRunner, { isArray: true }) @OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }] }) async getTopRunnersByDonations() { - let runners = await getConnection().getRepository(Runner).find({ relations: ["scans", "distanceDonations", 'group'] }); + let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations'] }); let topRunners = runners.sort((runner1, runner2) => runner1.distanceDonationAmount - runner2.distanceDonationAmount).slice(0, 9); let responseRunners: ResponseStatsRunner[] = new Array(); topRunners.forEach(runner => { @@ -66,6 +66,7 @@ export class StatsController { @Get("/teams/distance") @UseBefore(StatsAuth) + @ResponseSchema(ResponseStatsTeam, { isArray: true }) @OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }] }) async getTopTeamsByDistance() { let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ["runners", "runners.scans"] }); @@ -79,6 +80,7 @@ export class StatsController { @Get("/teams/donations") @UseBefore(StatsAuth) + @ResponseSchema(ResponseStatsTeam, { isArray: true }) @OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }] }) async getTopTeamsByDonations() { let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ["runners", "runners.scans", "runners.distanceDonations"] }); From d7791756dcee47e0e0e516a2a1be8f88d0394c4f Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 30 Dec 2020 16:13:57 +0100 Subject: [PATCH 31/36] Added mission relation resolving ref #56 --- src/controllers/StatsController.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts index bab4560..b6c75a3 100644 --- a/src/controllers/StatsController.ts +++ b/src/controllers/StatsController.ts @@ -69,7 +69,7 @@ export class StatsController { @ResponseSchema(ResponseStatsTeam, { isArray: true }) @OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }] }) async getTopTeamsByDistance() { - let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ["runners", "runners.scans"] }); + let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations'] }); let topTeams = teams.sort((team1, team2) => team1.distance - team2.distance).slice(0, 9); let responseTeams: ResponseStatsTeam[] = new Array(); topTeams.forEach(team => { @@ -83,7 +83,7 @@ export class StatsController { @ResponseSchema(ResponseStatsTeam, { isArray: true }) @OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }] }) async getTopTeamsByDonations() { - let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ["runners", "runners.scans", "runners.distanceDonations"] }); + let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations'] }); let topTeams = teams.sort((team1, team2) => team1.distanceDonationAmount - team2.distanceDonationAmount).slice(0, 9); let responseTeams: ResponseStatsTeam[] = new Array(); topTeams.forEach(team => { From 53a01ad97779ff47be4c309f5dc2547ecc61d08e Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 30 Dec 2020 16:31:18 +0100 Subject: [PATCH 32/36] Added stats response ref #56 --- src/controllers/StatsController.ts | 25 ++++----- src/models/responses/ResponseStats.ts | 74 +++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 src/models/responses/ResponseStats.ts diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts index b6c75a3..75e676b 100644 --- a/src/controllers/StatsController.ts +++ b/src/controllers/StatsController.ts @@ -8,6 +8,7 @@ import { RunnerOrganisation } from '../models/entities/RunnerOrganisation'; import { RunnerTeam } from '../models/entities/RunnerTeam'; import { Scan } from '../models/entities/Scan'; import { User } from '../models/entities/User'; +import { ResponseStats } from '../models/responses/ResponseStats'; import { ResponseStatsRunner } from '../models/responses/ResponseStatsRunner'; import { ResponseStatsTeam } from '../models/responses/ResponseStatsTeam'; @@ -15,25 +16,17 @@ import { ResponseStatsTeam } from '../models/responses/ResponseStatsTeam'; export class StatsController { @Get() + @ResponseSchema(ResponseStats) @OpenAPI({ description: "A very basic stats endpoint providing basic counters for a dashboard or simmilar" }) async get() { let connection = getConnection(); - let runners = await connection.getRepository(Runner).find({ relations: ["scans"] }); + let runners = await connection.getRepository(Runner).find({ relations: ['scans', 'scans.track'] }); let teams = await connection.getRepository(RunnerTeam).find(); let orgs = await connection.getRepository(RunnerOrganisation).find(); let users = await connection.getRepository(User).find(); let scans = await connection.getRepository(Scan).find(); - let donations = await connection.getRepository(Donation).find({ relations: ["runner", "runner.scans"] }); - return { - "total_runners": runners.length, - "total_teams": teams.length, - "total_orgs": orgs.length, - "total_users": users.length, - "total_scans": scans.filter(scan => { scan.valid === true }).length, - "total_distance": runners.reduce((sum, current) => sum + current.distance, 0), - "total_donation_amount": donations.reduce((sum, current) => sum + current.amount, 0), - "average_distance": runners.reduce((sum, current) => sum + current.distance, 0) / runners.length, - }; + let donations = await connection.getRepository(Donation).find({ relations: ['runner', 'runner.scans', 'runner.scans.track'] }); + return new ResponseStats(runners, teams, orgs, users, scans, donations) } @Get("/runners/distance") @@ -41,7 +34,7 @@ export class StatsController { @ResponseSchema(ResponseStatsRunner, { isArray: true }) @OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }] }) async getTopRunnersByDistance() { - let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations'] }); + let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations', 'scans.track'] }); let topRunners = runners.sort((runner1, runner2) => runner1.distance - runner2.distance).slice(0, 9); let responseRunners: ResponseStatsRunner[] = new Array(); topRunners.forEach(runner => { @@ -55,7 +48,7 @@ export class StatsController { @ResponseSchema(ResponseStatsRunner, { isArray: true }) @OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }] }) async getTopRunnersByDonations() { - let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations'] }); + let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations', 'scans.track'] }); let topRunners = runners.sort((runner1, runner2) => runner1.distanceDonationAmount - runner2.distanceDonationAmount).slice(0, 9); let responseRunners: ResponseStatsRunner[] = new Array(); topRunners.forEach(runner => { @@ -69,7 +62,7 @@ export class StatsController { @ResponseSchema(ResponseStatsTeam, { isArray: true }) @OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }] }) async getTopTeamsByDistance() { - let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations'] }); + let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track'] }); let topTeams = teams.sort((team1, team2) => team1.distance - team2.distance).slice(0, 9); let responseTeams: ResponseStatsTeam[] = new Array(); topTeams.forEach(team => { @@ -83,7 +76,7 @@ export class StatsController { @ResponseSchema(ResponseStatsTeam, { isArray: true }) @OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }] }) async getTopTeamsByDonations() { - let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations'] }); + let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track'] }); let topTeams = teams.sort((team1, team2) => team1.distanceDonationAmount - team2.distanceDonationAmount).slice(0, 9); let responseTeams: ResponseStatsTeam[] = new Array(); topTeams.forEach(team => { diff --git a/src/models/responses/ResponseStats.ts b/src/models/responses/ResponseStats.ts new file mode 100644 index 0000000..d236bd0 --- /dev/null +++ b/src/models/responses/ResponseStats.ts @@ -0,0 +1,74 @@ +import { + IsInt +} from "class-validator"; +import { Donation } from '../entities/Donation'; +import { Runner } from '../entities/Runner'; +import { RunnerOrganisation } from '../entities/RunnerOrganisation'; +import { RunnerTeam } from '../entities/RunnerTeam'; +import { Scan } from '../entities/Scan'; +import { User } from '../entities/User'; + +/** + * Defines the stats response. + * The stats response calculates some basic stats for a dashboard or public display. +*/ +export class ResponseStats { + /** + * The amount of runners registered in the system. + */ + @IsInt() + total_runners: number; + + /** + * The amount of teams registered in the system. + */ + @IsInt() + total_teams: number; + + /** + * The amount of organisations registered in the system. + */ + @IsInt() + total_orgs: number; + + /** + * The amount of users registered in the system. + */ + @IsInt() + total_users: number; + + /** + * The amount of valid scans registered in the system. + */ + @IsInt() + total_scans: number; + + /** + * The total distance that all runners ran. + */ + @IsInt() + total_distance: number; + + /** + * The total donation amount. + */ + @IsInt() + total_donation: number; + + /** + * The average distance per runner. + */ + @IsInt() + average_distance: number; + + public constructor(runners: Runner[], teams: RunnerTeam[], orgs: RunnerOrganisation[], users: User[], scans: Scan[], donations: Donation[]) { + this.total_runners = runners.length; + this.total_teams = teams.length; + this.total_orgs = orgs.length; + this.total_users = users.length; + this.total_scans = scans.filter(scan => { scan.valid === true }).length; + this.total_distance = runners.reduce((sum, current) => sum + current.distance, 0); + this.total_donation = donations.reduce((sum, current) => sum + current.amount, 0); + this.average_distance = this.total_distance / this.total_runners; + } +} From 5d31d8d1a23f8bbff31cf89cc1090103362c607e Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 30 Dec 2020 16:59:07 +0100 Subject: [PATCH 33/36] Added stats and stats responses for orgs ref #56 --- src/controllers/StatsController.ts | 25 +++++++++- src/models/entities/RunnerOrganisation.ts | 31 +++++++++++- .../responses/ResponseStatsOrganisation.ts | 47 +++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 src/models/responses/ResponseStatsOrganisation.ts diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts index 75e676b..03f5c51 100644 --- a/src/controllers/StatsController.ts +++ b/src/controllers/StatsController.ts @@ -9,6 +9,7 @@ import { RunnerTeam } from '../models/entities/RunnerTeam'; import { Scan } from '../models/entities/Scan'; import { User } from '../models/entities/User'; import { ResponseStats } from '../models/responses/ResponseStats'; +import { ResponseStatsOrgnisation } from '../models/responses/ResponseStatsOrganisation'; import { ResponseStatsRunner } from '../models/responses/ResponseStatsRunner'; import { ResponseStatsTeam } from '../models/responses/ResponseStatsTeam'; @@ -57,6 +58,14 @@ export class StatsController { return responseRunners; } + @Get("/scans") + @UseBefore(StatsAuth) + @ResponseSchema(ResponseStatsRunner, { isArray: true }) + @OpenAPI({ description: "Returns the top ten fastest track times (with their runner and the runner's group).", security: [{ "StatsApiToken": [] }] }) + async getTopRunnersByTrackTime() { + throw new Error("Not implemented yet.") + } + @Get("/teams/distance") @UseBefore(StatsAuth) @ResponseSchema(ResponseStatsTeam, { isArray: true }) @@ -89,13 +98,25 @@ export class StatsController { @UseBefore(StatsAuth) @OpenAPI({ description: "Returns the top ten organisations by distance.", security: [{ "StatsApiToken": [] }] }) async getTopOrgsByDistance() { - throw new Error("Not implemented yet.") + let orgs = await getConnection().getRepository(RunnerOrganisation).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] }); + let topOrgs = orgs.sort((org1, org2) => org1.distance - org2.distance).slice(0, 9); + let responseOrgs: ResponseStatsOrgnisation[] = new Array(); + topOrgs.forEach(org => { + responseOrgs.push(new ResponseStatsOrgnisation(org)); + }); + return responseOrgs; } @Get("/organisations/donations") @UseBefore(StatsAuth) @OpenAPI({ description: "Returns the top ten organisations by donations.", security: [{ "StatsApiToken": [] }] }) async getTopOrgsByDonations() { - throw new Error("Not implemented yet.") + let orgs = await getConnection().getRepository(RunnerOrganisation).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] }); + let topOrgs = orgs.sort((org1, org2) => org1.distanceDonationAmount - org2.distanceDonationAmount).slice(0, 9); + let responseOrgs: ResponseStatsOrgnisation[] = new Array(); + topOrgs.forEach(org => { + responseOrgs.push(new ResponseStatsOrgnisation(org)); + }); + return responseOrgs; } } \ No newline at end of file diff --git a/src/models/entities/RunnerOrganisation.ts b/src/models/entities/RunnerOrganisation.ts index 8d24077..877784b 100644 --- a/src/models/entities/RunnerOrganisation.ts +++ b/src/models/entities/RunnerOrganisation.ts @@ -1,6 +1,7 @@ -import { IsOptional } from "class-validator"; +import { IsInt, IsOptional } from "class-validator"; import { ChildEntity, ManyToOne, OneToMany } from "typeorm"; import { Address } from "./Address"; +import { Runner } from './Runner'; import { RunnerGroup } from "./RunnerGroup"; import { RunnerTeam } from "./RunnerTeam"; @@ -24,4 +25,32 @@ export class RunnerOrganisation extends RunnerGroup { */ @OneToMany(() => RunnerTeam, team => team.parentGroup, { nullable: true }) teams: RunnerTeam[]; + + /** + * Returns all runners associated with this organisation (directly or indirectly via teams). + */ + public get allRunners(): Runner[] { + let returnRunners: Runner[] = new Array(); + returnRunners.push(...this.runners); + for (let team of this.teams) { + returnRunners.push(...team.runners) + } + return returnRunners; + } + + /** + * Returns the total distance ran by this group's runners based on all their valid scans. + */ + @IsInt() + public get distance(): number { + return this.allRunners.reduce((sum, current) => sum + current.distance, 0); + } + + /** + * Returns the total donations a runner has collected based on his linked donations and distance ran. + */ + @IsInt() + public get distanceDonationAmount(): number { + return this.allRunners.reduce((sum, current) => sum + current.distanceDonationAmount, 0); + } } \ No newline at end of file diff --git a/src/models/responses/ResponseStatsOrganisation.ts b/src/models/responses/ResponseStatsOrganisation.ts new file mode 100644 index 0000000..2fa6ae4 --- /dev/null +++ b/src/models/responses/ResponseStatsOrganisation.ts @@ -0,0 +1,47 @@ +import { + IsInt, + + IsString +} from "class-validator"; +import { RunnerOrganisation } from '../entities/RunnerOrganisation'; + +/** + * Defines the org stats response. + * This differs from the normal org responce. +*/ +export class ResponseStatsOrgnisation { + /** + * The orgs's id. + */ + @IsInt() + id: number; + + /** + * The orgs's name. + */ + @IsString() + name: string; + + /** + * The orgs's runner's currently ran distance in meters. + */ + @IsInt() + distance: number; + + /** + * The orgs's currently collected donations. + */ + @IsInt() + donationAmount: number; + + /** + * Creates a ResponseRunner object from a runner. + * @param runner The user the response shall be build for. + */ + public constructor(org: RunnerOrganisation) { + this.name = org.name; + this.id = org.id; + this.distance = org.distance; + this.donationAmount = org.distanceDonationAmount; + } +} From e0fa58da57013a3482636a04d20095d2f842fa7e Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 30 Dec 2020 17:27:24 +0100 Subject: [PATCH 34/36] Added some comments ref #56 --- src/models/responses/ResponseStats.ts | 13 +++++++++++-- src/models/responses/ResponseStatsOrganisation.ts | 4 ++-- src/models/responses/ResponseStatsRunner.ts | 4 ++-- src/models/responses/ResponseStatsTeam.ts | 4 ++-- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/models/responses/ResponseStats.ts b/src/models/responses/ResponseStats.ts index d236bd0..a691306 100644 --- a/src/models/responses/ResponseStats.ts +++ b/src/models/responses/ResponseStats.ts @@ -44,7 +44,7 @@ export class ResponseStats { total_scans: number; /** - * The total distance that all runners ran. + * The total distance that all runners ran combined. */ @IsInt() total_distance: number; @@ -56,11 +56,20 @@ export class ResponseStats { total_donation: number; /** - * The average distance per runner. + * The average distance ran per runner. */ @IsInt() average_distance: number; + /** + * Creates a new stats response containing some basic statistics for a dashboard or public display. + * @param runners Array containing all runners - the following relations have to be resolved: scans, scans.track + * @param teams Array containing all teams - no relations have to be resolved. + * @param orgs Array containing all orgs - no relations have to be resolved. + * @param users Array containing all users - no relations have to be resolved. + * @param scans Array containing all scans - no relations have to be resolved. + * @param donations Array containing all donations - the following relations have to be resolved: runner, runner.scans, runner.scans.track + */ public constructor(runners: Runner[], teams: RunnerTeam[], orgs: RunnerOrganisation[], users: User[], scans: Scan[], donations: Donation[]) { this.total_runners = runners.length; this.total_teams = teams.length; diff --git a/src/models/responses/ResponseStatsOrganisation.ts b/src/models/responses/ResponseStatsOrganisation.ts index 2fa6ae4..338a8b3 100644 --- a/src/models/responses/ResponseStatsOrganisation.ts +++ b/src/models/responses/ResponseStatsOrganisation.ts @@ -35,8 +35,8 @@ export class ResponseStatsOrgnisation { donationAmount: number; /** - * Creates a ResponseRunner object from a runner. - * @param runner The user the response shall be build for. + * Creates a new organisation stats response from a organisation + * @param org The organisation whoes response shall be generated - the following relations have to be resolved: runners, runners.scans, runners.distanceDonations, runners.scans.track, teams, teams.runners, teams.runners.scans, teams.runners.distanceDonations, teams.runners.scans.track */ public constructor(org: RunnerOrganisation) { this.name = org.name; diff --git a/src/models/responses/ResponseStatsRunner.ts b/src/models/responses/ResponseStatsRunner.ts index a219264..8b55983 100644 --- a/src/models/responses/ResponseStatsRunner.ts +++ b/src/models/responses/ResponseStatsRunner.ts @@ -54,8 +54,8 @@ export class ResponseStatsRunner { group: RunnerGroup; /** - * Creates a ResponseRunner object from a runner. - * @param runner The user the response shall be build for. + * Creates a new runner stats response from a runner + * @param runner The runner whoes response shall be generated - the following relations have to be resolved: scans, group, distanceDonations, scans.track */ public constructor(runner: Runner) { this.id = runner.id; diff --git a/src/models/responses/ResponseStatsTeam.ts b/src/models/responses/ResponseStatsTeam.ts index df48362..362971a 100644 --- a/src/models/responses/ResponseStatsTeam.ts +++ b/src/models/responses/ResponseStatsTeam.ts @@ -42,8 +42,8 @@ export class ResponseStatsTeam { parent: RunnerGroup; /** - * Creates a ResponseRunner object from a runner. - * @param runner The user the response shall be build for. + * Creates a new team stats response from a team + * @param team The team whoes response shall be generated - the following relations have to be resolved: runners, runners.scans, runners.distanceDonations, runners.scans.track */ public constructor(team: RunnerTeam) { this.name = team.name; From 4cb0efa6bd17b30bbc67767e7eb2d7f313d5cf3c Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 30 Dec 2020 17:35:21 +0100 Subject: [PATCH 35/36] Added response schemas ref #56 --- src/controllers/StatsController.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts index 03f5c51..aa12698 100644 --- a/src/controllers/StatsController.ts +++ b/src/controllers/StatsController.ts @@ -96,6 +96,7 @@ export class StatsController { @Get("/organisations/distance") @UseBefore(StatsAuth) + @ResponseSchema(ResponseStatsOrgnisation, { isArray: true }) @OpenAPI({ description: "Returns the top ten organisations by distance.", security: [{ "StatsApiToken": [] }] }) async getTopOrgsByDistance() { let orgs = await getConnection().getRepository(RunnerOrganisation).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] }); @@ -109,6 +110,7 @@ export class StatsController { @Get("/organisations/donations") @UseBefore(StatsAuth) + @ResponseSchema(ResponseStatsOrgnisation, { isArray: true }) @OpenAPI({ description: "Returns the top ten organisations by donations.", security: [{ "StatsApiToken": [] }] }) async getTopOrgsByDonations() { let orgs = await getConnection().getRepository(RunnerOrganisation).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] }); From 6cb978df98c548111ea5da6dac4c551d7411748b Mon Sep 17 00:00:00 2001 From: Nicolai Ort Date: Wed, 30 Dec 2020 17:40:18 +0100 Subject: [PATCH 36/36] Updated security for the stats endpoints ref #56 requested by @philipp --- src/controllers/StatsController.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts index aa12698..127949d 100644 --- a/src/controllers/StatsController.ts +++ b/src/controllers/StatsController.ts @@ -33,7 +33,7 @@ export class StatsController { @Get("/runners/distance") @UseBefore(StatsAuth) @ResponseSchema(ResponseStatsRunner, { isArray: true }) - @OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }] }) + @OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async getTopRunnersByDistance() { let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations', 'scans.track'] }); let topRunners = runners.sort((runner1, runner2) => runner1.distance - runner2.distance).slice(0, 9); @@ -47,7 +47,7 @@ export class StatsController { @Get("/runners/donations") @UseBefore(StatsAuth) @ResponseSchema(ResponseStatsRunner, { isArray: true }) - @OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }] }) + @OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async getTopRunnersByDonations() { let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations', 'scans.track'] }); let topRunners = runners.sort((runner1, runner2) => runner1.distanceDonationAmount - runner2.distanceDonationAmount).slice(0, 9); @@ -61,7 +61,7 @@ export class StatsController { @Get("/scans") @UseBefore(StatsAuth) @ResponseSchema(ResponseStatsRunner, { isArray: true }) - @OpenAPI({ description: "Returns the top ten fastest track times (with their runner and the runner's group).", security: [{ "StatsApiToken": [] }] }) + @OpenAPI({ description: "Returns the top ten fastest track times (with their runner and the runner's group).", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async getTopRunnersByTrackTime() { throw new Error("Not implemented yet.") } @@ -69,7 +69,7 @@ export class StatsController { @Get("/teams/distance") @UseBefore(StatsAuth) @ResponseSchema(ResponseStatsTeam, { isArray: true }) - @OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }] }) + @OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async getTopTeamsByDistance() { let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track'] }); let topTeams = teams.sort((team1, team2) => team1.distance - team2.distance).slice(0, 9); @@ -83,7 +83,7 @@ export class StatsController { @Get("/teams/donations") @UseBefore(StatsAuth) @ResponseSchema(ResponseStatsTeam, { isArray: true }) - @OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }] }) + @OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async getTopTeamsByDonations() { let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track'] }); let topTeams = teams.sort((team1, team2) => team1.distanceDonationAmount - team2.distanceDonationAmount).slice(0, 9); @@ -97,7 +97,7 @@ export class StatsController { @Get("/organisations/distance") @UseBefore(StatsAuth) @ResponseSchema(ResponseStatsOrgnisation, { isArray: true }) - @OpenAPI({ description: "Returns the top ten organisations by distance.", security: [{ "StatsApiToken": [] }] }) + @OpenAPI({ description: "Returns the top ten organisations by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async getTopOrgsByDistance() { let orgs = await getConnection().getRepository(RunnerOrganisation).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] }); let topOrgs = orgs.sort((org1, org2) => org1.distance - org2.distance).slice(0, 9); @@ -111,7 +111,7 @@ export class StatsController { @Get("/organisations/donations") @UseBefore(StatsAuth) @ResponseSchema(ResponseStatsOrgnisation, { isArray: true }) - @OpenAPI({ description: "Returns the top ten organisations by donations.", security: [{ "StatsApiToken": [] }] }) + @OpenAPI({ description: "Returns the top ten organisations by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) async getTopOrgsByDonations() { let orgs = await getConnection().getRepository(RunnerOrganisation).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] }); let topOrgs = orgs.sort((org1, org2) => org1.distanceDonationAmount - org2.distanceDonationAmount).slice(0, 9);