some cleanup, split backend and frontend

This commit is contained in:
2023-07-29 13:04:26 +02:00
parent 14210cf0cf
commit faa0aa62c8
69 changed files with 13194 additions and 12319 deletions

48
backend/.eslintrc.json Normal file
View File

@@ -0,0 +1,48 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint",
"prettier",
"import",
"mocha"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:import/typescript",
"plugin:mocha/recommended"
],
"env": {
"node": true
},
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module",
"project": "./tsconfig.json"
},
"settings": {
"import/parsers": {
"@typescript-eslint/parser": [
".ts",
".tsx"
]
},
"import/resolver": {
"typescript": {
"alwaysTryTypes": true,
"project": [
"./tsconfig.json"
]
}
}
},
"rules": {
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-assignment": "off"
}
}

17
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
.DS_Store
.idea/
node_modules/
build/
tmp/
temp/
dist/
ormconfig.json
ormconfig.dev.json
ormconfig.test.json
.env
.directory
.history
data_test
data_dev
data
backend-report.xml

5
backend/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"trailingComma": "all",
"tabWidth": 4,
"endOfLine": "auto"
}

1
backend/insomnia.json Normal file

File diff suppressed because one or more lines are too long

6
backend/mocha.json Executable file
View File

@@ -0,0 +1,6 @@
{
"reporterEnabled": "spec, mocha-junit-reporter",
"mochaJunitReporterReporterOptions": {
"mochaFile":"backend-report.xml"
}
}

25
backend/ormconfig.ci.json Normal file
View File

@@ -0,0 +1,25 @@
{
"type": "mariadb",
"host": "localhost",
"port": 3306,
"username": "photos",
"password": "photos",
"database": "photos_test",
"synchronize": true,
"logging": false,
"entities": [
"src/entity/**/*.ts"
],
"migrations": [
"src/migration/**/*.ts"
],
"subscribers": [
"src/subscriber/**/*.ts"
],
"cli": {
"entitiesDir": "src/entity",
"migrationsDir": "src/migration",
"subscribersDir": "src/subscriber"
},
"charset": "utf8mb4"
}

View File

@@ -0,0 +1,19 @@
{
"type": "mariadb",
"host": "db",
"port": 3306,
"username": "photosuser",
"password": "photospass",
"database": "photosdb",
"synchronize": false,
"logging": false,
"entities": ["src/entity/**/*.ts"],
"migrations": ["src/migration/**/*.ts"],
"subscribers": ["src/subscriber/**/*.ts"],
"cli": {
"entitiesDir": "src/entity",
"migrationsDir": "src/migration",
"subscribersDir": "src/subscriber"
},
"charset": "utf8mb4"
}

View File

@@ -0,0 +1,19 @@
{
"type": "mariadb",
"host": "db",
"port": 3306,
"username": "photostestuser",
"password": "photostestpass",
"database": "photostestdb",
"synchronize": true,
"logging": false,
"entities": ["src/entity/**/*.ts"],
"migrations": ["src/migration/**/*.ts"],
"subscribers": ["src/subscriber/**/*.ts"],
"cli": {
"entitiesDir": "src/entity",
"migrationsDir": "src/migration",
"subscribersDir": "src/subscriber"
},
"charset": "utf8mb4"
}

View File

@@ -0,0 +1,25 @@
{
"type": "mariadb",
"host": "localhost",
"port": 3306,
"username": "photos",
"password": "photos",
"database": "photos",
"synchronize": false,
"logging": false,
"entities": [
"src/entity/**/*.ts"
],
"migrations": [
"src/migration/**/*.ts"
],
"subscribers": [
"src/subscriber/**/*.ts"
],
"cli": {
"entitiesDir": "src/entity",
"migrationsDir": "src/migration",
"subscribersDir": "src/subscriber"
},
"charset": "utf8mb4"
}

12695
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

86
backend/package.json Executable file
View File

@@ -0,0 +1,86 @@
{
"name": "photos",
"version": "0.0.1",
"scripts": {
"start": "ts-node -T -r tsconfig-paths/register src/server.ts",
"ts-node-dev": "ts-node-dev -r tsconfig-paths/register ./src/server.ts",
"test": "cross-env NODE_ENV=test mocha --timeout 15000 -r ts-node/register -r tsconfig-paths/register --reporter mocha-multi-reporters --reporter-options configFile=mocha.json 'src/tests/**/*.ts'",
"lint": "eslint ./src/** --ext .js,.jsx,.ts,.tsx && tsc --noEmit",
"lint-fix": "eslint ./src/** --ext .js,.jsx,.ts,.tsx --fix",
"prettier-check": "prettier src/**/*.ts --check",
"prettify": "prettier src/**/*.ts --write",
"typeorm-dev": "cross-env NODE_ENV=development ts-node -T -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"typeorm": "cross-env NODE_ENV=production ts-node -T -r tsconfig-paths/register ./node_modules/typeorm/cli.js"
},
"license": "MIT",
"dependencies": {
"@koa/cors": "^4.0.0",
"@koa/router": "^12.0.0",
"bcrypt": "^5.1.0",
"class-validator": "^0.14.0",
"exifreader": "^4.13.0",
"hasha": "^5.2.2",
"io-ts": "^2.2.20",
"jsonwebtoken": "^9.0.1",
"koa": "^2.14.2",
"koa-body": "^5.0.0",
"koa-jwt": "^4.0.4",
"koa-logger": "^3.2.1",
"koa-send": "^5.0.1",
"koa-sslify": "^5.0.1",
"koa-static": "^5.0.0",
"mime-types": "^2.1.35",
"mysql": "^2.18.1",
"sharp": "^0.32.4",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typeorm": "^0.2.41",
"typescript": "^5.1.6"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
"@types/chai": "^4.3.5",
"@types/concurrently": "^6.4.0",
"@types/deasync": "^0.1.2",
"@types/eslint": "^8.44.1",
"@types/eslint-plugin-prettier": "^3.1.0",
"@types/jsonwebtoken": "^9.0.2",
"@types/koa": "^2.13.7",
"@types/koa__cors": "^4.0.0",
"@types/koa__router": "^12.0.0",
"@types/koa-logger": "^3.1.2",
"@types/koa-send": "^4.1.3",
"@types/koa-sslify": "^4.0.3",
"@types/koa-static": "^4.0.2",
"@types/mime-types": "^2.1.1",
"@types/mocha": "^10.0.1",
"@types/mysql": "^2.15.21",
"@types/prettier": "^2.7.3",
"@types/sharp": "^0.31.1",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6.2.0",
"@typescript-eslint/parser": "^6.2.0",
"chai": "^4.3.7",
"concurrently": "^8.2.0",
"cross-env": "^7.0.3",
"eslint": "^8.46.0",
"eslint-config-prettier": "^8.9.0",
"eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-import": "^2.28.0",
"eslint-plugin-mocha": "^10.1.0",
"eslint-plugin-prettier": "^5.0.0",
"husky": "^8.0.3",
"mocha": "^10.2.0",
"mocha-junit-reporter": "^2.2.1",
"mocha-multi-reporters": "^1.5.1",
"prettier": "^3.0.0",
"prettier-eslint": "^15.0.1",
"supertest": "^6.3.3",
"ts-node-dev": "^2"
},
"husky": {
"hooks": {
"pre-commit": "npm run lint-all && npm run prettier-check"
}
}
}

99
backend/src/app.ts Normal file
View File

