feat: initial commit
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
uploads
|
||||||
|
.git
|
||||||
|
.idea
|
||||||
|
test
|
||||||
|
*.log
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
.env
|
||||||
24
.env.example
Normal file
24
.env.example
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Do not touch this port - it is local, if you want to change port - change it in docker-compose.yml
|
||||||
|
PORT=3000
|
||||||
|
# Replace with `production`
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
DB_HOST=IP-OR-DOMAIN
|
||||||
|
DB_PORT=DATABASE_PORT
|
||||||
|
|
||||||
|
DB_NAME=DATABASE_NAME
|
||||||
|
|
||||||
|
DB_USER=USERNAMME
|
||||||
|
DB_PASS=PASSWORD
|
||||||
|
|
||||||
|
# You can keep this value by default for test env, but this not recommended.
|
||||||
|
JWT_SECRET=supersecretjwt
|
||||||
|
# JWT token live duration.
|
||||||
|
JWT_EXPIRES=7d
|
||||||
|
|
||||||
|
# If you running this project through docker - DO NOT MODITY THIS VARIABLE
|
||||||
|
UPLOAD_DIR=uploads
|
||||||
|
|
||||||
|
# Replace it by `http://[public ip or domain]:[public port, not local (PORT env variable)]`
|
||||||
|
PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
/.nyc_output
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# temp directory
|
||||||
|
.temp
|
||||||
|
.tmp
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
uploads
|
||||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN corepack enable \
|
||||||
|
&& pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN corepack enable \
|
||||||
|
&& pnpm install --prod --frozen-lockfile \
|
||||||
|
&& mkdir -p /app/uploads
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
VOLUME ["/app/uploads"]
|
||||||
|
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
|
|
||||||
10
README.md
Normal file
10
README.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# AAAA - название лучше не придумал.
|
||||||
|
|
||||||
|
### Installations steps.
|
||||||
|
1. Install Docker (with Docker Compose) by [following guide](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository).
|
||||||
|
2. Copy .env.example to .env.
|
||||||
|
3. Replace environment variables in .env by your. Make sure that you are using PostgreSQL, not MySQL.
|
||||||
|
4. In `docker-compose.yml` modify public port (replace 80 by any port) if you need.
|
||||||
|
5. Run `mkdir uploads` if you don't have that directory in project root.
|
||||||
|
6. Run `docker compose up --build --detach`.
|
||||||
|
7. Try to open this url `http://[public ip or domain]:[public port specified in docker-compose.yml]/docs`.
|
||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: aaaa-app
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
35
eslint.config.mjs
Normal file
35
eslint.config.mjs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
|
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
91
package.json
Normal file
91
package.json
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"name": "aaaa",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.1",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/serve-static": "^5.0.4",
|
||||||
|
"@nestjs/swagger": "^11.2.1",
|
||||||
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.2",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"mime-types": "^3.0.1",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"sharp": "^0.34.4",
|
||||||
|
"typeorm": "^0.3.27",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
7384
pnpm-lock.yaml
generated
Normal file
7384
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
src/app.module.ts
Normal file
38
src/app.module.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { ServeStaticModule } from "@nestjs/serve-static";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
import { AuthModule } from "./auth/auth.module";
|
||||||
|
import { UsersModule } from "./users/users.module";
|
||||||
|
import { PostsModule } from "./posts/posts.module";
|
||||||
|
import { CommentsModule } from "./comments/comments.module";
|
||||||
|
import { User } from "./users/user.entity";
|
||||||
|
import { PostEntity } from "./posts/post.entity";
|
||||||
|
import { Comment } from "./comments/comment.entity";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ServeStaticModule.forRoot({
|
||||||
|
rootPath: path.resolve(process.env.UPLOAD_DIR || "uploads"),
|
||||||
|
serveRoot: "/static",
|
||||||
|
}),
|
||||||
|
TypeOrmModule.forRoot({
|
||||||
|
type: "postgres",
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT || 5432),
|
||||||
|
username: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASS,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
synchronize: true,
|
||||||
|
entities: [User, PostEntity, Comment],
|
||||||
|
}),
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
|
PostsModule,
|
||||||
|
CommentsModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
55
src/auth/auth.controller.ts
Normal file
55
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {AuthService} from "./auth.service";
|
||||||
|
import {ApiBody, ApiConsumes, ApiTags} from "@nestjs/swagger";
|
||||||
|
import {FileInterceptor} from "@nestjs/platform-express";
|
||||||
|
import {RegisterDto} from "./dto/register.dto";
|
||||||
|
import {LoginDto} from "./dto/login.dto";
|
||||||
|
import {ImageValidationInterceptor} from "../common/interceptors/image-validation.interceptor";
|
||||||
|
|
||||||
|
@ApiTags("auth")
|
||||||
|
@Controller("auth")
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly auth: AuthService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("register")
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
username: {type: "string"},
|
||||||
|
email: {type: "string"},
|
||||||
|
password: {type: "string"},
|
||||||
|
name: {type: "string"},
|
||||||
|
file: {
|
||||||
|
type: "string",
|
||||||
|
format: "binary",
|
||||||
|
description: "avatar WEBP <= 10MB, <= 1920px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["username", "email", "password"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {limits: {fileSize: 10 * 1024 * 1024}}),
|
||||||
|
new ImageValidationInterceptor({required: false, fieldName: "file"}),
|
||||||
|
)
|
||||||
|
async register(
|
||||||
|
@Body() dto: RegisterDto,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.auth.register(dto, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("login")
|
||||||
|
async login(@Body() dto: LoginDto) {
|
||||||
|
return this.auth.login(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/auth/auth.module.ts
Normal file
14
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
|
import { PassportModule } from "@nestjs/passport";
|
||||||
|
import { UsersModule } from "../users/users.module";
|
||||||
|
import { AuthService } from "./auth.service";
|
||||||
|
import { AuthController } from "./auth.controller";
|
||||||
|
import { JwtStrategy } from "./jwt.strategy";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [UsersModule, PassportModule, JwtModule.register({})],
|
||||||
|
providers: [AuthService, JwtStrategy],
|
||||||
|
controllers: [AuthController],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
75
src/auth/auth.service.ts
Normal file
75
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { UsersService } from "../users/users.service";
|
||||||
|
import * as bcrypt from "bcryptjs";
|
||||||
|
import { JwtService } from "@nestjs/jwt";
|
||||||
|
import { LoginDto } from "./dto/login.dto";
|
||||||
|
import { RegisterDto } from "./dto/register.dto";
|
||||||
|
import { Role } from "../common/enums/role.enum";
|
||||||
|
import { saveBufferAsFile } from "../common/utils/file.util";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private users: UsersService,
|
||||||
|
private jwt: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private sign(user: { id: number; role: Role }) {
|
||||||
|
const payload = JSON.stringify({ sub: user.id, role: user.role });
|
||||||
|
return this.jwt.sign(payload, {
|
||||||
|
secret: process.env.JWT_SECRET,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(dto: RegisterDto, avatarFile?: Express.Multer.File) {
|
||||||
|
if (await this.users.findByUsername(dto.username))
|
||||||
|
throw new BadRequestException("Username taken");
|
||||||
|
if (await this.users.findByEmail(dto.email))
|
||||||
|
throw new BadRequestException("Email taken");
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(dto.password, 12);
|
||||||
|
let avatarUrl: string | undefined;
|
||||||
|
|
||||||
|
if (avatarFile) {
|
||||||
|
const saved = await saveBufferAsFile(
|
||||||
|
avatarFile.buffer,
|
||||||
|
"avatars",
|
||||||
|
".webp",
|
||||||
|
);
|
||||||
|
avatarUrl = saved.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.users.create({
|
||||||
|
username: dto.username,
|
||||||
|
email: dto.email,
|
||||||
|
name: dto.name,
|
||||||
|
passwordHash,
|
||||||
|
avatarUrl,
|
||||||
|
role: Role.USER,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { token: this.sign({ id: user.id, role: user.role }), user };
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(dto: LoginDto) {
|
||||||
|
const user =
|
||||||
|
(await this.users.findByUsername(dto.identifier)) ??
|
||||||
|
(await this.users.findByEmail(dto.identifier));
|
||||||
|
|
||||||
|
if (!user) throw new UnauthorizedException("Invalid credentials");
|
||||||
|
|
||||||
|
if (user.passwordResetAllowed) {
|
||||||
|
return { token: this.sign({ id: user.id, role: user.role }), user };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dto.password) throw new UnauthorizedException("Password required");
|
||||||
|
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||||
|
if (!ok) throw new UnauthorizedException("Invalid credentials");
|
||||||
|
|
||||||
|
return { token: this.sign({ id: user.id, role: user.role }), user };
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/auth/dto/login.dto.ts
Normal file
17
src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { ApiPropertyOptional, ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsOptional, IsString, Length } from "class-validator";
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@ApiProperty({ description: "username или email" })
|
||||||
|
@IsString()
|
||||||
|
identifier: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Пароль. Можно опустить, если админ сделал reset (вход без пароля)",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 72)
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
31
src/auth/dto/register.dto.ts
Normal file
31
src/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Length,
|
||||||
|
Matches,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export class RegisterDto {
|
||||||
|
@ApiProperty({ example: "john_doe" })
|
||||||
|
@IsString()
|
||||||
|
@Length(3, 24)
|
||||||
|
@Matches(/^[a-zA-Z0-9_]+$/)
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "john@example.com" })
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "VeryStrong#123" })
|
||||||
|
@IsString()
|
||||||
|
@Length(8, 72)
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "John Doe", required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 64)
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
18
src/auth/jwt.strategy.ts
Normal file
18
src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
|
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: process.env.JWT_SECRET!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: any) {
|
||||||
|
return { userId: payload.sub, role: payload.role };
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/comments/comment.entity.ts
Normal file
37
src/comments/comment.entity.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from "typeorm";
|
||||||
|
import { User } from "../users/user.entity";
|
||||||
|
import { PostEntity } from "../posts/post.entity";
|
||||||
|
|
||||||
|
@Entity("comments")
|
||||||
|
export class Comment {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column("text")
|
||||||
|
text: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, (u) => u.comments, {
|
||||||
|
eager: true,
|
||||||
|
onDelete: "CASCADE",
|
||||||
|
})
|
||||||
|
author: User;
|
||||||
|
|
||||||
|
@ManyToOne(() => PostEntity, (p) => p.comments, {
|
||||||
|
eager: true,
|
||||||
|
onDelete: "CASCADE",
|
||||||
|
})
|
||||||
|
post: PostEntity;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
64
src/comments/comments.controller.ts
Normal file
64
src/comments/comments.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post as HttpPost,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { CommentsService } from "./comments.service";
|
||||||
|
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { CreateCommentDto } from "./dto/create-comment.dto";
|
||||||
|
import { UpdateCommentDto } from "./dto/update-comment.dto";
|
||||||
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
|
import { UsersService } from "../users/users.service";
|
||||||
|
|
||||||
|
@ApiTags("comments")
|
||||||
|
@Controller()
|
||||||
|
export class CommentsController {
|
||||||
|
constructor(
|
||||||
|
private readonly comments: CommentsService,
|
||||||
|
private readonly users: UsersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@HttpPost("posts/:postId/comments")
|
||||||
|
async create(
|
||||||
|
@Param("postId", ParseIntPipe) postId: number,
|
||||||
|
@Req() req: any,
|
||||||
|
@Body() dto: CreateCommentDto,
|
||||||
|
) {
|
||||||
|
const user = await this.users.findById(req.user.userId);
|
||||||
|
return this.comments.create(postId, dto.text, user!);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("posts/:postId/comments")
|
||||||
|
async list(@Param("postId", ParseIntPipe) postId: number) {
|
||||||
|
return this.comments.listByPost(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Patch("comments/:id")
|
||||||
|
async update(
|
||||||
|
@Param("id", ParseIntPipe) id: number,
|
||||||
|
@Req() req: any,
|
||||||
|
@Body() dto: UpdateCommentDto,
|
||||||
|
) {
|
||||||
|
const user = await this.users.findById(req.user.userId);
|
||||||
|
return this.comments.update(id, dto.text, user!);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Delete("comments/:id")
|
||||||
|
async remove(@Param("id", ParseIntPipe) id: number, @Req() req: any) {
|
||||||
|
const user = await this.users.findById(req.user.userId);
|
||||||
|
return this.comments.remove(id, user!);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/comments/comments.module.ts
Normal file
14
src/comments/comments.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { Comment } from "./comment.entity";
|
||||||
|
import { CommentsService } from "./comments.service";
|
||||||
|
import { CommentsController } from "./comments.controller";
|
||||||
|
import { PostEntity } from "../posts/post.entity";
|
||||||
|
import { UsersModule } from "../users/users.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Comment, PostEntity]), UsersModule],
|
||||||
|
providers: [CommentsService],
|
||||||
|
controllers: [CommentsController],
|
||||||
|
})
|
||||||
|
export class CommentsModule {}
|
||||||
55
src/comments/comments.service.ts
Normal file
55
src/comments/comments.service.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { Comment } from "./comment.entity";
|
||||||
|
import { User } from "../users/user.entity";
|
||||||
|
import { PostEntity } from "../posts/post.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommentsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Comment) private repo: Repository<Comment>,
|
||||||
|
@InjectRepository(PostEntity) private posts: Repository<PostEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(postId: number, text: string, author: User) {
|
||||||
|
if (author.isBanned) throw new ForbiddenException("User is banned");
|
||||||
|
const post = await this.posts.findOne({ where: { id: postId } });
|
||||||
|
if (!post) throw new NotFoundException("Post not found");
|
||||||
|
const c = this.repo.create({ text, author, post });
|
||||||
|
return this.repo.save(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
listByPost(postId: number) {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { post: { id: postId } },
|
||||||
|
order: { createdAt: "ASC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, text: string, user: User) {
|
||||||
|
const c = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!c) throw new NotFoundException("Comment not found");
|
||||||
|
const isOwner = c.author.id === user.id;
|
||||||
|
const isAdmin = user.role === "ADMIN";
|
||||||
|
if (!(isOwner || isAdmin)) throw new ForbiddenException("Not allowed");
|
||||||
|
if (user.isBanned && !isAdmin)
|
||||||
|
throw new ForbiddenException("User is banned");
|
||||||
|
await this.repo.update(id, { text });
|
||||||
|
return this.repo.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number, user: User) {
|
||||||
|
const c = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!c) throw new NotFoundException("Comment not found");
|
||||||
|
const isOwner = c.author.id === user.id;
|
||||||
|
const isAdmin = user.role === "ADMIN";
|
||||||
|
if (!(isOwner || isAdmin)) throw new ForbiddenException("Not allowed");
|
||||||
|
await this.repo.delete(id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/comments/dto/create-comment.dto.ts
Normal file
9
src/comments/dto/create-comment.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsString, Length } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateCommentDto {
|
||||||
|
@ApiProperty({ example: "Мой комментарий" })
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 1000)
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
9
src/comments/dto/update-comment.dto.ts
Normal file
9
src/comments/dto/update-comment.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsString, Length } from "class-validator";
|
||||||
|
|
||||||
|
export class UpdateCommentDto {
|
||||||
|
@ApiProperty({ example: "Обновленный текст" })
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 1000)
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
5
src/common/decorators/roles.decorator.ts
Normal file
5
src/common/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from "@nestjs/common";
|
||||||
|
import { Role } from "../enums/role.enum";
|
||||||
|
|
||||||
|
export const ROLES_KEY = "roles";
|
||||||
|
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
4
src/common/enums/role.enum.ts
Normal file
4
src/common/enums/role.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export enum Role {
|
||||||
|
USER = "USER",
|
||||||
|
ADMIN = "ADMIN",
|
||||||
|
}
|
||||||
5
src/common/guards/jwt-auth.guard.ts
Normal file
5
src/common/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { AuthGuard } from "@nestjs/passport";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard("jwt") {}
|
||||||
29
src/common/guards/roles.guard.ts
Normal file
29
src/common/guards/roles.guard.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ROLES_KEY } from "../decorators/roles.decorator";
|
||||||
|
import { Role } from "../enums/role.enum";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(ctx: ExecutionContext) {
|
||||||
|
const required = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||||
|
ctx.getHandler(),
|
||||||
|
ctx.getClass(),
|
||||||
|
]);
|
||||||
|
if (!required || required.length === 0) return true;
|
||||||
|
const req = ctx.switchToHttp().getRequest();
|
||||||
|
const user = req.user as { role?: Role };
|
||||||
|
if (!user) throw new ForbiddenException("Not authenticated");
|
||||||
|
if (!required.includes(<Role>user.role)) {
|
||||||
|
throw new ForbiddenException("Insufficient role");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/common/interceptors/image-validation.interceptor.ts
Normal file
61
src/common/interceptors/image-validation.interceptor.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
export interface ImageValidationOptions {
|
||||||
|
required?: boolean;
|
||||||
|
maxBytes?: number;
|
||||||
|
maxSide?: number;
|
||||||
|
mime?: string;
|
||||||
|
fieldName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImageValidationInterceptor implements NestInterceptor {
|
||||||
|
constructor(private readonly opts: ImageValidationOptions = {}) {
|
||||||
|
this.opts.maxBytes ??= 10 * 1024 * 1024;
|
||||||
|
this.opts.maxSide ??= 1920;
|
||||||
|
this.opts.mime ??= "image/webp";
|
||||||
|
this.opts.fieldName ??= "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
async intercept(ctx: ExecutionContext, next: CallHandler) {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
const file = request.file;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
if (this.opts.required) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`File "${this.opts.fieldName}" is required`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.mimetype !== this.opts.mime) {
|
||||||
|
throw new BadRequestException(`Only WEBP images are allowed`);
|
||||||
|
}
|
||||||
|
if (file.size > (this.opts.maxBytes as number)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`File too large (max ${(this.opts.maxBytes! / (1024 * 1024)).toFixed(0)} MB)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let meta: sharp.Metadata;
|
||||||
|
try {
|
||||||
|
meta = await sharp(file.buffer).metadata();
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException("Invalid image");
|
||||||
|
}
|
||||||
|
const side = Math.max(meta.width ?? 0, meta.height ?? 0);
|
||||||
|
if (!side || side > (this.opts.maxSide as number)) {
|
||||||
|
throw new BadRequestException(`Max image side is ${this.opts.maxSide}px`);
|
||||||
|
}
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/common/utils/file.util.ts
Normal file
28
src/common/utils/file.util.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import {promises as fs} from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import {v4 as uuid} from "uuid";
|
||||||
|
|
||||||
|
export async function saveBufferAsFile(
|
||||||
|
buffer: Buffer,
|
||||||
|
category: "avatars" | "posts",
|
||||||
|
ext = ".webp",
|
||||||
|
): Promise<{
|
||||||
|
url: string;
|
||||||
|
relPath: string;
|
||||||
|
absPath: string;
|
||||||
|
filename: string;
|
||||||
|
}> {
|
||||||
|
const uploadDir = process.env.UPLOAD_DIR || "uploads";
|
||||||
|
const dir = path.resolve(uploadDir, category);
|
||||||
|
await fs.mkdir(dir, {recursive: true});
|
||||||
|
|
||||||
|
const filename = `${uuid().replace(/-/g, "")}${ext}`;
|
||||||
|
const absPath = path.join(dir, filename);
|
||||||
|
await fs.writeFile(absPath, buffer);
|
||||||
|
|
||||||
|
const baseUrl = process.env.PUBLIC_BASE_URL || "";
|
||||||
|
const url = `${baseUrl}/static/${category}/${filename}`;
|
||||||
|
const relPath = `/${category}/${filename}`;
|
||||||
|
|
||||||
|
return {url, relPath, absPath, filename};
|
||||||
|
}
|
||||||
24
src/main.ts
Normal file
24
src/main.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NestFactory } from "@nestjs/core";
|
||||||
|
import { AppModule } from "./app.module";
|
||||||
|
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||||
|
import { ValidationPipe } from "@nestjs/common";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
dotenv.config();
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
app.setGlobalPrefix("api");
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||||
|
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle("AAAA API")
|
||||||
|
.setVersion("1.0")
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup("docs", app, document);
|
||||||
|
|
||||||
|
await app.listen(process.env.PORT || 3000);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
19
src/posts/dto/create-post.dto.ts
Normal file
19
src/posts/dto/create-post.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsNotEmpty, IsString, Length } from "class-validator";
|
||||||
|
|
||||||
|
export class CreatePostDto {
|
||||||
|
@ApiProperty({ example: "Заголовок" })
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 200)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "Краткое описание" })
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 400)
|
||||||
|
shortDescription: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "Полное описание..." })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
fullDescription: string;
|
||||||
|
}
|
||||||
21
src/posts/dto/update-post.dto.ts
Normal file
21
src/posts/dto/update-post.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsOptional, IsString, Length } from "class-validator";
|
||||||
|
|
||||||
|
export class UpdatePostDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 200)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 400)
|
||||||
|
shortDescription?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
fullDescription?: string;
|
||||||
|
}
|
||||||
41
src/posts/post.entity.ts
Normal file
41
src/posts/post.entity.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from "typeorm";
|
||||||
|
import { User } from "../users/user.entity";
|
||||||
|
import { Comment } from "../comments/comment.entity";
|
||||||
|
|
||||||
|
@Entity("posts")
|
||||||
|
export class PostEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ length: 200 })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ length: 400 })
|
||||||
|
shortDescription: string;
|
||||||
|
|
||||||
|
@Column("text")
|
||||||
|
fullDescription: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
imageUrl?: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, (u) => u.posts, { eager: true, onDelete: "CASCADE" })
|
||||||
|
author: User;
|
||||||
|
|
||||||
|
@OneToMany(() => Comment, (c) => c.post)
|
||||||
|
comments: Comment[];
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
109
src/posts/posts.controller.ts
Normal file
109
src/posts/posts.controller.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post as HttpPost,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { PostsService } from "./posts.service";
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiQuery,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
|
import { CreatePostDto } from "./dto/create-post.dto";
|
||||||
|
import { UpdatePostDto } from "./dto/update-post.dto";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { ImageValidationInterceptor } from "../common/interceptors/image-validation.interceptor";
|
||||||
|
import { saveBufferAsFile } from "../common/utils/file.util";
|
||||||
|
import { UsersService } from "../users/users.service";
|
||||||
|
|
||||||
|
@ApiTags("posts")
|
||||||
|
@Controller("posts")
|
||||||
|
export class PostsController {
|
||||||
|
constructor(
|
||||||
|
private readonly posts: PostsService,
|
||||||
|
private readonly users: UsersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiQuery({ name: "page", required: false, example: 1 })
|
||||||
|
@ApiQuery({ name: "limit", required: false, example: 10 })
|
||||||
|
async list(@Query("page") page?: number, @Query("limit") limit?: number) {
|
||||||
|
return this.posts.paginate(Number(page) || 1, Number(limit) || 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id")
|
||||||
|
async getOne(@Param("id", ParseIntPipe) id: number) {
|
||||||
|
return this.posts.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpPost()
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: { type: "string" },
|
||||||
|
shortDescription: { type: "string" },
|
||||||
|
fullDescription: { type: "string" },
|
||||||
|
file: {
|
||||||
|
type: "string",
|
||||||
|
format: "binary",
|
||||||
|
description: "WEBP <= 10MB, <= 1920px (optional)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["title", "shortDescription", "fullDescription"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", { limits: { fileSize: 10 * 1024 * 1024 } }),
|
||||||
|
new ImageValidationInterceptor({ required: false, fieldName: "file" }),
|
||||||
|
)
|
||||||
|
async create(
|
||||||
|
@Req() req: any,
|
||||||
|
@Body() dto: CreatePostDto,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
const user = await this.users.findById(req.user.userId);
|
||||||
|
let imageUrl: string | undefined;
|
||||||
|
if (file) {
|
||||||
|
const saved = await saveBufferAsFile(file.buffer, "posts", ".webp");
|
||||||
|
imageUrl = saved.url;
|
||||||
|
}
|
||||||
|
return this.posts.create({ ...dto, imageUrl }, user!);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Patch(":id")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
async update(
|
||||||
|
@Param("id", ParseIntPipe) id: number,
|
||||||
|
@Req() req: any,
|
||||||
|
@Body() dto: UpdatePostDto,
|
||||||
|
) {
|
||||||
|
const user = await this.users.findById(req.user.userId);
|
||||||
|
return this.posts.update(id, dto, user!);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Delete(":id")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
async remove(@Param("id", ParseIntPipe) id: number, @Req() req: any) {
|
||||||
|
const user = await this.users.findById(req.user.userId);
|
||||||
|
return this.posts.remove(id, user!);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/posts/posts.module.ts
Normal file
14
src/posts/posts.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { PostEntity } from "./post.entity";
|
||||||
|
import { PostsService } from "./posts.service";
|
||||||
|
import { PostsController } from "./posts.controller";
|
||||||
|
import { UsersModule } from "../users/users.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([PostEntity]), UsersModule],
|
||||||
|
providers: [PostsService],
|
||||||
|
controllers: [PostsController],
|
||||||
|
exports: [PostsService],
|
||||||
|
})
|
||||||
|
export class PostsModule {}
|
||||||
59
src/posts/posts.service.ts
Normal file
59
src/posts/posts.service.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { PostEntity } from "./post.entity";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { User } from "../users/user.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PostsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(PostEntity) private repo: Repository<PostEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(data: Partial<PostEntity>, author: User) {
|
||||||
|
if (author.isBanned) throw new ForbiddenException("User is banned");
|
||||||
|
const entity = this.repo.create({ ...data, author });
|
||||||
|
return this.repo.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async paginate(page = 1, limit = 10) {
|
||||||
|
page = Math.max(1, page);
|
||||||
|
limit = Math.min(Math.max(1, limit), 100);
|
||||||
|
const [items, total] = await this.repo.findAndCount({
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
return { items, total, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: number) {
|
||||||
|
const post = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!post) throw new NotFoundException("Post not found");
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(postId: number, patch: Partial<PostEntity>, user: User) {
|
||||||
|
const post = await this.findById(postId);
|
||||||
|
const isOwner = post.author.id === user.id;
|
||||||
|
const isAdmin = user.role === "ADMIN";
|
||||||
|
if (!(isOwner || isAdmin)) throw new ForbiddenException("Not allowed");
|
||||||
|
if (user.isBanned && !isAdmin)
|
||||||
|
throw new ForbiddenException("User is banned");
|
||||||
|
await this.repo.update(postId, patch);
|
||||||
|
return this.findById(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(postId: number, user: User) {
|
||||||
|
const post = await this.findById(postId);
|
||||||
|
const isOwner = post.author.id === user.id;
|
||||||
|
const isAdmin = user.role === "ADMIN";
|
||||||
|
if (!(isOwner || isAdmin)) throw new ForbiddenException("Not allowed");
|
||||||
|
await this.repo.delete(postId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/users/dto/update-email.dto.ts
Normal file
8
src/users/dto/update-email.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsEmail } from "class-validator";
|
||||||
|
|
||||||
|
export class UpdateEmailDto {
|
||||||
|
@ApiProperty({ example: "new@example.com" })
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
9
src/users/dto/update-password.dto.ts
Normal file
9
src/users/dto/update-password.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsString, Length } from "class-validator";
|
||||||
|
|
||||||
|
export class UpdatePasswordDto {
|
||||||
|
@ApiProperty({ example: "NewStrong#123" })
|
||||||
|
@IsString()
|
||||||
|
@Length(8, 72)
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
10
src/users/dto/update-username.dto.ts
Normal file
10
src/users/dto/update-username.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsString, Length, Matches } from "class-validator";
|
||||||
|
|
||||||
|
export class UpdateUsernameDto {
|
||||||
|
@ApiProperty({ example: "new_name" })
|
||||||
|
@IsString()
|
||||||
|
@Length(3, 24)
|
||||||
|
@Matches(/^[a-zA-Z0-9_]+$/)
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
53
src/users/user.entity.ts
Normal file
53
src/users/user.entity.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
OneToMany,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from "typeorm";
|
||||||
|
import { Role } from "../common/enums/role.enum";
|
||||||
|
import { PostEntity } from "../posts/post.entity";
|
||||||
|
import { Comment } from "../comments/comment.entity";
|
||||||
|
|
||||||
|
@Entity("users")
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ unique: true, length: 32 })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true, length: 64 })
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
avatarUrl?: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
passwordHash: string;
|
||||||
|
|
||||||
|
@Column({ type: "enum", enum: Role, default: Role.USER })
|
||||||
|
role: Role;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
isBanned: boolean;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
passwordResetAllowed: boolean;
|
||||||
|
|
||||||
|
@OneToMany(() => PostEntity, (p) => p.author)
|
||||||
|
posts: PostEntity[];
|
||||||
|
|
||||||
|
@OneToMany(() => Comment, (c) => c.author)
|
||||||
|
comments: Comment[];
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
111
src/users/users.controller.ts
Normal file
111
src/users/users.controller.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Req,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {ApiBearerAuth, ApiBody, ApiConsumes, ApiTags} from "@nestjs/swagger";
|
||||||
|
import {UsersService} from "./users.service";
|
||||||
|
import {JwtAuthGuard} from "../common/guards/jwt-auth.guard";
|
||||||
|
import {UpdateEmailDto} from "./dto/update-email.dto";
|
||||||
|
import {UpdateUsernameDto} from "./dto/update-username.dto";
|
||||||
|
import {UpdatePasswordDto} from "./dto/update-password.dto";
|
||||||
|
import * as bcrypt from "bcryptjs";
|
||||||
|
import {Roles} from "../common/decorators/roles.decorator";
|
||||||
|
import {RolesGuard} from "../common/guards/roles.guard";
|
||||||
|
import {Role} from "../common/enums/role.enum";
|
||||||
|
import {FileInterceptor} from "@nestjs/platform-express";
|
||||||
|
import {ImageValidationInterceptor} from "../common/interceptors/image-validation.interceptor";
|
||||||
|
import {saveBufferAsFile} from "../common/utils/file.util";
|
||||||
|
|
||||||
|
@ApiTags("users")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller("users")
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly users: UsersService) {}
|
||||||
|
|
||||||
|
@Get("me")
|
||||||
|
async me(@Req() req: any) {
|
||||||
|
return await this.users.findById(req.user.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("me/email")
|
||||||
|
async updateEmail(@Req() req: any, @Body() dto: UpdateEmailDto) {
|
||||||
|
const exists = await this.users.findByEmail(dto.email);
|
||||||
|
if (exists && exists.id !== req.user.userId)
|
||||||
|
throw new BadRequestException("Email taken");
|
||||||
|
return this.users.update(req.user.userId, { email: dto.email });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("me/username")
|
||||||
|
async updateUsername(@Req() req: any, @Body() dto: UpdateUsernameDto) {
|
||||||
|
const exists = await this.users.findByUsername(dto.username);
|
||||||
|
if (exists && exists.id !== req.user.userId)
|
||||||
|
throw new BadRequestException("Username taken");
|
||||||
|
return this.users.update(req.user.userId, { username: dto.username });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("me/password")
|
||||||
|
async updatePassword(@Req() req: any, @Body() dto: UpdatePasswordDto) {
|
||||||
|
const passwordHash = await bcrypt.hash(dto.newPassword, 12);
|
||||||
|
return this.users.update(req.user.userId, {
|
||||||
|
passwordHash,
|
||||||
|
passwordResetAllowed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("me/avatar")
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
file: {
|
||||||
|
type: "string",
|
||||||
|
format: "binary",
|
||||||
|
description: "WEBP ≤10MB, ≤1920px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["file"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", { limits: { fileSize: 10 * 1024 * 1024 } }),
|
||||||
|
new ImageValidationInterceptor({ required: true, fieldName: "file" }),
|
||||||
|
)
|
||||||
|
async updateAvatar(
|
||||||
|
@Req() req: any,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
const saved = await saveBufferAsFile(file.buffer, "avatars", ".webp");
|
||||||
|
return this.users.update(req.user.userId, { avatarUrl: saved.url });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(Role.ADMIN)
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Patch("admin/:id/ban")
|
||||||
|
async ban(@Param("id") id: string) {
|
||||||
|
return this.users.setBan(Number(id), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(Role.ADMIN)
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Patch("admin/:id/unban")
|
||||||
|
async unban(@Param("id") id: string) {
|
||||||
|
return this.users.setBan(Number(id), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(Role.ADMIN)
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Patch("admin/:id/reset-password")
|
||||||
|
async resetPassword(@Param("id") id: string) {
|
||||||
|
return this.users.setPasswordReset(Number(id), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/users/users.module.ts
Normal file
13
src/users/users.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { User } from "./user.entity";
|
||||||
|
import { UsersService } from "./users.service";
|
||||||
|
import { UsersController } from "./users.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([User])],
|
||||||
|
providers: [UsersService],
|
||||||
|
controllers: [UsersController],
|
||||||
|
exports: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
42
src/users/users.service.ts
Normal file
42
src/users/users.service.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { User } from "./user.entity";
|
||||||
|
import { Role } from "../common/enums/role.enum";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(@InjectRepository(User) private repo: Repository<User>) {}
|
||||||
|
|
||||||
|
findById(id: number) {
|
||||||
|
return this.repo.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
findByUsername(username: string) {
|
||||||
|
return this.repo.findOne({ where: { username } });
|
||||||
|
}
|
||||||
|
findByEmail(email: string) {
|
||||||
|
return this.repo.findOne({ where: { email } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Partial<User>) {
|
||||||
|
const user = this.repo.create(data);
|
||||||
|
return this.repo.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, patch: Partial<User>) {
|
||||||
|
await this.repo.update(id, patch);
|
||||||
|
const updated = await this.findById(id);
|
||||||
|
if (!updated) throw new NotFoundException();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setBan(id: number, banned: boolean) {
|
||||||
|
return this.update(id, { isBanned: banned });
|
||||||
|
}
|
||||||
|
async setPasswordReset(id: number, allowed: boolean) {
|
||||||
|
return this.update(id, { passwordResetAllowed: allowed });
|
||||||
|
}
|
||||||
|
async setRole(id: number, role: Role) {
|
||||||
|
return this.update(id, { role });
|
||||||
|
}
|
||||||
|
}
|
||||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"resolvePackageJsonExports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"noFallthroughCasesInSwitch": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user