mirror of
https://github.com/usatiuk/photos.git
synced 2025-10-28 15:27:49 +01:00
some cleanup, split backend and frontend
This commit is contained in:
48
backend/.eslintrc.json
Normal file
48
backend/.eslintrc.json
Normal 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
17
backend/.gitignore
vendored
Normal 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
5
backend/.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 4,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
1
backend/insomnia.json
Normal file
1
backend/insomnia.json
Normal file
File diff suppressed because one or more lines are too long
6
backend/mocha.json
Executable file
6
backend/mocha.json
Executable file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"reporterEnabled": "spec, mocha-junit-reporter",
|
||||
"mochaJunitReporterReporterOptions": {
|
||||
"mochaFile":"backend-report.xml"
|
||||
}
|
||||
}
|
||||
25
backend/ormconfig.ci.json
Normal file
25
backend/ormconfig.ci.json
Normal 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"
|
||||
}
|
||||
19
backend/ormconfig.dockerdevexample.json
Normal file
19
backend/ormconfig.dockerdevexample.json
Normal 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"
|
||||
}
|
||||
19
backend/ormconfig.dockerdevexample.test.json
Normal file
19
backend/ormconfig.dockerdevexample.test.json
Normal 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"
|
||||
}
|
||||
25
backend/ormconfig.example.json
Normal file
25
backend/ormconfig.example.json
Normal 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
12695
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
86
backend/package.json
Executable file
86
backend/package.json
Executable 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
99
backend/src/app.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
10
backend/src/config/database.ts
Normal file
10
backend/src/config/database.ts
Normal 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();
|
||||
}
|
||||
93
backend/src/config/index.ts
Normal file
93
backend/src/config/index.ts
Normal 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 };
|
||||
77
backend/src/entity/Config.ts
Normal file
77
backend/src/entity/Config.ts
Normal 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
239
backend/src/entity/Photo.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
99
backend/src/entity/User.ts
Normal file
99
backend/src/entity/User.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
37
backend/src/migration/1603126879697-init.ts
Normal file
37
backend/src/migration/1603126879697-init.ts
Normal 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`");
|
||||
}
|
||||
}
|
||||
31
backend/src/migration/1603132068670-restrictSignups.ts
Normal file
31
backend/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",
|
||||
);
|
||||
}
|
||||
}
|
||||
11
backend/src/routes/dev.ts
Normal file
11
backend/src/routes/dev.ts
Normal 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 };
|
||||
});
|
||||
416
backend/src/routes/photos.ts
Normal file
416
backend/src/routes/photos.ts
Normal 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
133
backend/src/routes/users.ts
Normal 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
56
backend/src/server.ts
Normal 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
1
backend/src/shared
Symbolic link
@@ -0,0 +1 @@
|
||||
../../shared
|
||||
758
backend/src/tests/integration/photos.test.ts
Normal file
758
backend/src/tests/integration/photos.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
BIN
backend/src/tests/integration/photos/cat.jpg
Normal file
BIN
backend/src/tests/integration/photos/cat.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
backend/src/tests/integration/photos/dog.jpg
Normal file
BIN
backend/src/tests/integration/photos/dog.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
BIN
backend/src/tests/integration/photos/ee.png
Normal file
BIN
backend/src/tests/integration/photos/ee.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
297
backend/src/tests/integration/users.test.ts
Normal file
297
backend/src/tests/integration/users.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
76
backend/src/tests/integration/util.ts
Normal file
76
backend/src/tests/integration/util.ts
Normal 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
73
backend/src/util.ts
Normal 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
31
backend/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user