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