Skip to content

Commit

Permalink
[WIP] Feat/backend/avatar (#123)
Browse files Browse the repository at this point in the history
* [backend] Format schema.prisma
* [backend] Add avatarURL to User model, and add avatar module
* [backend] Implement GET /avatar/:filename API
* [backend] Implement POST /user/:userId/avatar API (Upload)
* [backend] Implement DELETE /user/:userId/avatar API (Remove)
* [web/backend] Add avatar images volume
  • Loading branch information
usatie authored Dec 10, 2023
1 parent 6ec9486 commit b32a6b1
Show file tree
Hide file tree
Showing 21 changed files with 426 additions and 35 deletions.
6 changes: 5 additions & 1 deletion backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
/dist
/node_modules

# public
/public/avatar/*
!/public/avatar/default.png

# Logs
logs
*.log
Expand Down Expand Up @@ -32,4 +36,4 @@ lerna-debug.log*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/extensions.json
6 changes: 4 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,23 @@
"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"
"test:e2e": "jest --config ./test/jest-e2e.json --runInBand"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.1.1",
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-express": "^10.2.10",
"@nestjs/platform-socket.io": "^10.2.8",
"@nestjs/swagger": "^7.1.14",
"@nestjs/websockets": "^10.2.8",
"@prisma/client": "5.5.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"multer": "^1.4.5-lts.1",
"nestjs-prisma": "^0.22.0",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
Expand All @@ -50,6 +51,7 @@
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/multer": "^1.4.11",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^3.0.12",
"@types/supertest": "^2.0.12",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "avatarURL" TEXT;
52 changes: 28 additions & 24 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -11,41 +11,45 @@ datasource db {
}

model User {
id Int @id @default(autoincrement())
email String @unique
name String?
password String
rooms UserOnRoom[]
sentMessages DirectMessage[] @relation("SentMessages")
id Int @id @default(autoincrement())
email String @unique
name String?
password String
avatarURL String?
// Chat
rooms UserOnRoom[]
sentMessages DirectMessage[] @relation("SentMessages")
receivedMessages DirectMessage[] @relation("ReceivedMessages")
// Friendship
friends User[] @relation("Friendship")
friends User[] @relation("Friendship")
friendsOf User[] @relation("Friendship")
// FriendRequest
requestedBy User[] @relation("FriendRequest")
requesting User[] @relation("FriendRequest")
requesting User[] @relation("FriendRequest")
// Block
blockedBy User[] @relation("BlockUser")
blocking User[] @relation("BlockUser")
blocking User[] @relation("BlockUser")
}

model Room {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
name String
users UserOnRoom[]
}

model UserOnRoom {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int
role Role @default(MEMBER)
room Room @relation(fields: [roomId], references: [id])
roomId Int
@@unique(fields: [userId, roomId], name: "userId_roomId_unique")
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int
role Role @default(MEMBER)
room Room @relation(fields: [roomId], references: [id])
roomId Int
@@unique(fields: [userId, roomId], name: "userId_roomId_unique")
}

enum Role {
Expand All @@ -55,13 +59,13 @@ enum Role {
}

model DirectMessage {
id Int @id @default(autoincrement())
content String
id Int @id @default(autoincrement())
content String
senderId Int
sender User @relation("SentMessages", fields: [senderId], references: [id])
receiverId Int
receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id])
senderId Int
sender User @relation("SentMessages", fields: [senderId], references: [id])
receiverId Int
receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id])
createdAt DateTime @default(now())
createdAt DateTime @default(now())
}
Binary file added backend/public/avatar/default.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { RoomModule } from './room/room.module';
import { EventsModule } from './events/events.module';
import { ChatModule } from './chat/chat.module';
import { FriendRequestModule } from './friend-request/friend-request.module';
import { AvatarModule } from './avatar/avatar.module';

@Module({
imports: [
Expand All @@ -18,6 +19,7 @@ import { FriendRequestModule } from './friend-request/friend-request.module';
EventsModule,
ChatModule,
FriendRequestModule,
AvatarModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
21 changes: 21 additions & 0 deletions backend/src/avatar/avatar.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AvatarController } from './avatar.controller';
import { AvatarService } from './avatar.service';
import { PrismaService } from 'src/prisma/prisma.service';

describe('AvatarController', () => {
let controller: AvatarController;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AvatarController],
providers: [AvatarService, PrismaService],
}).compile();

controller = module.get<AvatarController>(AvatarController);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});
});
94 changes: 94 additions & 0 deletions backend/src/avatar/avatar.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {
Controller,
Get,
Post,
Body,
Delete,
UseGuards,
Param,
Res,
UseInterceptors,
UploadedFile,
ParseIntPipe,
HttpCode,
} from '@nestjs/common';
import { AvatarService } from './avatar.service';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { UserGuard } from 'src/user/user.guard';
import {
ApiBearerAuth,
ApiConsumes,
ApiNoContentResponse,
ApiTags,
} from '@nestjs/swagger';
import { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import * as multer from 'multer';
import { CreateAvatarDto } from './dto/create-avatar.dto';

@Controller()
@ApiTags('avatar')
export class AvatarController {
constructor(private readonly avatarService: AvatarService) {}

// Public
@Get('avatar/:filename')
findOne(@Param('filename') filename: string, @Res() res: Response) {
// Validate filename
// e.g. 1621234567890-1.png
// e.g. default.png
// e.g. 1621234567890-1.jpeg
if (!filename.match(/^(default|(\d+)-\d+)\.(png|jpeg)$/)) {
return res.status(404).send('Not found');
}
res.sendFile(filename, { root: 'public/avatar' });
}

// Private
@Post('user/:userId/avatar')
@UseInterceptors(
FileInterceptor('avatar', {
// File size limit
limits: {
fileSize: 1024 * 1024,
},
// File type filter
fileFilter: (req, file, cb) => {
const allowedMimes = ['image/jpeg', 'image/png'];
if (allowedMimes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Unsupported file type'), false);
}
},
// Save file to public/avatar
storage: multer.diskStorage({
destination: './public/avatar',
filename: (req, file, cb) => {
const ext = file.mimetype.split('/')[1];
const filename = `${Date.now()}-${req.params.userId}.${ext}`;
cb(null, filename);
},
}),
}),
)
@UseGuards(JwtAuthGuard, UserGuard)
@ApiConsumes('multipart/form-data')
@ApiBearerAuth()
create(
@Param('userId', ParseIntPipe) userId: number,
@Body() createAvatarDto: CreateAvatarDto,
@UploadedFile() file: Express.Multer.File,
) {
return this.avatarService.create(userId, file);
}

@Delete('user/:userId/avatar')
@HttpCode(204)
@UseGuards(JwtAuthGuard, UserGuard)
@ApiNoContentResponse()
@ApiBearerAuth()
remove(@Param('userId', ParseIntPipe) userId: number) {
return this.avatarService.remove(userId);
}
}
11 changes: 11 additions & 0 deletions backend/src/avatar/avatar.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AvatarService } from './avatar.service';
import { AvatarController } from './avatar.controller';
import { PrismaModule } from 'src/prisma/prisma.module';

@Module({
controllers: [AvatarController],
providers: [AvatarService],
imports: [PrismaModule],
})
export class AvatarModule {}
19 changes: 19 additions & 0 deletions backend/src/avatar/avatar.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AvatarService } from './avatar.service';
import { PrismaService } from 'src/prisma/prisma.service';

describe('AvatarService', () => {
let service: AvatarService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AvatarService, PrismaService],
}).compile();

service = module.get<AvatarService>(AvatarService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});
});
42 changes: 42 additions & 0 deletions backend/src/avatar/avatar.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import * as fs from 'fs';

@Injectable()
export class AvatarService {
constructor(private prisma: PrismaService) {}

async create(userId: number, file: Express.Multer.File) {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
});
const avatarURL = `/avatar/${file.filename}`;
return this.prisma.user
.update({
where: { id: userId },
data: { avatarURL },
})
.then(() => {
// Delete old avatar
if (user.avatarURL) {
fs.rmSync('./public' + user.avatarURL, { force: true });
}
return { filename: file.filename, url: avatarURL };
});
}

async remove(userId: number) {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
});
return this.prisma.user
.delete({
where: { id: userId },
})
.then(() => {
if (!user.avatarURL) return user;
fs.rmSync('./public' + user.avatarURL, { force: true });
return user;
});
}
}
15 changes: 15 additions & 0 deletions backend/src/avatar/dto/create-avatar.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';

export class CreateAvatarDto {
@ApiProperty({
description: 'アップロードするファイル',
type: 'file',
properties: {
file: {
type: 'string',
format: 'binary',
},
},
})
avatar!: Express.Multer.File;
}
1 change: 1 addition & 0 deletions backend/src/avatar/entities/avatar.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class Avatar {}
3 changes: 3 additions & 0 deletions backend/src/user/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@ export class UserEntity implements User {

@Exclude()
password: string;

@ApiProperty({ required: false, nullable: true })
avatarURL: string | null;
}
Loading

0 comments on commit b32a6b1

Please sign in to comment.