@@ -0,0 +1,99 @@
import "reflect-metadata";
import * as cors from "@koa/cors";
import * as Koa from "koa";
import * as bodyParser from "koa-body";
import * as jwt from "koa-jwt";
import * as logger from "koa-logger";
import * as send from "koa-send";
import sslify, { xForwardedProtoResolver } from "koa-sslify";
import * as serve from "koa-static";
import * as path from "path";
import * as fs from "fs";
import { config, EnvType } from "~config";
import { userRouter } from "~routes/users";
import { devRouter } from "~routes/dev";
import { photosRouter } from "~routes/photos";
export const app = new Koa();
const tmpPath = path.join(config.dataDir, "tmp");
// Create both data dir if it doesn't exist and temp dir
fs.mkdirSync(tmpPath, { recursive: true });
app.use(logger());
app.use(cors());
app.use(
bodyParser({
multipart: true,
formidable: { uploadDir: tmpPath },
}),
);
if (config.https) {
app.use(sslify({ resolver: xForwardedProtoResolver }));
}
app.use(
jwt({
secret: config.jwtSecret,
passthrough: true,
}),
);
app.use(async (ctx, next) => {
try {
await next();
} finally {
if (ctx.request.files) {
const filesVals = Object.values(ctx.request.files);
await Promise.all(
filesVals.map(async (f) => {
try {
if (Array.isArray(f)) {
throw "more than one file uploaded";
}
await fs.promises.unlink(f.filepath);
} catch (e) {
if (e.code !== "ENOENT") {
throw e;
}
}
}),
);
}
}
});
app.use(async (ctx, next) => {
try {
await next();
const status = ctx.status || 404;
if (status === 404) {
await send(ctx, "frontend/dist/index.html");
}
} catch (err) {
ctx.status = err.status || 500;
ctx.body = err.message;
ctx.app.emit("error", err, ctx);
}
});
app.use(serve("frontend/dist"));
app.use(userRouter.routes()).use(userRouter.allowedMethods());
app.use(photosRouter.routes()).use(photosRouter.allowedMethods());
if (config.env === EnvType.development) {
app.use(devRouter.routes()).use(devRouter.allowedMethods());
}
app.on("error", (err, ctx) => {
console.log(err);
ctx.body = {
error: err.message,
data: false,
};
});

View File

@@ -0,0 +1,10 @@
import "../entity/User";
import { Connection, createConnection } from "typeorm";
import { config } from "./";
export async function connect(): Promise<Connection> {
return config.dbConnectionOptions
? createConnection(config.dbConnectionOptions)
: createConnection();
}

View File

@@ -0,0 +1,93 @@
import * as fs from "fs";
import { ConnectionOptions } from "typeorm";
export enum EnvType {
production,
development,
test,
}
export interface IConfig {
env: EnvType;
port: number;
jwtSecret: string;
dataDir: string;
https: boolean;
dbConnectionOptions: ConnectionOptions | null;
}
function getJwtSecret(): string {
switch (process.env.NODE_ENV) {
case "development":
return "DEVSECRET";
break;
case "test":
return "TESTSECRET";
break;
case "production":
default:
if (process.env.JWT_SECRET === undefined) {
console.log("JWT_SECRET is not set");
process.exit(1);
} else {
return process.env.JWT_SECRET;
}
break;
}
}
function getDataDir(): string {
switch (process.env.NODE_ENV) {
case "development":
return "./data_dev";
break;
case "test":
return "./data_test";
break;
case "production":
default:
if (process.env.DATA_DIR === undefined) {
console.log("DATA_DIR is not set");
process.exit(1);
} else {
return process.env.DATA_DIR;
}
break;
}
}
const production: IConfig = {
env: EnvType.production,
port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3000,
https: process.env.HTTPS ? process.env.HTTPS === "yes" : false,
jwtSecret: getJwtSecret(),
dataDir: getDataDir(),
dbConnectionOptions: null,
};
const development: IConfig = {
...production,
env: EnvType.development,
};
const test: IConfig = {
...production,
env: EnvType.test,
dbConnectionOptions:
process.env.NODE_ENV === "test"
? process.env.CI
? (JSON.parse(
fs.readFileSync("./ormconfig.ci.json").toString(),
) as ConnectionOptions)
: (JSON.parse(
fs.readFileSync("./ormconfig.test.json").toString(),
) as ConnectionOptions)
: null,
};
const envs: { [key: string]: IConfig } = { production, development, test };
const env = process.env.NODE_ENV || "production";
const currentConfig = envs[env];
export { currentConfig as config };

View File

@@ -0,0 +1,77 @@
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: "yes" | "no";
}
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);
} else {
pair.value = val;
}
await pair.save();
}

239
backend/src/entity/Photo.ts Normal file
View File

