From fc7d5b7f349ea950ea272da495edaa53975eefec Mon Sep 17 00:00:00 2001 From: Stepan Usatiuk Date: Mon, 19 Oct 2020 21:29:26 +0300 Subject: [PATCH] first user is admin user, restrict signups todo: actual admin interface (web and/or cli) --- package.json | 2 +- src/config/index.ts | 8 -- src/entity/Config.ts | 75 +++++++++++ src/entity/Photo.ts | 5 +- src/entity/User.ts | 13 +- .../1603132068670-restrictSignups.ts | 31 +++++ src/routes/users.ts | 12 ++ src/tests/integration/users.test.ts | 118 +++++++++++++++++- src/tests/integration/util.ts | 7 ++ 9 files changed, 255 insertions(+), 16 deletions(-) create mode 100644 src/entity/Config.ts create mode 100644 src/migration/1603132068670-restrictSignups.ts diff --git a/package.json b/package.json index 53d1593..109e4d3 100644 --- a/package.json +++ b/package.json @@ -89,4 +89,4 @@ "pre-commit": "npm run lint-all && npm run prettier-check" } } -} \ No newline at end of file +} diff --git a/src/config/index.ts b/src/config/index.ts index 949bc92..bf4220f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -69,14 +69,6 @@ const production: IConfig = { const development: IConfig = { ...production, env: EnvType.development, - dbConnectionOptions: - process.env.NODE_ENV === "development" - ? fs.existsSync("./ormconfig.dev.json") - ? (JSON.parse( - fs.readFileSync("./ormconfig.dev.json").toString(), - ) as ConnectionOptions) - : null - : null, }; const test: IConfig = { diff --git a/src/entity/Config.ts b/src/entity/Config.ts new file mode 100644 index 0000000..6f0dea8 --- /dev/null +++ b/src/entity/Config.ts @@ -0,0 +1,75 @@ +import { isIn, IsIn, validateOrReject } from "class-validator"; +import { + BaseEntity, + BeforeInsert, + BeforeUpdate, + Column, + Entity, + PrimaryColumn, +} from "typeorm"; + +export enum ConfigKey { + "signupAllowed" = "signupAllowed", +} + +export interface IDBConfig { + signupAllowed: boolean; +} + +const defaultValues: Record = { + signupAllowed: "no", +}; + +@Entity() +export class Config extends BaseEntity { + @PrimaryColumn() + @IsIn(Object.values(ConfigKey)) + public key: ConfigKey; + + @Column() + public value: string; + + @BeforeInsert() + async beforeInsertValidate(): Promise { + return validateOrReject(this); + } + + @BeforeUpdate() + async beforeUpdateValidate(): Promise { + return validateOrReject(this); + } + + constructor(key: ConfigKey, value: string) { + super(); + this.key = key; + this.value = value; + } +} + +export async function getConfigValue(key: ConfigKey): Promise { + if (!isIn(key, Object.values(ConfigKey))) { + throw new Error(`${key} is not valid config key`); + } + + try { + const pair = await Config.findOneOrFail({ key }); + return pair.value; + } catch (e) { + return defaultValues[key]; + } +} + +export async function setConfigValue( + key: ConfigKey, + val: string, +): Promise { + if (!isIn(key, Object.values(ConfigKey))) { + throw new Error(`${key} is not valid config key`); + } + + let pair = await Config.findOne({ key }); + if (!pair) { + pair = new Config(key, val); + } + await pair.save(); +} diff --git a/src/entity/Photo.ts b/src/entity/Photo.ts index 95e0f49..8951dd8 100644 --- a/src/entity/Photo.ts +++ b/src/entity/Photo.ts @@ -90,7 +90,10 @@ export class Photo extends BaseEntity { @Column({ type: "timestamp", default: null }) public editedAt: Date; - @ManyToOne(() => User, (user) => user.photos, { eager: true }) + @ManyToOne(() => User, (user) => user.photos, { + eager: true, + onDelete: "CASCADE", + }) public user: User; public getFileName(): string { diff --git a/src/entity/User.ts b/src/entity/User.ts index db3b962..868ed0d 100644 --- a/src/entity/User.ts +++ b/src/entity/User.ts @@ -26,7 +26,7 @@ import { validateOrReject, } from "class-validator"; -export type IUserJSON = Pick; +export type IUserJSON = Pick; export interface IUserJWT extends IUserJSON { ext: number; @@ -58,6 +58,9 @@ export class User extends BaseEntity { @OneToMany(() => Photo, (photo) => photo.user) photos: Promise; + @Column({ default: false }) + public isAdmin: boolean; + constructor(username: string, email: string) { super(); this.username = username; @@ -97,13 +100,13 @@ export class User extends BaseEntity { } public toJSON(): IUserJSON { - const { id, username } = this; - return { id, username }; + const { id, username, isAdmin } = this; + return { id, username, isAdmin }; } public toAuthJSON(): IUserAuthJSON { - const { id, username } = this; - return { id, username, jwt: this.toJWT() }; + const json = this.toJSON(); + return { ...json, jwt: this.toJWT() }; } public toJWT(): string { diff --git a/src/migration/1603132068670-restrictSignups.ts b/src/migration/1603132068670-restrictSignups.ts new file mode 100644 index 0000000..085f643 --- /dev/null +++ b/src/migration/1603132068670-restrictSignups.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class restrictSignups1603132068670 implements MigrationInterface { + name = "restrictSignups1603132068670"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + "ALTER TABLE `photo` DROP FOREIGN KEY `FK_4494006ff358f754d07df5ccc87`", + ); + await queryRunner.query( + "CREATE TABLE `config` (`key` varchar(255) NOT NULL, `value` varchar(255) NOT NULL, PRIMARY KEY (`key`)) ENGINE=InnoDB", + ); + await queryRunner.query( + "ALTER TABLE `user` ADD `isAdmin` tinyint NOT NULL DEFAULT 0", + ); + await queryRunner.query( + "ALTER TABLE `photo` ADD CONSTRAINT `FK_4494006ff358f754d07df5ccc87` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION", + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + "ALTER TABLE `photo` DROP FOREIGN KEY `FK_4494006ff358f754d07df5ccc87`", + ); + await queryRunner.query("ALTER TABLE `user` DROP COLUMN `isAdmin`"); + await queryRunner.query("DROP TABLE `config`"); + await queryRunner.query( + "ALTER TABLE `photo` ADD CONSTRAINT `FK_4494006ff358f754d07df5ccc87` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION", + ); + } +} diff --git a/src/routes/users.ts b/src/routes/users.ts index bffb828..71174ff 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -1,4 +1,5 @@ import * as Router from "@koa/router"; +import { getConfigValue, ConfigKey } from "~entity/Config"; import { IUserAuthJSON, IUserJWT, User } from "~entity/User"; import { IAPIResponse } from "~types"; @@ -70,6 +71,17 @@ userRouter.post("/users/signup", async (ctx) => { } const user = new User(username, email); + const users = await User.find(); + + if (users.length === 0) { + user.isAdmin = true; + } + if ((await getConfigValue(ConfigKey.signupAllowed)) !== "yes") { + if (users.length !== 0) { + ctx.throw(400, "Signups not allowed"); + } + } + await user.setPassword(password); try { diff --git a/src/tests/integration/users.test.ts b/src/tests/integration/users.test.ts index 548c8f2..9072c4a 100644 --- a/src/tests/integration/users.test.ts +++ b/src/tests/integration/users.test.ts @@ -14,7 +14,7 @@ import { IUserSignupRespBody, } from "~routes/users"; -import { ISeed, seedDB } from "./util"; +import { allowSignups, ISeed, seedDB } from "./util"; const callback = app.callback(); @@ -87,6 +87,8 @@ describe("users", function () { }); it("should signup user", async function () { + await allowSignups(); + const response = await request(callback) .post("/users/signup") .set({ "Content-Type": "application/json" }) @@ -110,7 +112,121 @@ describe("users", function () { expect(user).to.deep.equal(newUser.toJSON()); }); + it("should not signup user if other exist (by default)", async function () { + const response = await request(callback) + .post("/users/signup") + .set({ "Content-Type": "application/json" }) + .send({ + username: "NUser1", + password: "NUser1", + email: "nuser1@users.com", + } as IUserSignupBody) + .expect("Content-Type", /json/) + .expect(400); + + const body = response.body as IUserSignupRespBody; + + expect(body.error).to.be.equal("Signups not allowed"); + expect(body.data).to.be.false; + }); + + it("should signup first user and it should be admin, do not signup new users (by default)", async function () { + await User.remove(await User.find()); + + const response = await request(callback) + .post("/users/signup") + .set({ "Content-Type": "application/json" }) + .send({ + username: "NUser1", + password: "NUser1", + email: "nuser1@users.com", + } as IUserSignupBody) + .expect("Content-Type", /json/) + .expect(200); + + const body = response.body as IUserSignupRespBody; + + if (body.error !== false) { + assert(false); + return; + } + + const { jwt: _, ...user } = body.data; + const newUser = await User.findOneOrFail({ username: "NUser1" }); + expect(user).to.deep.equal(newUser.toJSON()); + expect(user.isAdmin).to.be.true; + + const response2 = await request(callback) + .post("/users/signup") + .set({ "Content-Type": "application/json" }) + .send({ + username: "NUser2", + password: "NUser2", + email: "nuser2@users.com", + } as IUserSignupBody) + .expect("Content-Type", /json/) + .expect(400); + + const body2 = response2.body as IUserSignupRespBody; + + expect(body2.error).to.be.equal("Signups not allowed"); + expect(body2.data).to.be.false; + }); + + it("should signup first user and it should be admin, but not new ones", async function () { + await allowSignups(); + await User.remove(await User.find()); + + const response = await request(callback) + .post("/users/signup") + .set({ "Content-Type": "application/json" }) + .send({ + username: "NUser1", + password: "NUser1", + email: "nuser1@users.com", + } as IUserSignupBody) + .expect("Content-Type", /json/) + .expect(200); + + const body = response.body as IUserSignupRespBody; + + if (body.error !== false) { + assert(false); + return; + } + + const { jwt: jwt1, ...user } = body.data; + const newUser = await User.findOneOrFail({ username: "NUser1" }); + expect(user).to.deep.equal(newUser.toJSON()); + expect(user.isAdmin).to.be.true; + + const response2 = await request(callback) + .post("/users/signup") + .set({ "Content-Type": "application/json" }) + .send({ + username: "NUser2", + password: "NUser2", + email: "nuser2@users.com", + } as IUserSignupBody) + .expect("Content-Type", /json/) + .expect(200); + + const body2 = response2.body as IUserSignupRespBody; + + if (body2.error !== false) { + assert(false); + return; + } + + const { jwt: jwt2, ...user2 } = body2.data; + const newUser2 = await User.findOneOrFail({ username: "NUser2" }); + expect(user2).to.deep.equal(newUser2.toJSON()); + expect(user2.isAdmin).to.be.false; + }); + it("should not signup user with duplicate username", async function () { + await allowSignups(); + const response = await request(callback) .post("/users/signup") .set({ "Content-Type": "application/json" }) diff --git a/src/tests/integration/util.ts b/src/tests/integration/util.ts index 877eda3..6ab14bf 100644 --- a/src/tests/integration/util.ts +++ b/src/tests/integration/util.ts @@ -3,6 +3,8 @@ import * as fs from "fs/promises"; import { User } from "entity/User"; import { Photo } from "~entity/Photo"; import { getHash, getSize } from "~util"; +import { Config, ConfigKey, setConfigValue } from "~entity/Config"; +import { config } from "chai"; export const dogPath = "./src/tests/integration/photos/dog.jpg"; export const catPath = "./src/tests/integration/photos/cat.jpg"; @@ -48,6 +50,7 @@ export async function seedDB(): Promise { await Photo.remove(await Photo.find()); await User.remove(await User.find()); + await Config.remove(await Config.find()); const user1 = new User("User1", "user1@users.com"); await user1.setPassword("User1"); @@ -68,3 +71,7 @@ export async function seedDB(): Promise { return { user1, user2, dogPhoto, catPhoto }; } + +export async function allowSignups(): Promise { + await setConfigValue(ConfigKey.signupAllowed, "yes"); +}