1
0

feat: initial commit

This commit is contained in:
2025-11-13 11:08:20 +04:00
commit 059d79a256
46 changed files with 8797 additions and 0 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
dist
uploads
.git
.idea
test
*.log
Dockerfile*
docker-compose*.yml
.env

24
.env.example Normal file
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"singleQuote": false,
"trailingComma": "all"
}

29
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

38
src/app.module.ts Normal file
View 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 {}

View 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
View 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
View 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
View 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;
}

View 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
View 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 };
}
}

View 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;
}

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

View 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 {}

View 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 };
}
}

View 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;
}

View 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;
}

View 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);

View File

@@ -0,0 +1,4 @@
export enum Role {
USER = "USER",
ADMIN = "ADMIN",
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}

View 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;
}
}

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

View 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
View 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();

View 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;
}

View 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
View 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;
}

View 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
View 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 {}

View 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 };
}
}

View 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;
}

View 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;
}

View 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
View 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;
}

View 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
View 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 {}

View 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
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
tsconfig.json Normal file
View 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
}
}