@@ -0,0 +1,239 @@
import * as path from "path";
import * as fs from "fs/promises";
import * as mime from "mime-types";
import * as jwt from "jsonwebtoken";
import { IPhotoReqJSON, IPhotoJSON } from "~/shared/types";
import {
BaseEntity,
BeforeInsert,
BeforeRemove,
BeforeUpdate,
Column,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { User } from "./User";
import {
IsAlphanumeric,
IsHash,
IsMimeType,
isNumber,
Matches,
validateOrReject,
} from "class-validator";
import { config } from "~config";
import { fileCheck, getShotDate, resizeToJpeg } from "~util";
export const thumbSizes = ["512", "1024", "2048", "original"];
export type ThumbSize = (typeof thumbSizes)[number];
@Entity()
@Index(["hash", "size", "user"], { unique: true })
export class Photo extends BaseEntity {
@PrimaryGeneratedColumn()
public id: number;
@Column({ length: 190 })
@Index()
@IsHash("md5")
public hash: string;
@Column({ length: 190 })
@IsAlphanumeric()
@Matches(/\d*x\d*/)
public size: string;
@Column({ length: 190 })
@IsMimeType()
public format: string;
@Column({ default: false })
public uploaded: boolean;
@Column({ type: "set", enum: thumbSizes, default: [] })
public generatedThumbs: ThumbSize[];
@Column({ type: "varchar", length: 500 })
public accessToken: string;
@Column({ type: "timestamp", default: null })
public accessTokenExpiry: Date | null;
@Column({ type: "timestamp", default: null })
public shotAt: Date;
@Column({ type: "timestamp", default: null })
public createdAt: Date;
@Column({ type: "timestamp", default: null })
public editedAt: Date;
@ManyToOne(() => User, (user) => user.photos, {
eager: true,
onDelete: "CASCADE",
})
public user: User;
public getFileName(): string {
return `${this.user.id.toString()}-${this.hash}-${this.size}.${
mime.extension(this.format) as string
}`;
}
private getThumbFileName(size: ThumbSize): string {
return `${this.user.id.toString()}-${this.hash}-${this.size}-${size}.${
mime.extension("image/jpeg") as string
}`;
}
public getPath(): string {
return path.join(this.user.getDataPath(), this.getFileName());
}
public getThumbPath(size: ThumbSize): string {
return path.join(this.user.getDataPath(), this.getThumbFileName(size));
}
@BeforeInsert()
async beforeInsertValidate(): Promise<void> {
return validateOrReject(this);
}
@BeforeUpdate()
async beforeUpdateValidate(): Promise<void> {
return validateOrReject(this);
}
@BeforeRemove()
async cleanupFiles(): Promise<void> {
try {
await fs.unlink(this.getPath());
await Promise.all(
this.generatedThumbs.map(
async (size) => await fs.unlink(this.getThumbPath(size)),
),
);
} catch (e) {
if (e.code !== "ENOENT" && e.code !== "NotFoundError") {
throw e;
}
}
}
// Checks if file exists
public async origFileExists(): Promise<boolean> {
if (await fileCheck(this.getPath())) {
return true;
} else {
return false;
}
}
public async processUpload(origFile: string): Promise<void> {
await fs.rename(origFile, this.getPath());
this.uploaded = true;
const date = await getShotDate(this.getPath());
if (date !== null) {
this.shotAt = date;
} else {
this.shotAt = new Date();
}
await this.save();
}
private async generateThumbnail(size: ThumbSize): Promise<void> {
if (!(await this.origFileExists())) {
await this.remove();
throw new Error("Photo file not found");
}
await resizeToJpeg(
this.getPath(),
this.getThumbPath(size),
parseInt(size),
);
this.generatedThumbs.push(size);
await this.save();
}
public async getReadyPath(size: ThumbSize): Promise<string> {
if (!thumbSizes.includes(size)) {
throw new Error("Wrong thumbnail size");
}
const path =
size === "original" ? this.getPath() : this.getThumbPath(size);
if (
size !== "original" &&
(!this.generatedThumbs.includes(size.toString()) ||
!(await fileCheck(path)))
) {
await this.generateThumbnail(size);
}
if (size === "original") {
if (!(await fileCheck(path))) {
await this.remove();
throw new Error("Photo file not found");
}
}
return path;
}
constructor(user: User, hash: string, size: string, format: string) {
super();
this.createdAt = new Date();
this.editedAt = this.createdAt;
this.shotAt = this.createdAt;
this.accessTokenExpiry = this.createdAt;
this.accessToken = "";
this.hash = hash;
this.format = format;
this.size = size;
this.user = user;
this.generatedThumbs = [];
}
public async getJWTToken(): Promise<string> {
const now = new Date().getTime();
const tokenExpiryOld = this.accessTokenExpiry?.getTime();
// If expires in more than 10 minutes then get from cache
if (tokenExpiryOld && tokenExpiryOld - now - 60 * 10 * 1000 > 0) {
return this.accessToken;
} else {
const token = jwt.sign(await this.toJSON(), config.jwtSecret, {
expiresIn: "1h",
algorithm: "HS256",
});
this.accessToken = token;
this.accessTokenExpiry = new Date(now + 60 * 60 * 1000);
await this.save();
return token;
}
}
public async toJSON(): Promise<IPhotoJSON> {
if (!isNumber(this.user.id)) {
throw new Error("User not loaded");
}
return {
id: this.id,
user: this.user.id,
hash: this.hash,
size: this.size,
format: this.format,
createdAt: this.createdAt.getTime(),
editedAt: this.editedAt.getTime(),
// workaround weird bug where this.shotAt is null
shotAt: this.shotAt
? this.shotAt.getTime()
: this.createdAt.getTime(),
uploaded: this.uploaded,
};
}
public async toReqJSON(): Promise<IPhotoReqJSON> {
const token = await this.getJWTToken();
return { ...(await this.toJSON()), accessToken: token };
}
}

View File

@@ -0,0 +1,99 @@
import * as bcrypt from "bcrypt";
import * as jwt from "jsonwebtoken";
import * as path from "path";
import * as fs from "fs/promises";
import { IUserJSON, IUserAuthJSON } from "~/shared/types";
import {
AfterInsert,
BaseEntity,
BeforeInsert,
BeforeRemove,
BeforeUpdate,
Column,
Entity,
Index,
OneToMany,
PrimaryGeneratedColumn,
} from "typeorm";
import { config } from "../config";
import { Photo } from "./Photo";
import { IsAlphanumeric, IsEmail, validateOrReject } from "class-validator";
@Entity()
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
public id: number;
@Column({ length: 190 })
@Index({ unique: true })
@IsAlphanumeric()
public username: string;
@Column({ length: 190 })
@Index({ unique: true })
@IsEmail()
public email: string;
@Column({ length: 190 })
public passwordHash: string;
@OneToMany(() => Photo, (photo) => photo.user)
photos: Promise<Photo[]>;
@Column({ default: false })
public isAdmin: boolean;
constructor(username: string, email: string) {
super();
this.username = username;
this.email = email;
}
public async verifyPassword(password: string): Promise<boolean> {
return bcrypt.compare(password, this.passwordHash);
}
public async setPassword(password: string): Promise<void> {
this.passwordHash = await bcrypt.hash(password, 10);
}
public getDataPath(): string {
return path.join(config.dataDir, this.id.toString());
}
@AfterInsert()
async createDataDir(): Promise<void> {
await fs.mkdir(this.getDataPath(), { recursive: true });
}
@BeforeRemove()
async removeDataDir(): Promise<void> {
// force because otherwise it will fail if directory already doesn't exist
await fs.rm(this.getDataPath(), { recursive: true, force: true });
}
@BeforeInsert()
async beforeInsertValidate(): Promise<void> {
return validateOrReject(this);
}
@BeforeUpdate()
async beforeUpdateValidate(): Promise<void> {
return validateOrReject(this);
}
public toJSON(): IUserJSON {
const { id, username, isAdmin } = this;
return { id, username, isAdmin };
}
public toAuthJSON(): IUserAuthJSON {
const json = this.toJSON();
return { ...json, jwt: this.toJWT() };
}
public toJWT(): string {
return jwt.sign(this.toJSON(), config.jwtSecret, { expiresIn: "31d" });
}
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class init1603126879697 implements MigrationInterface {
name = "init1603126879697";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"CREATE TABLE `user` (`id` int NOT NULL AUTO_INCREMENT, `username` varchar(190) NOT NULL, `email` varchar(190) NOT NULL, `passwordHash` varchar(190) NOT NULL, UNIQUE INDEX `IDX_78a916df40e02a9deb1c4b75ed` (`username`), UNIQUE INDEX `IDX_e12875dfb3b1d92d7d7c5377e2` (`email`), PRIMARY KEY (`id`)) ENGINE=InnoDB",
);
await queryRunner.query(
"CREATE TABLE `photo` (`id` int NOT NULL AUTO_INCREMENT, `hash` varchar(190) NOT NULL, `size` varchar(190) NOT NULL, `format` varchar(190) NOT NULL, `uploaded` tinyint NOT NULL DEFAULT 0, `generatedThumbs` set ('512', '1024', '2048') NOT NULL DEFAULT '', `accessToken` varchar(500) NOT NULL, `accessTokenExpiry` timestamp NULL DEFAULT NULL, `shotAt` timestamp NULL DEFAULT NULL, `createdAt` timestamp NULL DEFAULT NULL, `editedAt` timestamp NULL DEFAULT NULL, `userId` int NULL, INDEX `IDX_43d1a6df29f544bdc57ab4cdc6` (`hash`), UNIQUE INDEX `IDX_491fe52f7ce0f0696fc0b70e7f` (`hash`, `size`, `userId`), PRIMARY KEY (`id`)) ENGINE=InnoDB",
);
await queryRunner.query(
"ALTER TABLE `photo` ADD CONSTRAINT `FK_4494006ff358f754d07df5ccc87` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE NO ACTION 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(
"DROP INDEX `IDX_491fe52f7ce0f0696fc0b70e7f` ON `photo`",
);
await queryRunner.query(
"DROP INDEX `IDX_43d1a6df29f544bdc57ab4cdc6` ON `photo`",
);
await queryRunner.query("DROP TABLE `photo`");
await queryRunner.query(
"DROP INDEX `IDX_e12875dfb3b1d92d7d7c5377e2` ON `user`",
);
await queryRunner.query(
"DROP INDEX `IDX_78a916df40e02a9deb1c4b75ed` ON `user`",
);
await queryRunner.query("DROP TABLE `user`");
}
}

