Skip to content

Commit

Permalink
Create registration endpoint (#20)
Browse files Browse the repository at this point in the history
* Reorganize config and add bcryptRounds config

* wip

* Progress

* Signup working with automatic schema generation

* Update readme with new run command

* Improve GHA concurrency
  • Loading branch information
Telokis authored Jan 16, 2024
1 parent 28c4f9f commit 127f100
Show file tree
Hide file tree
Showing 31 changed files with 848 additions and 69 deletions.
18 changes: 17 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
name: Orchestrates sub-workflows

on: push
on:
pull_request:
types:
- opened
- reopened
- synchronize
- ready_for_review
push:
branches:
- master

concurrency:
# When the workflow is running on master, we want to let it finish everytime.
# If it's not on master, we use the ref (mainly branch name) instead.
# We fall back to the run_id if the ref is undefined (manual trigger).
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/master' && github.run_id || github.ref || github.run_id }}
cancel-in-progress: true

jobs:
lint-all:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ npm-debug.log
yarn-error.log
testem.log
/typings
.nx

# System Files
.DS_Store
Expand Down
55 changes: 55 additions & 0 deletions Makefile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
const fs = require("node:fs");
const path = require("node:path");
const _ = require("lodash");

exports["generate-schemas"] = async () => {
const tsj = require("ts-json-schema-generator");
const schemasIndex = require("./tools/schemas-index.json");
const prettier = require("prettier");

const templateContent = fs.readFileSync(path.resolve(__dirname, "./tools/schema.ts.tpl"));
const compiledTemplate = _.template(templateContent);

/** @type {import('ts-json-schema-generator/dist/src/Config').Config} */
const baseConfig = {
tsconfig: path.resolve(__dirname, "./apps/teloalapi/tsconfig.app.json"),
topRef: true,
skipTypeCheck: true,
};

const baseOutputPath = path.resolve(__dirname, "apps/teloalapi/src/schemas/");

for (const { filePath, typeName, additionalOptions, keepDefinition, asConst } of schemasIndex) {
console.log(`Processing type '${typeName}' from '${filePath}'`);

const schema = tsj
.createGenerator({
...baseConfig,
path: filePath,
...additionalOptions,
})
.createSchema(typeName);

let schemaString = JSON.stringify(schema, null, 2);

if (keepDefinition) {
schemaString = schemaString.replace(
/"#\/definitions\/([a-z-_]+)"/gi,
`"#/components/schemas/$1"`,
);
}

const outputPath = path.resolve(baseOutputPath, `${typeName}.schema.ts`);

const generatedFile = compiledTemplate({
typeName,
schemaString,
filePath,
asConst: asConst ?? false,
});

const formattedFile = await prettier.format(generatedFile, { filepath: outputPath });

fs.writeFileSync(outputPath, formattedFile);
}
};
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,5 @@ Simply run `docker-compose up -d` at the root of the repository to get things st
Use the following command to rebuild and serve the server. This won't watch the files, you need to do it again when changing something:

```
npx nx run teloalapi:build && npx nx run teloalapi:serve
npx jmake generate-schemas && npx nx run teloalapi:build && npx nx run teloalapi:serve
```
29 changes: 29 additions & 0 deletions apps/teloalapi/config/_helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
exports.parseIntDefault = function parseIntDefault(str, defaultValue) {
const res = parseInt(str, 10);

if (Object.is(NaN, res)) {
return defaultValue;
}

return res;
};

exports.parseBoolDefault = function parseBoolDefault(str, defaultValue) {
if (str === "true") {
return true;
}

if (str === "false") {
return false;
}

return defaultValue;
};

exports.urlDefault = function urlDefault(maybeUrl, defaultValue) {
if (!maybeUrl) {
return defaultValue;
}

return maybeUrl.endsWith("/") ? maybeUrl.substring(0, -1) : maybeUrl;
};
7 changes: 7 additions & 0 deletions apps/teloalapi/config/custom-environment-variables.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ module.exports = {
al: {
token: "AL_TOKEN",
},
auth: {
pepper: "AUTH_PEPPER",
recaptcha: {
siteKey: "AUTH_RECAPTCHA_SITE_KEY",
secretKey: "AUTH_RECAPTCHA_SECRET_KEY",
},
},
mongo: {
host: "MONGO_HOST",
user: "MONGO_USER",
Expand Down
39 changes: 7 additions & 32 deletions apps/teloalapi/config/default.js
Original file line number Diff line number Diff line change
@@ -1,47 +1,22 @@
require("dotenv").config();

function parseIntDefault(str, radix, defaultValue) {
const res = parseInt(str, radix);

if (Object.is(NaN, res)) {
return defaultValue;
}

return res;
}

function parseBoolDefault(str, defaultValue) {
if (str === "true") {
return true;
}
const { parseIntDefault, urlDefault, parseBoolDefault } = require("./_helpers");

if (str === "false") {
return false;
}

return defaultValue;
}

function urlDefault(maybeUrl, defaultValue) {
if (!maybeUrl) {
return defaultValue;
}

return maybeUrl.endsWith("/") ? maybeUrl.substring(0, -1) : maybeUrl;
}
require("dotenv").config();

const env = process.env;

module.exports = {
prod: env.NODE_ENV === "production",
port: parseIntDefault(env.PORT, 10, 3000),
port: parseIntDefault(env.PORT, 3000),
al: {
url: urlDefault(env.AL_URL, "https://adventure.land"),
},
mongo: {
port: parseIntDefault(env.MONGO_PORT, 10, 27027),
port: parseIntDefault(env.MONGO_PORT, 27027),
database: "teloal",
},
auth: {
bcryptRounds: parseIntDefault(env.AUTH_BCRYPT_ROUNDS, 10),
},
disableAllCrons: parseBoolDefault(env.DISABLE_ALL_CRONS, false),
crons: {
gameDataFetcher: {
Expand Down
3 changes: 3 additions & 0 deletions apps/teloalapi/config/development.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@ module.exports = {
user: "teloal",
password: "teloal",
},
auth: {
pepper: "8Xh9jXF8XBiLJqX7_8Q6EEPgDzQ_Mi8H",
},
};
2 changes: 0 additions & 2 deletions apps/teloalapi/config/production.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
const boolVal = (val, def) => (val ? val === "true" : def);

// `undefined` means the default value is ignored and an environment variable is required
module.exports = {
host: undefined,
Expand Down
6 changes: 3 additions & 3 deletions apps/teloalapi/src/controllers/adventureLand.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { inject } from "@loopback/core";
import { HttpErrors, api, get, getModelSchemaRef, param, response } from "@loopback/rest";
import { NotFoundErrorSchema } from "../schemas/NotFoundError";
import { NotFoundErrorResponse } from "../schemas/responses/NotFoundResponse";
import { AdventureLandService } from "../services/adventureLand.service";
import { cache } from "@teloal/lb4-cache";
import { parseCharacters, AlCharacter } from "@teloal/parse-character";
Expand Down Expand Up @@ -32,7 +32,7 @@ export class AdventureLandController {
},
})
@response(404, {
...NotFoundErrorSchema,
...NotFoundErrorResponse,
description: "The requested character was not found.",
})
async getCharacter(@param.path.string("name") name: string): Promise<AlCharacter> {
Expand All @@ -59,7 +59,7 @@ export class AdventureLandController {
},
})
@response(404, {
...NotFoundErrorSchema,
...NotFoundErrorResponse,
description: "The requested player was not found.",
})
async getPlayer(@param.path.string("name") name: string): Promise<Array<AlCharacter>> {
Expand Down
82 changes: 82 additions & 0 deletions apps/teloalapi/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { HttpErrors, RequestBodyObject, api, post, requestBody, response } from "@loopback/rest";
import { repository } from "@loopback/repository";
import { UsersRepository } from "../repositories/Users.repository";
import { userSignupBodySchema } from "../schemas/UserSignupBody.schema";
import { ConflictErrorResponse } from "../schemas/responses/ConflictErrorResponse";
import { ValidationErrorResponse } from "../schemas/responses/ValidationErrorResponse";
import { SuccessResponse } from "../schemas/responses/SuccessResponse";
import { SuccessResponseObject } from "../types/SuccessResponseObject";
import { hashPassword } from "@teloal/pseudo-crypto";
import config from "config";
import { UserRole } from "../types/User";

export type UserSignupBody = {
/**
* The chosen username for the account. Must be unique
*
* @minLength 3
* @maxLength 20
* @pattern ^[a-zA-Z](?:[a-zA-Z0-9]+[-_]?)+[a-zA-Z0-9]+$
*/
username: string;

/**
* The chosen password for the account. Must be at least 10 chars long.
*
* @minLength 10
*/
password: string;
};

const userSignupBodyDesc: Partial<RequestBodyObject> = {
description: "Required input for signup",
required: true,
content: {
"application/json": {
schema: userSignupBodySchema,
},
},
};

@api({ basePath: "/v1/auth" })
export class AuthController {
@repository(UsersRepository)
protected usersRepo: UsersRepository;

@post("/signup")
@response(200, SuccessResponse)
@response(409, ConflictErrorResponse)
@response(422, ValidationErrorResponse)
async signup(
@requestBody(userSignupBodyDesc) signupPayload: UserSignupBody,
): Promise<SuccessResponseObject> {
const { username, password } = signupPayload;

if (password.length < 10) {
throw new HttpErrors.BadRequest(`The password must be at least 10 characters long.`);
}

if (await this.usersRepo.exists(username)) {
throw new HttpErrors.Conflict(`This username is already in use.`);
}

const hashedPassword = await hashPassword(
password,
config.auth.bcryptRounds,
config.auth.pepper,
);

try {
await this.usersRepo.create({
username,
password: hashedPassword,
role: UserRole.user,
});
} catch (err) {
console.error(err);
throw new HttpErrors.InternalServerError("Something went wrong. Try again later.");
}

return { success: true };
}
}
11 changes: 11 additions & 0 deletions apps/teloalapi/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { ApplicationConfig, TeloAlApiApplication } from "./application";
import config from "config";
import Ajv from "ajv";
import { configInterfaceSchema } from "./schemas/ConfigInterface.schema";

export * from "./application";

Expand All @@ -15,6 +17,15 @@ export async function main(options: ApplicationConfig = {}) {
return app;
}

const ajv = new Ajv({ allErrors: true });

const isConfigValid = ajv.validate(configInterfaceSchema, config);
if (!isConfigValid) {
console.error(`Configuration is invalid. See below.`);
console.error(ajv.errors);
throw new Error("Invalid config.");
}

// Run the application
const restConfig = {
rest: {
Expand Down
3 changes: 3 additions & 0 deletions apps/teloalapi/src/models/User.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { User, UserRole } from "../types/User";
export class UserModel extends Entity implements User {
@property({
id: true,
required: true,
jsonSchema: {
description: "Uniquely identifies a user.",
},
Expand All @@ -23,13 +24,15 @@ export class UserModel extends Entity implements User {
email?: string;

@property({
required: true,
jsonSchema: {
description: "Hashed user password.",
},
})
password: string;

@property({
required: true,
jsonSchema: {
description: "Hashed user password.",
enum: ["admin", "user"],
Expand Down
Loading

0 comments on commit 127f100

Please sign in to comment.