mirror of
https://github.com/usatiuk/photos.git
synced 2025-10-28 07:27:47 +01:00
first user is admin user, restrict signups
todo: actual admin interface (web and/or cli)
This commit is contained in:
@@ -89,4 +89,4 @@
|
|||||||
"pre-commit": "npm run lint-all && npm run prettier-check"
|
"pre-commit": "npm run lint-all && npm run prettier-check"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,14 +69,6 @@ const production: IConfig = {
|
|||||||
const development: IConfig = {
|
const development: IConfig = {
|
||||||
...production,
|
...production,
|
||||||
env: EnvType.development,
|
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 = {
|
const test: IConfig = {
|
||||||
|
|||||||
75
src/entity/Config.ts
Normal file
75
src/entity/Config.ts
Normal file
@@ -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<ConfigKey, string> = {
|
||||||
|
signupAllowed: "no",
|
||||||
|
};
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Config extends BaseEntity {
|
||||||
|
@PrimaryColumn()
|
||||||
|
@IsIn(Object.values(ConfigKey))
|
||||||
|
public key: ConfigKey;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
public value: string;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
async beforeInsertValidate(): Promise<void> {
|
||||||
|
return validateOrReject(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
async beforeUpdateValidate(): Promise<void> {
|
||||||
|
return validateOrReject(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(key: ConfigKey, value: string) {
|
||||||
|
super();
|
||||||
|
this.key = key;
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfigValue(key: ConfigKey): Promise<string> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -90,7 +90,10 @@ export class Photo extends BaseEntity {
|
|||||||
@Column({ type: "timestamp", default: null })
|
@Column({ type: "timestamp", default: null })
|
||||||
public editedAt: Date;
|
public editedAt: Date;
|
||||||
|
|
||||||
@ManyToOne(() => User, (user) => user.photos, { eager: true })
|
@ManyToOne(() => User, (user) => user.photos, {
|
||||||
|
eager: true,
|
||||||
|
onDelete: "CASCADE",
|
||||||
|
})
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
public getFileName(): string {
|
public getFileName(): string {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
validateOrReject,
|
validateOrReject,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
|
||||||
export type IUserJSON = Pick<User, "id" | "username">;
|
export type IUserJSON = Pick<User, "id" | "username" | "isAdmin">;
|
||||||
|
|
||||||
export interface IUserJWT extends IUserJSON {
|
export interface IUserJWT extends IUserJSON {
|
||||||
ext: number;
|
ext: number;
|
||||||
@@ -58,6 +58,9 @@ export class User extends BaseEntity {
|
|||||||
@OneToMany(() => Photo, (photo) => photo.user)
|
@OneToMany(() => Photo, (photo) => photo.user)
|
||||||
photos: Promise<Photo[]>;
|
photos: Promise<Photo[]>;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
public isAdmin: boolean;
|
||||||
|
|
||||||
constructor(username: string, email: string) {
|
constructor(username: string, email: string) {
|
||||||
super();
|
super();
|
||||||
this.username = username;
|
this.username = username;
|
||||||
@@ -97,13 +100,13 @@ export class User extends BaseEntity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public toJSON(): IUserJSON {
|
public toJSON(): IUserJSON {
|
||||||
const { id, username } = this;
|
const { id, username, isAdmin } = this;
|
||||||
return { id, username };
|
return { id, username, isAdmin };
|
||||||
}
|
}
|
||||||
|
|
||||||
public toAuthJSON(): IUserAuthJSON {
|
public toAuthJSON(): IUserAuthJSON {
|
||||||
const { id, username } = this;
|
const json = this.toJSON();
|
||||||
return { id, username, jwt: this.toJWT() };
|
return { ...json, jwt: this.toJWT() };
|
||||||
}
|
}
|
||||||
|
|
||||||
public toJWT(): string {
|
public toJWT(): string {
|
||||||
|
|||||||
31
src/migration/1603132068670-restrictSignups.ts
Normal file
31
src/migration/1603132068670-restrictSignups.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class restrictSignups1603132068670 implements MigrationInterface {
|
||||||
|
name = "restrictSignups1603132068670";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as Router from "@koa/router";
|
import * as Router from "@koa/router";
|
||||||
|
import { getConfigValue, ConfigKey } from "~entity/Config";
|
||||||
import { IUserAuthJSON, IUserJWT, User } from "~entity/User";
|
import { IUserAuthJSON, IUserJWT, User } from "~entity/User";
|
||||||
import { IAPIResponse } from "~types";
|
import { IAPIResponse } from "~types";
|
||||||
|
|
||||||
@@ -70,6 +71,17 @@ userRouter.post("/users/signup", async (ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = new User(username, email);
|
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);
|
await user.setPassword(password);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
IUserSignupRespBody,
|
IUserSignupRespBody,
|
||||||
} from "~routes/users";
|
} from "~routes/users";
|
||||||
|
|
||||||
import { ISeed, seedDB } from "./util";
|
import { allowSignups, ISeed, seedDB } from "./util";
|
||||||
|
|
||||||
const callback = app.callback();
|
const callback = app.callback();
|
||||||
|
|
||||||
@@ -87,6 +87,8 @@ describe("users", function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should signup user", async function () {
|
it("should signup user", async function () {
|
||||||
|
await allowSignups();
|
||||||
|
|
||||||
const response = await request(callback)
|
const response = await request(callback)
|
||||||
.post("/users/signup")
|
.post("/users/signup")
|
||||||
.set({ "Content-Type": "application/json" })
|
.set({ "Content-Type": "application/json" })
|
||||||
@@ -110,7 +112,121 @@ describe("users", function () {
|
|||||||
expect(user).to.deep.equal(newUser.toJSON());
|
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 () {
|
it("should not signup user with duplicate username", async function () {
|
||||||
|
await allowSignups();
|
||||||
|
|
||||||
const response = await request(callback)
|
const response = await request(callback)
|
||||||
.post("/users/signup")
|
.post("/users/signup")
|
||||||
.set({ "Content-Type": "application/json" })
|
.set({ "Content-Type": "application/json" })
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import * as fs from "fs/promises";
|
|||||||
import { User } from "entity/User";
|
import { User } from "entity/User";
|
||||||
import { Photo } from "~entity/Photo";
|
import { Photo } from "~entity/Photo";
|
||||||
import { getHash, getSize } from "~util";
|
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 dogPath = "./src/tests/integration/photos/dog.jpg";
|
||||||
export const catPath = "./src/tests/integration/photos/cat.jpg";
|
export const catPath = "./src/tests/integration/photos/cat.jpg";
|
||||||
@@ -48,6 +50,7 @@ export async function seedDB(): Promise<ISeed> {
|
|||||||
|
|
||||||
await Photo.remove(await Photo.find());
|
await Photo.remove(await Photo.find());
|
||||||
await User.remove(await User.find());
|
await User.remove(await User.find());
|
||||||
|
await Config.remove(await Config.find());
|
||||||
|
|
||||||
const user1 = new User("User1", "user1@users.com");
|
const user1 = new User("User1", "user1@users.com");
|
||||||
await user1.setPassword("User1");
|
await user1.setPassword("User1");
|
||||||
@@ -68,3 +71,7 @@ export async function seedDB(): Promise<ISeed> {
|
|||||||
|
|
||||||
return { user1, user2, dogPhoto, catPhoto };
|
return { user1, user2, dogPhoto, catPhoto };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function allowSignups(): Promise<void> {
|
||||||
|
await setConfigValue(ConfigKey.signupAllowed, "yes");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user