View 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",
);
}
}

11
backend/src/routes/dev.ts Normal file
View File

@@ -0,0 +1,11 @@
import * as Router from "@koa/router";
import { Photo } from "~entity/Photo";
import { User } from "~entity/User";
export const devRouter = new Router();
devRouter.get("/dev/clean", async (ctx) => {
await Photo.remove(await Photo.find());
await User.remove(await User.find());
ctx.body = { success: true };
});

View File

@@ -0,0 +1,416 @@
import * as Router from "@koa/router";
import { Photo } from "~entity/Photo";
import {
IPhotoReqJSON,
IPhotosNewRespBody,
IPhotosNewPostBody,
IPhotoByIDDeleteRespBody,
IPhotosUploadRespBody,
IPhotosListRespBody,
IPhotosByIDGetRespBody,
IPhotosDeleteRespBody,
IPhotosDeleteBody,
IAPIResponse, IPhotosListPagination } from "~/shared/types";
import send = require("koa-send");
import { getHash, getSize } from "~util";
import * as jwt from "jsonwebtoken";
import { config } from "~config";
import { ValidationError } from "class-validator";
import { In } from "typeorm";
export const photosRouter = new Router();
photosRouter.post("/photos/new", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
}
const { user } = ctx.state;
const body = ctx.request.body as IPhotosNewPostBody;
const { hash, size, format } = body;
if (!(hash && size && format)) {
ctx.throw(400);
return;
}
const photo = new Photo(user, hash, size, format);
try {
await photo.save();
} catch (e) {
if (e.code === "ER_DUP_ENTRY") {
const photo = await Photo.findOne({ hash, size, user });
if (!photo) {
ctx.throw(404);
return;
}
ctx.body = {
error: false,
data: await photo.toReqJSON(),
} as IPhotosNewRespBody;
return;
}
if (
e.name === "ValidationError" ||
(Array.isArray(e) && e.some((e) => e instanceof ValidationError))
) {
ctx.throw(400);
return;
}
console.log(e);
ctx.throw(500);
}
ctx.body = {
error: false,
data: await photo.toReqJSON(),
} as IPhotosNewRespBody;
});
photosRouter.post("/photos/upload/:id", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
}
const { id } = ctx.params as {
id: string | undefined;
};
if (!id) {
ctx.throw(400);
return;
}
const { user } = ctx.state;
const photo = await Photo.findOne({ id: parseInt(id), user });
if (!photo) {
ctx.throw(404);
return;
}
if (!ctx.request.files || Object.keys(ctx.request.files).length === 0) {
ctx.throw(400, "No file");
return;
}
if (photo.uploaded) {
ctx.throw(400, "Already uploaded");
return;
}
if (ctx.request.files) {
const files = ctx.request.files;
if (Object.keys(files).length > 1) {
ctx.throw(400, "Too many files");
return;
}
const file = Object.values(files)[0];
if (Array.isArray(file)) {
throw "more than one file uploaded";
}
const photoHash = await getHash(file.filepath);
const photoSize = await getSize(file.filepath);
if (photoHash !== photo.hash || photoSize !== photo.size) {
ctx.throw(400, "Wrong photo");
return;
}
try {
// TODO: actually move file if it's on different filesystems
await photo.processUpload(file.filepath);
} catch (e) {
console.log(e);
ctx.throw(500);
}
}
ctx.body = {
error: false,
data: await photo.toReqJSON(),
} as IPhotosUploadRespBody;
});
/**
export interface IPhotosByIDPatchBody {
}
export type IPhotosByIDPatchRespBody = IAPIResponse<IPhotoReqJSON>;
photosRouter.patch("/photos/byID/:id", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
return;
}
const { user } = ctx.state;
const { id } = ctx.params as {
id: number | undefined;
};
if (!id) {
ctx.throw(400);
return;
}
const photo = await Photo.findOne({ id, user });
if (!photo) {
ctx.throw(404);
return;
}
// TODO: Some actual editing
try {
photo.editedAt = new Date();
await photo.save();
} catch (e) {
ctx.throw(400);
}
ctx.body = {
error: false,
data: photo.toReqJSON(),
};
});
*/
photosRouter.get("/photos/list", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
}
const { user } = ctx.state;
let { skip, num } = ctx.request.query as {
skip: string | number | undefined;
num: string | number | undefined;
};
if (typeof num === "string") {
num = parseInt(num);
}
if (typeof skip === "string") {
skip = parseInt(skip);
}
if (!num || num > IPhotosListPagination) {
num = IPhotosListPagination;
}
const photos = await Photo.find({
where: { user },
take: num,
skip: skip,
order: { shotAt: "DESC" },
});
const photosList: IPhotoReqJSON[] = await Promise.all(
photos.map(async (photo) => await photo.toReqJSON()),
);
ctx.body = {
error: false,
data: photosList,
} as IPhotosListRespBody;
});
photosRouter.get("/photos/byID/:id", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
}
const { id } = ctx.params as {
id: string | undefined;
};
if (!id) {
ctx.throw(400);
return;
}
const { user } = ctx.state;
const photo = await Photo.findOne({ id: parseInt(id), user });
if (!photo) {
ctx.throw(404);
return;
}
ctx.body = {
error: false,
data: await photo.toReqJSON(),
} as IPhotosByIDGetRespBody;
});
photosRouter.get("/photos/showByID/:id/:token", async (ctx) => {
const { id, token } = ctx.params as {
id: string | undefined;
token: string | undefined;
};
if (!(id && token)) {
ctx.throw(400);
return;
}
try {
jwt.verify(token, config.jwtSecret);
} catch (e) {
ctx.throw(401);
}
const photoReqJSON = jwt.decode(token) as IPhotoReqJSON;
const { user } = photoReqJSON;
const photo = await Photo.findOne({
id: parseInt(id),
user: { id: user },
});
if (!photo) {
ctx.throw(404);
return;
}
if (
ctx.request.query["size"] &&
typeof ctx.request.query["size"] == "string"
) {
const size = ctx.request.query["size"];
await send(ctx, await photo.getReadyPath(size));
return;
}
await send(ctx, await photo.getReadyPath("original"));
});
photosRouter.get("/photos/showByID/:id", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
}
const { id } = ctx.params as {
id: string | undefined;
};
if (!id) {
ctx.throw(400);
return;
}
const { user } = ctx.state;
const photo = await Photo.findOne({ id: parseInt(id), user });
if (!photo) {
ctx.throw(404);
return;
}
if (
ctx.request.query["size"] &&
typeof ctx.request.query["size"] == "string"
) {
const size = ctx.request.query["size"];
await send(ctx, await photo.getReadyPath(size));
return;
}
await send(ctx, await photo.getReadyPath("original"));
});
export type IPhotoShowToken = string;
export type IPhotosGetShowTokenByID = IAPIResponse<IPhotoShowToken>;
photosRouter.get("/photos/getShowByIDToken/:id", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
}
const { id } = ctx.params as {
id: string | undefined;
};
if (!id) {
ctx.throw(400);
return;
}
const { user } = ctx.state;
const photo = await Photo.findOne({ id: parseInt(id), user });
if (!photo) {
ctx.throw(404);
return;
}
const token = await photo.getJWTToken();
ctx.body = { error: false, data: token } as IPhotosGetShowTokenByID;
});
photosRouter.delete("/photos/byID/:id", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
}
const { id } = ctx.params as {
id: string | undefined;
};
if (!id) {
ctx.throw(400);
return;
}
const { user } = ctx.state;
const photo = await Photo.findOne({ id: parseInt(id), user });
if (!photo) {
ctx.throw(404);
return;
}
await photo.remove();
ctx.body = {
error: false,
data: true,
} as IPhotoByIDDeleteRespBody;
});
photosRouter.post("/photos/delete", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
}
const body = ctx.request.body as IPhotosDeleteBody;
const { photos } = body;
if (!photos || !Array.isArray(photos) || photos.length == 0) {
ctx.throw(400);
return;
}
const { user } = ctx.state;
try {
await Photo.delete({
id: In(photos.map((photo) => photo.id)),
user,
});
ctx.body = {
error: false,
data: true,
} as IPhotosDeleteRespBody;
} catch (e) {
ctx.body = {
data: null,
error: "Internal server error",
} as IPhotosDeleteRespBody;
}
});

