backend done

This commit is contained in:
leca 2025-01-25 16:46:31 +03:00
parent e30649ef14
commit b82c2dc1d6
18 changed files with 3937 additions and 0 deletions

9
backend/Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM node:22-bullseye
WORKDIR /opt/backend
COPY . .
RUN npm i
EXPOSE 3000
ENTRYPOINT ["npm", "run", "start"]

19
backend/ormconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"type": "postgres",
"host": "localhost",
"port": 5432,
"username": "WelBeX",
"password": "123",
"database": "blog",
"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"
}
}

3507
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
backend/package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "welbex-job-interview",
"version": "1.0.0",
"description": "task for a job interview",
"repository": {
"type": "git",
"url": "https://git.foxarmy.org/leca/welbex-job-interview"
},
"license": "WTFPL",
"author": "leca",
"type": "module",
"main": "src/index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "npx tsx --env-file=.env --watch src/index.ts",
"typeorm": "typeorm-ts-node-commonjs"
},
"dependencies": {
"bcrypt": "^5.1.1",
"bcrypt-ts": "^5.0.3",
"body-parser": "^1.20.3",
"cookie-parser": "^1.4.7",
"express": "^4.21.2",
"express-session": "^1.18.1",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"pg": "^8.13.1",
"react": "^19.0.0",
"reflect-metadata": "^0.1.13",
"typeorm": "0.3.20"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0",
"@types/express-session": "^1.18.1",
"@types/jsonwebtoken": "^9.0.8",
"@types/multer": "^1.4.12",
"@types/node": "^16.18.125",
"ts-node": "10.9.1",
"typescript": "^4.5.2"
}
}

View File

@ -0,0 +1,32 @@
import { Request, Response } from "express";
import { AppDataSource } from "../data-source";
import { Post } from "../entity/Post";
class PostController {
async create(req: Request, res: Response): Promise<void> {
const post = res.locals.post;
AppDataSource.manager.save(post);
res.status(200).send("Ok");
}
async update(req: Request, res: Response): Promise<void> {
const { postId } = req.params;
const post = res.locals.post;
AppDataSource.manager.update(Post, { id: postId }, post);
res.status(200).send("Ok");
}
async delete(req: Request, res: Response): Promise<void> {
const { postId } = req.params;
AppDataSource.manager.delete(Post, { id: postId });
res.status(200).send("Ok");
}
}
export default new PostController();

View File

@ -0,0 +1,35 @@
import { Request, Response } from "express";
import { compareSync, genSaltSync, hashSync } from "bcrypt-ts";
import jwt from 'jsonwebtoken';
import { AppDataSource } from '../data-source';
import { User } from "../entity/User";
class UserController {
async register(req: Request, res: Response): Promise<void> {
const { username, password } = req.body;
const user = new User();
user.username = username;
user.password_hash = hashSync(password, genSaltSync(10));
const userId = (await AppDataSource.manager.save(user)).id;
res.cookie("jwt", jwt.sign({ username, id: userId }, process.env.JWT_SECRET));
res.status(200).redirect("/");
}
async login(req: Request, res: Response): Promise<void> {
const { username, password } = req.body;
let savedUser = (await AppDataSource.manager.findOneBy(User, { username }));
if (!compareSync(password, savedUser.password_hash)) {
res.status(401).send("Incorrect password");
return;
}
res.cookie("jwt", jwt.sign({ username, id: savedUser.id }, process.env.JWT_SECRET));
res.status(200).redirect("/");
}
}
export default new UserController();

View File

@ -0,0 +1,18 @@
import 'reflect-metadata'
import { DataSource } from 'typeorm'
import { User } from './entity/User'
import { Post } from './entity/Post'
export const AppDataSource = new DataSource({
type: "postgres",
host: process.env.DB_HOST,
port: Number.parseInt(process.env.DB_PORT),
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
synchronize: true,
logging: false,
entities: [User, Post],
migrations: [],
subscribers: [],
});

View File

@ -0,0 +1,25 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from "typeorm"
import { User } from "./User"
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number
@Column({ nullable: true, type: "integer" })
authorId: number
@ManyToOne((type) => User)
@JoinColumn()
author: User
@Column("timestamp")
date: string
@Column("smallint")
type: number
@Column("text")
message: string
}

View File

@ -0,0 +1,19 @@
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number
@Column({
type: "varchar",
length: 32,
unique: true
})
username: string
@Column("text")
password_hash: string
}

28
backend/src/index.ts Normal file
View File

@ -0,0 +1,28 @@
import express from "express";
import { AppDataSource } from "./data-source";
import UserRouter from "./routers/user";
import bodyParser from "body-parser";
import cookieParser from 'cookie-parser';
import PostRouter from "./routers/post";
import session from "express-session";
const app = express();
app.use(express.json({limit: "200mb"}));
app.use(express.urlencoded({ extended: false, limit: "200mb", parameterLimit: 100000 }));
app.use(cookieParser());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
}));
AppDataSource.initialize().then(() => {
app.use('/api/v1/user/', UserRouter);
app.use('/api/v1/post/', PostRouter);
const port = process.env.APP_PORT;
app.listen(port, () => {
console.log(`Listening on port ${port}`)
});
});

View File