133
backend/src/routes/users.ts Normal file
View File

@@ -0,0 +1,133 @@
import * as Router from "@koa/router";
import { getConfigValue, ConfigKey } from "~entity/Config";
import { User } from "~entity/User";
import {
IUserJWT,
IUserGetRespBody,
IUserEditRespBody,
IUserSignupBody,
IUserSignupRespBody,
IUserLoginRespBody,
IUserEditBody,
IUserLoginBody,
} from "~/shared/types";
export const userRouter = new Router();
userRouter.get("/users/user", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
}
const jwt = ctx.state.user as IUserJWT;
const user = await User.findOne(jwt.id);
if (!user) {
ctx.throw(401);
return;
}
ctx.body = { error: false, data: user.toAuthJSON() } as IUserGetRespBody;
});
userRouter.post("/users/login", async (ctx) => {
const request = ctx.request;
if (!request.body) {
ctx.throw(400);
}
const { username, password } = request.body as IUserLoginBody;
if (!(username && password)) {
ctx.throw(400);
return;
}
const user = await User.findOne({ username });
if (!user || !(await user.verifyPassword(password))) {
ctx.throw(404, "User not found");
return;
}
ctx.body = { error: false, data: user.toAuthJSON() } as IUserLoginRespBody;
});
userRouter.post("/users/signup", async (ctx) => {
const request = ctx.request;
if (!request.body) {
ctx.throw(400);
}
const { username, password, email } = request.body as IUserSignupBody;
if (!(username && password && email)) {
ctx.throw(400);
return;
}
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 {
await user.save();
} catch (e) {
if (e.code === "ER_DUP_ENTRY") {
ctx.throw(400, "User already exists");
}
console.log(e);
ctx.throw(500);
}
ctx.body = { error: false, data: user.toAuthJSON() } as IUserSignupRespBody;
});
userRouter.post("/users/edit", async (ctx) => {
if (!ctx.state.user) {
ctx.throw(401);
}
const jwt = ctx.state.user as IUserJWT;
const user = await User.findOne(jwt.id);
const request = ctx.request;
if (!user) {
ctx.throw(401);
return;
}
if (!request.body) {
ctx.throw(400);
return;
}
const { password } = request.body as IUserEditBody;
if (!password) {
ctx.throw(400);
return;
}
await user.setPassword(password);
try {
await user.save();
} catch (e) {
console.log(e);
ctx.throw(500);
}
ctx.body = { error: false, data: user.toAuthJSON() } as IUserEditRespBody;
});

56
backend/src/server.ts Normal file
View File

@@ -0,0 +1,56 @@
import { Connection } from "typeorm";
import { Config, ConfigKey, setConfigValue } from "~entity/Config";
import { app } from "~app";
import { config } from "./config";
import { connect } from "~config/database";
async function readConfig() {
if (process.env.SIGNUP_ALLOWED) {
if (process.env.SIGNUP_ALLOWED === "yes") {
await setConfigValue(ConfigKey.signupAllowed, "yes");
console.log("Signups enabled");
} else if (process.env.SIGNUP_ALLOWED === "no") {
await setConfigValue(ConfigKey.signupAllowed, "no");
console.log("Signups disabled");
} else {
await setConfigValue(ConfigKey.signupAllowed, "no");
console.log("Signups disabled");
}
}
}
async function dumpConfig() {
console.log("Running with config:");
//TODO: not print sensitive values
for (const entry of await Config.find()) {
console.log(`${entry.key} = ${entry.value}`);
}
}
async function migrate(connection: Connection) {
await connection.runMigrations();
console.log("Migrations ran");
}
async function startApp() {
app.listen(config.port);
console.log(`Listening at ${config.port}`);
}
connect()
.then((connection) => {
console.log(`Connected to ${connection.name}`);
migrate(connection)
.then(() =>
readConfig()
.then(() =>
dumpConfig()
.then(() => startApp())
.catch((e) => console.log(e)),
)
.catch((e) => console.log(e)),
)
.catch((e) => console.log(e));
})
.catch((e) => console.log(e));

1
backend/src/shared Symbolic link
View File

@@ -0,0 +1 @@
../../shared

View File