@ -0,0 +1,32 @@
import { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
import { AppDataSource } from "../data-source";
import { Post } from "../entity/Post";
import { User } from "../entity/User";
const authenticate = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const token = req.cookies.jwt;
if (!token || !jwt.verify(token, process.env.JWT_SECRET)) {
res.status(401).send("No valid JWT is present");
return;
}
next();
}
const authorizeForPost = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const { postId } = req.params;
const user = (jwt.decode(req.cookies.jwt) as jwt.JwtPayload);
const userId = user.id;
const postAuthorId = (await AppDataSource.manager.findOneBy(
Post,
{ id: Number.parseInt(postId) }
)).authorId
if (userId != postAuthorId) {
res.status(403).send("Not your post");
return;
}
next();
}
export default { authenticate, authorizeForPost };

View File

@ -0,0 +1,41 @@
import { NextFunction, Request, Response } from 'express';
import { AppDataSource } from '../data-source';
import { User } from "../entity/User";
import { Post } from "../entity/Post";
const userShouldExist = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const { username } = req.body;
if (!(await AppDataSource.manager.findOneBy(User, {
username
}))) {
res.status(404).send("User does not exist.");
return;
}
next();
}
const userShouldNotExist = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const { username } = req.body;
if (await AppDataSource.manager.findOneBy(User, {
username
})) {
res.status(409).send("Such user already exists");
return;
}
next();
}
const postShouldExist = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const postId = Number.parseInt(req.params.postId);
if (!(await AppDataSource.manager.findOneBy(Post, {id: postId}))) {
res.status(404).send("Post not found");
return;
}
next();
}
export default { userShouldExist, userShouldNotExist, postShouldExist};

View File

@ -0,0 +1,49 @@
import { NextFunction, Request, Response } from "express";
import { Post } from "../entity/Post";
import path from "path";
import fs from 'fs';
import crypto from 'crypto';
import jwt, { JwtPayload } from "jsonwebtoken";
import { AppDataSource } from "../data-source";
// Updates or creates a post and handles things like deleting old post's media
const handlePostData = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const token = req.cookies.jwt;
const user = (jwt.decode(token) as JwtPayload)
const post = new Post();
const { message } = req.body;
if (req.method == "PUT") {
// Delete old post data if it was media
const postToUpdate = (await AppDataSource.manager.findOneBy(Post, { id: Number.parseInt(req.params.postId) }));
if (postToUpdate.type == 1) {
const filename = postToUpdate.message;
fs.rmSync(`${process.env.UPLOAD_DESTINATION}/${filename}`);
}
}
if (req.file) {
const extension = path.extname(req.file.originalname).toLowerCase()
if ([".png", ".jpg", ".jpeg", ".webp", ".mp4", ".webm"].indexOf(extension) < 0) {
res.status(400).send("Unknown mime type");
return;
}
const buffer = fs.readFileSync(req.file.path);
const hash = crypto.createHash('md5');
hash.update(buffer);
const newFilename = `${hash.digest('hex')}${extension}`;
fs.renameSync(`./${req.file.path}`, `${process.env.UPLOAD_DESTINATION}/${newFilename}`)
post.message = newFilename
post.type = 1
} else {
post.type = 0;
post.message = message;
}
if (req.method == "POST") post.date = new Date().toISOString();
post.authorId = user.id;
res.locals.post = post;
next();
}
export default { handlePostData };

View File

@ -0,0 +1,23 @@
import { Router } from "express";
import PostController from "../controllers/post";
import auth from '../middlewares/auth';
import multer from 'multer';
import existance from "../middlewares/existance";
import utils from "../middlewares/utils";
const PostRouter = Router();
const upload = multer({
dest: "./temp",
limits: {
fileSize: 12 * 1024 * 1024
}
});
PostRouter.post('/create', auth.authenticate, upload.single("file"), utils.handlePostData, PostController.create);
PostRouter.put('/update/:postId', auth.authorizeForPost, existance.postShouldExist, upload.single("file"), utils.handlePostData, PostController.update);
PostRouter.delete('/delete/:postId', auth.authorizeForPost, existance.postShouldExist, PostController.delete);
export default PostRouter;

View File

@ -0,0 +1,11 @@
import { Router } from "express";
import UserController from "../controllers/user";
import existance from '../middlewares/existance';
const UserRouter = Router();
UserRouter.post('/register', existance.userShouldNotExist, UserController.register);
UserRouter.post('/login', existance.userShouldExist, UserController.login);
export default UserRouter;

16
backend/tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"lib": [
"es5",
"es6"
],
"target": "es5",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "./build",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"esModuleInterop": true
}
}

22
docker-compose.yml Normal file
View File

@ -0,0 +1,22 @@
services:
database:
image: 'postgres:15'
env_file: .env
volumes:
- ./data/db:/var/lib/postgresql/data
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: ${DB_NAME}
healthcheck:
test: pg_isready -U $${DB_USER} -d $${DB_NAME}
backend:
build: backend
env_file: .env
ports:
- 8080:${APP_PORT}
depends_on:
- database
restart: on-failure
volumes:
- ./data/files:${PWD}/${UPLOAD_DESTINATION}

9
sample.env Normal file
View File

@ -0,0 +1,9 @@
APP_PORT=3000
DB_HOST=database
DB_PORT=5432
DB_USER=WelbeX
DB_PASS=123
DB_NAME=blog
JWT_SECRET=SUP3RS3CR37
SESSION_SECRET=SUP3RS3CR37
UPLOAD_DESTINATION=./files