@@ -0,0 +1,758 @@
import { assert, expect } from "chai";
import { connect } from "config/database";
import * as request from "supertest";
import { getConnection } from "typeorm";
import { app } from "~app";
import { Photo } from "~entity/Photo";
import { IPhotoReqJSON ,
IPhotosDeleteBody,
IPhotosListRespBody,
IPhotosNewPostBody,
} from "~shared/types";
import * as fs from "fs/promises";
import { constants as fsConstants } from "fs";
import * as jwt from "jsonwebtoken";
import {
catFileSize,
catPath,
dogFileSize,
dogFormat,
dogHash,
dogPath,
dogSize,
ISeed,
pngFileSize,
pngFormat,
pngHash,
pngPath,
pngSize,
prepareMetadata,
seedDB,
} from "./util";
import { config } from "~config";
const callback = app.callback();
let seed: ISeed;
describe("photos", function () {
before(async function () {
await connect();
await prepareMetadata();
});
after(async function () {
await getConnection().close();
});
beforeEach(async function () {
seed = await seedDB();
});
it("should get a photo", async function () {
const response = await request(callback)
.get(`/photos/byID/${seed.dogPhoto.id}`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
})
.expect(200);
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoReqJSON;
const usedPhoto = await seed.dogPhoto.toReqJSON();
expect(photo).to.deep.equal(usedPhoto);
});
it("should not get a photo without jwt", async function () {
const response = await request(callback)
.get(`/photos/byID/${seed.dogPhoto.id}`)
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
})
.expect(404);
expect(response.body.error).to.be.equal("Not Found");
});
it("should show a photo", async function () {
const response = await request(callback)
.get(`/photos/showByID/${seed.dogPhoto.id}`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
})
.expect(200);
expect(parseInt(response.header["content-length"])).to.equal(
dogFileSize,
);
});
it("should delete a photo after file has been deleted", async function () {
const response = await request(callback)
.get(`/photos/showByID/${seed.dogPhoto.id}`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
})
.expect(200);
expect(parseInt(response.header["content-length"])).to.equal(
dogFileSize,
);
await fs.unlink(await seed.dogPhoto.getReadyPath("original"));
const response2 = await request(callback)
.get(`/photos/showByID/${seed.dogPhoto.id}`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
})
.expect(500);
const dbPhoto = await Photo.findOne(seed.dogPhoto.id);
expect(dbPhoto).to.be.undefined;
});
it("should delete a photo after file has been deleted (thumbnail access)", async function () {
const response = await request(callback)
.get(`/photos/showByID/${seed.dogPhoto.id}?size=512`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
})
.expect(200);
const dogSmallThumbSize = (
await fs.stat(seed.dogPhoto.getThumbPath("512"))
).size;
expect(parseInt(response.header["content-length"])).to.equal(
dogSmallThumbSize,
);
await fs.unlink(await seed.dogPhoto.getReadyPath("512"));
await fs.unlink(await seed.dogPhoto.getReadyPath("original"));
const response2 = await request(callback)
.get(`/photos/showByID/${seed.dogPhoto.id}?size=512`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
})
.expect(500);
const dbPhoto = await Photo.findOne(seed.dogPhoto.id);
expect(dbPhoto).to.be.undefined;
});
it("should show a thumbnail", async function () {
const response = await request(callback)
.get(`/photos/showByID/${seed.dogPhoto.id}?size=512`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
})
.expect(200);
expect(parseInt(response.header["content-length"])).to.be.lessThan(
dogFileSize,
);
});
it("should show a thumbnail after it was deleted", async function () {
const response = await request(callback)
.get(`/photos/showByID/${seed.dogPhoto.id}?size=512`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
})
.expect(200);
const dogSmallThumbSize = (
await fs.stat(seed.dogPhoto.getThumbPath("512"))
).size;
expect(parseInt(response.header["content-length"])).to.equal(
dogSmallThumbSize,
);
await fs.unlink(seed.dogPhoto.getThumbPath("512"));
const response2 = await request(callback)
.get(`/photos/showByID/${seed.dogPhoto.id}?size=512`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
})
.expect(200);
const dogSmallThumbSize2 = (
await fs.stat(seed.dogPhoto.getThumbPath("512"))
).size;
expect(parseInt(response.header["content-length"])).to.equal(
dogSmallThumbSize2,
);
});
it("should show a photo using access token", async function () {
const listResp = await request(callback)
.get(`/photos/list`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
})
.expect(200);
const listRespBody = listResp.body as IPhotosListRespBody;
if (listRespBody.error !== false) {
expect(listResp.body.error).to.be.false;
return;
}
const photos = listRespBody.data;
expect(photos.length).to.be.equal(2);
const listAnyResp = await request(callback)
.get(`/photos/showByID/${photos[0].id}/${photos[0].accessToken}`)
.expect(200);
expect(parseInt(listAnyResp.header["content-length"])).to.be.oneOf([
dogFileSize,
catFileSize,
]);
const getTokenResp = await request(callback)
.get(`/photos/getShowByIDToken/${seed.dogPhoto.id}`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
})
.expect(200);
expect(getTokenResp.body.error).to.be.false;
const token = getTokenResp.body.data as string;
const response = await request(callback)
.get(`/photos/showByID/${seed.dogPhoto.id}/${token}`)
.expect(200);
expect(parseInt(response.header["content-length"])).to.equal(
dogFileSize,
);
const tokenSelfSigned = jwt.sign(
await seed.dogPhoto.toReqJSON(),
config.jwtSecret,
{
expiresIn: "1m",
},
);
const responseSS = await request(callback)
.get(`/photos/showByID/${seed.dogPhoto.id}/${tokenSelfSigned}`)
.expect(200);
expect(parseInt(responseSS.header["content-length"])).to.equal(
dogFileSize,
);
});
it("should not show a photo using expired access token", async function () {
const token = jwt.sign(
await seed.dogPhoto.toReqJSON(),
config.jwtSecret,
{
expiresIn: "0s",
},
);
const response = await request(callback)
.get(`/photos/showByID/${seed.dogPhoto.id}/${token}`)
.expect(401);
});
it("should not show a photo without jwt", async function () {
const response = await request(callback)
.get(`/photos/byID/${seed.dogPhoto.id}`)
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
})
.expect(404);
expect(response.body.error).to.be.equal("Not Found");
});
it("should create, upload and show a photo with a shot date", async function () {
const response = await request(callback)
.post("/photos/new")
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.send({
hash: dogHash,
size: dogSize,
format: dogFormat,
} as IPhotosNewPostBody)
.expect(200);
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoReqJSON;
expect(photo.hash).to.be.equal(dogHash);
const dbPhoto = await Photo.findOneOrFail({
id: photo.id,
user: seed.user1.id as any,
});
expect(dbPhoto.hash).to.be.equal(dogHash);
expect(await dbPhoto.origFileExists()).to.be.equal(false);
await request(callback)
.post(`/photos/upload/${photo.id}`)
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.attach("photo", dogPath)
.expect(200);
const dbPhotoUpl = await Photo.findOneOrFail({
id: photo.id,
user: seed.user1.id as any,
});
expect(dbPhotoUpl.hash).to.be.equal(dogHash);
expect(await dbPhotoUpl.origFileExists()).to.be.equal(true);
expect(dbPhotoUpl.shotAt.toISOString()).to.be.equal(
new Date("2020-10-05T14:20:18").toISOString(),
);
const showResp = await request(callback)
.get(`/photos/showByID/${photo.id}`)
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
})
.expect(200);
expect(parseInt(showResp.header["content-length"])).to.equal(
dogFileSize,
);
});
it("should create, upload and show a png file", async function () {
const response = await request(callback)
.post("/photos/new")
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.send({
hash: pngHash,
size: pngSize,
format: pngFormat,
} as IPhotosNewPostBody)
.expect(200);
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoReqJSON;
expect(photo.hash).to.be.equal(pngHash);
const dbPhoto = await Photo.findOneOrFail({
id: photo.id,
user: seed.user1.id as any,
});
expect(dbPhoto.hash).to.be.equal(pngHash);
expect(await dbPhoto.origFileExists()).to.be.equal(false);
await request(callback)
.post(`/photos/upload/${photo.id}`)
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.attach("photo", pngPath)
.expect(200);
const dbPhotoUpl = await Photo.findOneOrFail({
id: photo.id,
user: seed.user1.id as any,
});
expect(dbPhotoUpl.hash).to.be.equal(pngHash);
expect(dbPhotoUpl.format).to.be.equal(pngFormat);
expect(await dbPhotoUpl.origFileExists()).to.be.equal(true);
expect(dbPhotoUpl.shotAt.getTime()).to.be.approximately(
new Date().getTime(),
10000,
);
const showResp = await request(callback)
.get(`/photos/showByID/${photo.id}`)
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
})
.expect(200);
expect(parseInt(showResp.header["content-length"])).to.equal(
pngFileSize,
);
});
it("should not create a photo twice", async function () {
const response = await request(callback)
.post("/photos/new")
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.send({
hash: dogHash,
size: dogSize,
format: dogFormat,
} as IPhotosNewPostBody)
.expect(200);
expect(response.body.error).to.be.false;
const response2 = await request(callback)
.post("/photos/new")
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.send({
hash: dogHash,
size: dogSize,
format: dogFormat,
} as IPhotosNewPostBody)
.expect(200);
const dbPhoto = await Photo.find({
hash: dogHash,
size: dogSize,
user: { id: seed.user1.id },
});
expect(dbPhoto).to.have.lengthOf(1);
});
it("should not upload a photo twice", async function () {
const response = await request(callback)
.post("/photos/new")
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.send({
hash: dogHash,
size: dogSize,
format: dogFormat,
} as IPhotosNewPostBody)
.expect(200);
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoReqJSON;
expect(photo.hash).to.be.equal(dogHash);
const dbPhoto = await Photo.findOneOrFail({
id: photo.id,
user: seed.user1.id as any,
});
expect(dbPhoto.hash).to.be.equal(dogHash);
expect(await dbPhoto.origFileExists()).to.be.equal(false);
await request(callback)
.post(`/photos/upload/${photo.id}`)
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.attach("photo", dogPath)
.expect(200);
expect(await dbPhoto.origFileExists()).to.be.equal(true);
await request(callback)
.post(`/photos/upload/${photo.id}`)
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.attach("photo", dogPath)
.expect(400);
const showResp = await request(callback)
.get(`/photos/showByID/${photo.id}`)
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
})
.expect(200);
expect(parseInt(showResp.header["content-length"])).to.equal(
dogFileSize,
);
});
it("should not upload a wrong photo", async function () {
const response = await request(callback)
.post("/photos/new")
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.send({
hash: dogHash,
size: dogSize,
format: dogFormat,
} as IPhotosNewPostBody)
.expect(200);
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoReqJSON;
expect(photo.hash).to.be.equal(dogHash);
const dbPhoto = await Photo.findOneOrFail({
id: photo.id,
user: seed.user1.id as any,
});
expect(dbPhoto.hash).to.be.equal(dogHash);
expect(await dbPhoto.origFileExists()).to.be.equal(false);
await request(callback)
.post(`/photos/upload/${photo.id}`)
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.attach("photo", catPath)
.expect(400);
expect(await dbPhoto.origFileExists()).to.be.equal(false);
const showResp = await request(callback)
.get(`/photos/showByID/${photo.id}`)
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
})
.expect(500);
});
it("should create a photo but not upload for other user", async function () {
const response = await request(callback)
.post("/photos/new")
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.send({
hash: dogHash,
size: dogSize,
format: dogFormat,
} as IPhotosNewPostBody)
.expect(200);
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoReqJSON;
expect(photo.hash).to.be.equal(dogHash);
const dbPhoto = await Photo.findOneOrFail({
id: photo.id,
user: seed.user1.id as any,
});
expect(dbPhoto.hash).to.be.equal(dogHash);
expect(await dbPhoto.origFileExists()).to.be.equal(false);
await request(callback)
.post(`/photos/upload/${photo.id}`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
"Content-Type": "application/json",
})
.attach("photo", dogPath)
.expect(404);
expect(await dbPhoto.origFileExists()).to.be.equal(false);
});
it("should create, upload but not show a photo to another user", async function () {
const response = await request(callback)
.post("/photos/new")
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.send({
hash: dogHash,
size: dogSize,
format: dogFormat,
} as IPhotosNewPostBody)
.expect(200);
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoReqJSON;
expect(photo.hash).to.be.equal(dogHash);
const dbPhoto = await Photo.findOneOrFail({
id: photo.id,
user: seed.user1.id as any,
});
expect(dbPhoto.hash).to.be.equal(dogHash);
expect(await dbPhoto.origFileExists()).to.be.equal(false);
await request(callback)
.post(`/photos/upload/${photo.id}`)
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.attach("photo", dogPath)
.expect(200);
expect(await dbPhoto.origFileExists()).to.be.equal(true);
await request(callback)
.get(`/photos/showByID/${photo.id}`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
})
.expect(404);
});
it("should not create a photo with weird properties", async function () {
const response = await request(callback)
.post("/photos/new")
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.send({
hash: "../test",
size: "33333",
format: dogFormat,
} as IPhotosNewPostBody)
.expect(400);
});
/*
it("should update a photo", async function () {
const response = await request(callback)
.patch(`/photos/byID/${seed.dogPhoto.id}`)
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.send({ name: "Test1", content: "Test1" })
.expect(200);
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoReqJSON;
expect(photo.name).to.be.equal("Test1");
const dbPhoto = await Photo.findOne({
id: seed.dogPhoto.id,
user: seed.user1.id as any,
});
expect(dbPhoto.name).to.be.equal("Test1");
expect(dbPhoto.editedAt.getTime()).to.be.closeTo(
new Date().getTime(),
2000,
);
});
*/
it("should list photos, sorted", async function () {
const response = await request(callback)
.get("/photos/list")
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
})
.expect(200);
expect(response.body.error).to.be.false;
const photos = response.body.data as IPhotoReqJSON[];
const userPhotos = [
await seed.dogPhoto.toReqJSON(),
await seed.catPhoto.toReqJSON(),
].sort((a, b) => b.shotAt - a.shotAt);
const photoIds = photos.map((p) => p.id);
const userPhotoIds = userPhotos.map((p) => p.id);
expect(photos).to.deep.equal(userPhotos);
expect(photoIds).to.have.ordered.members(userPhotoIds);
//TODO: Test pagination
});
/*
it("should get a shared photo", async function () {
const response = await request(callback)
.get(`/photos/shared/${seed.user1.username}/${seed.catPhoto.id}`)
.expect(200);
expect(response.body.error).to.be.false;
const photo = response.body.data as IPhotoReqJSON;
const usedPhoto = seed.catPhoto.toReqJSON();
expect(photo).to.deep.equal(usedPhoto);
});
*/
it("should delete a photo", async function () {
const photoPath = seed.dogPhoto.getPath();
const photoSmallThumbPath = await seed.dogPhoto.getReadyPath("512");
const response = await request(callback)
.post(`/photos/delete`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
"Content-Type": "application/json",
})
.send({
photos: [await seed.dogPhoto.toReqJSON()],
} as IPhotosDeleteBody)
.expect(200);
expect(response.body.error).to.be.false;
const dbPhoto = await Photo.findOne(seed.dogPhoto.id);
expect(dbPhoto).to.be.undefined;
try {
await fs.access(photoPath, fsConstants.F_OK);
await fs.access(photoSmallThumbPath, fsConstants.F_OK);
assert(false);
} catch (e) {
assert(true);
}
});
it("should delete two photos", async function () {
const photo1Path = seed.dogPhoto.getPath();
const photo2Path = seed.catPhoto.getPath();
const photo1SmallThumbPath = await seed.dogPhoto.getReadyPath("512");
const photo2SmallThumbPath = await seed.catPhoto.getReadyPath("512");
const response = await request(callback)
.post(`/photos/delete`)
.set({
Authorization: `Bearer ${seed.user2.toJWT()}`,
"Content-Type": "application/json",
})
.send({
photos: [
await seed.dogPhoto.toReqJSON(),
await seed.catPhoto.toReqJSON(),
],
} as IPhotosDeleteBody)
.expect(200);
expect(response.body.error).to.be.false;
const dbPhoto1 = await Photo.findOne(seed.dogPhoto.id);
expect(dbPhoto1).to.be.undefined;
const dbPhoto2 = await Photo.findOne(seed.catPhoto.id);
expect(dbPhoto2).to.be.undefined;
try {
await fs.access(photo1Path, fsConstants.F_OK);
await fs.access(photo1SmallThumbPath, fsConstants.F_OK);
await fs.access(photo2Path, fsConstants.F_OK);
await fs.access(photo2SmallThumbPath, fsConstants.F_OK);
assert(false);
} catch (e) {
assert(true);
}
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

@@ -0,0 +1,297 @@
import { assert, expect } from "chai";
import { connect } from "config/database";
import * as request from "supertest";
import { getConnection } from "typeorm";
import { app } from "~app";
import { User } from "~entity/User";
import {
IUserEditBody,
IUserEditRespBody,
IUserGetRespBody,
IUserLoginBody,
IUserLoginRespBody,
IUserSignupBody,
IUserSignupRespBody,
} from "~shared/types";
import { allowSignups, ISeed, seedDB } from "./util";
const callback = app.callback();
let seed: ISeed;
describe("users", function () {
before(async function () {
await connect();
});
after(async function () {
await getConnection().close();
});
beforeEach(async function () {
seed = await seedDB();
});
it("should get user", async function () {
const response = await request(callback)
.get("/users/user")
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.expect("Content-Type", /json/)
.expect(200);
const body = response.body as IUserGetRespBody;
if (body.error !== false) {
assert(false);
return;
}
const { jwt: _, ...user } = body.data;
expect(user).to.deep.equal(seed.user1.toJSON());
});
it("should login user", async function () {
const response = await request(callback)
.post("/users/login")
.set({ "Content-Type": "application/json" })
.send({ username: "User1", password: "User1" } as IUserLoginBody)
.expect("Content-Type", /json/)
.expect(200);
const body = response.body as IUserLoginRespBody;
if (body.error !== false) {
assert(false);
return;
}
const { jwt: _, ...user } = response.body.data;
expect(user).to.deep.equal(seed.user1.toJSON());
});
it("should not login user with wrong password", async function () {
const response = await request(callback)
.post("/users/login")
.set({ "Content-Type": "application/json" })
.send({ username: "User1", password: "asdf" } as IUserLoginBody)
.expect(404);
const body = response.body as IUserLoginRespBody;
expect(body.error).to.be.equal("User not found");
expect(body.data).to.be.false;
});
it("should signup user", async function () {
await allowSignups();
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());
});
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" })
.send({
username: "User1",
password: "NUser1",
email: "user1@users.com",
} as IUserSignupBody)
.expect(400);
const body = response.body as IUserSignupRespBody;
expect(body.error).to.be.equal("User already exists");
expect(body.data).to.be.false;
});
it("should change user's password", async function () {
const response = await request(callback)
.post("/users/edit")
.set({
Authorization: `Bearer ${seed.user1.toJWT()}`,
"Content-Type": "application/json",
})
.send({
password: "User1NewPass",
} as IUserEditBody)
.expect("Content-Type", /json/)
.expect(200);
const body = response.body as IUserEditRespBody;
if (body.error !== false) {
assert(false);
return;
}
const loginResponse = await request(callback)
.post("/users/login")
.set({ "Content-Type": "application/json" })
.send({
username: "User1",
password: "User1NewPass",
} as IUserLoginBody)
.expect("Content-Type", /json/)
.expect(200);
const loginBody = loginResponse.body as IUserLoginRespBody;
if (loginBody.error !== false) {
assert(false);
return;
}
const { jwt: _, ...user } = loginBody.data;
expect(user).to.deep.equal(seed.user1.toJSON());
const badLoginResponse = await request(callback)
.post("/users/login")
.set({ "Content-Type": "application/json" })
.send({ username: "User1", password: "User1" } as IUserLoginBody)
.expect(404);
const badLoginBody = badLoginResponse.body as IUserLoginRespBody;
expect(badLoginBody.error).to.be.equal("User not found");
expect(badLoginBody.data).to.be.false;
});
});

View File

@@ -0,0 +1,76 @@
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";
export const dogPath = "./src/tests/integration/photos/dog.jpg";
export const catPath = "./src/tests/integration/photos/cat.jpg";
export const pngPath = "./src/tests/integration/photos/ee.png";
export interface ISeed {
user1: User;
user2: User;
dogPhoto: Photo;
catPhoto: Photo;
}
export let dogHash = "";
export let dogSize = "";
export let dogFileSize = 0;
export const dogFormat = "image/jpeg";
export let catHash = "";
export let catSize = "";
export let catFileSize = 0;
export const catFormat = "image/jpeg";
export let pngHash = "";
export let pngSize = "";
export let pngFileSize = 0;
export const pngFormat = "image/png";
export async function prepareMetadata(): Promise<void> {
dogHash = await getHash(dogPath);
dogSize = await getSize(dogPath);
dogFileSize = (await fs.stat(dogPath)).size;
catHash = await getHash(catPath);
catSize = await getSize(catPath);
catFileSize = (await fs.stat(catPath)).size;
pngHash = await getHash(pngPath);
pngSize = await getSize(pngPath);
pngFileSize = (await fs.stat(pngPath)).size;
}
export async function seedDB(): Promise<ISeed> {
dogHash = await getHash(dogPath);
dogSize = await getSize(dogPath);
catHash = await getHash(catPath);
catSize = await getSize(catPath);
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");
await user1.save();
const user2 = new User("User2", "user2@users.com");
await user2.setPassword("User2");
await user2.save();
const dogPhoto = new Photo(user2, dogHash, dogSize, dogFormat);
const catPhoto = new Photo(user2, catHash, catSize, catFormat);
await fs.copyFile(dogPath, dogPhoto.getPath());
await fs.copyFile(catPath, catPhoto.getPath());
await dogPhoto.save();
await catPhoto.save();
return { user1, user2, dogPhoto, catPhoto };
}
export async function allowSignups(): Promise<void> {
await setConfigValue(ConfigKey.signupAllowed, "yes");
}

73
backend/src/util.ts Normal file
View File

@@ -0,0 +1,73 @@
import { fromFile } from "hasha";
import * as ExifReader from "exifreader";
import * as sharp from "sharp";
import * as fs from "fs/promises";
import { constants as fsConstants } from "fs";
export async function getHash(file: string): Promise<string> {
return await fromFile(file, {
algorithm: "md5",
});
}
export async function getSize(file: string): Promise<string> {
const metadata = await sharp(file).metadata();
if (!(metadata.width && metadata.height)) {
throw new Error(
`The ${file} doesn't have width and height... how did we get there?`,
);
}
const orientation = metadata.orientation ? metadata.orientation : 1;
return orientation <= 4
? `${metadata.width}x${metadata.height}`
: `${metadata.height}x${metadata.width}`;
}
export async function getShotDate(file: string): Promise<Date | null> {
const tags = ExifReader.load(await fs.readFile(file));
if (!tags || !tags["DateTimeOriginal"]) {
return null;
}
const imageDate = tags["DateTimeOriginal"].description;
if (!imageDate) {
return null;
}
const dateStr = imageDate.split(" ")[0].replace(/:/g, "-");
const date = new Date(dateStr + "T" + imageDate.split(" ")[1]);
return date;
}
export async function resizeToJpeg(
inPath: string,
outPath: string,
size: number,
): Promise<void> {
const file = sharp(inPath);
const metadata = await file.metadata();
if (!(metadata.width && metadata.height)) {
throw new Error(
`The ${inPath} doesn't have width and height... how did we get there?`,
);
}
const wider = metadata.width > metadata.height;
const ratio = wider
? metadata.height / metadata.width
: metadata.width / metadata.height;
const newWidth = Math.floor(wider ? size : size * ratio);
const newHeight = Math.floor(wider ? size * ratio : size);
await sharp(inPath)
.resize(newWidth, newHeight)
.withMetadata()
.jpeg({ progressive: true })
.toFile(outPath);
}
export async function fileCheck(file: string) {
try {
await fs.access(file, fsConstants.F_OK);
return true;
} catch (e) {
return false;
}
}

31
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"lib": [
"es2017"
],
"target": "es2017",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "./dist",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"noImplicitAny": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"skipLibCheck": true,
"baseUrl": "./src",
"paths": {
"~*": [
"./*"
]
}
},
"include": [
"./src/**/*.ts",
"./tests/**/*.ts",
],
"exclude": [
"frontend"
]
}