Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: 스터디 보고서 이미지 업로드 (#139) #140

Merged
merged 3 commits into from
Oct 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/main/java/edu/handong/csee/histudy/config/WebConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
Expand All @@ -24,6 +25,12 @@ public class WebConfig implements WebMvcConfigurer {
@Value("${custom.path-patterns.include}")
private String[] includePathPatterns;

@Value("${custom.resource.path}")
private String imageBasePath;

@Value("${custom.resource.location}")
private String imageBaseLocation;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new AuthenticationInterceptor(jwtService))
Expand All @@ -38,4 +45,10 @@ public void addCorsMappings(CorsRegistry registry) {
.allowedMethods("GET", "POST", "DELETE", "PATCH")
.allowCredentials(true);
}

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler(imageBasePath)
.addResourceLocations("file://" + imageBaseLocation);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,32 @@

import edu.handong.csee.histudy.controller.form.ReportForm;
import edu.handong.csee.histudy.domain.Role;
import edu.handong.csee.histudy.domain.StudyGroup;
import edu.handong.csee.histudy.dto.CourseDto;
import edu.handong.csee.histudy.dto.ReportDto;
import edu.handong.csee.histudy.dto.UserDto;
import edu.handong.csee.histudy.exception.ForbiddenException;
import edu.handong.csee.histudy.exception.UserNotFoundException;
import edu.handong.csee.histudy.repository.UserRepository;
import edu.handong.csee.histudy.service.CourseService;
import edu.handong.csee.histudy.service.ImageService;
import edu.handong.csee.histudy.service.ReportService;
import edu.handong.csee.histudy.service.TeamService;
import io.jsonwebtoken.Claims;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.apache.commons.collections.map.SingletonMap;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;
import java.util.Optional;
Expand All @@ -33,6 +42,8 @@ public class TeamController {
private final ReportService reportService;
private final CourseService courseService;
private final TeamService teamService;
private final ImageService imageService;
private final UserRepository userRepository;

@Operation(summary = "그룹 스터디 보고서 생성")
@PostMapping("/reports")
Expand Down Expand Up @@ -124,4 +135,41 @@ public ResponseEntity<List<UserDto.UserMeWithMasking>> getTeamUsers(
}
throw new ForbiddenException();
}

/**
* 이미지를 업로드하고, 저장한 이미지 경로를 반환하는 API
*
* <p>스터디 보고서를 생성하는 API를 호출하기 전에 호출되어야 한다.
*
* @param image 이미지 파일
* @param claims 토큰 페이로드
* @return 저장한 이미지 경로
* @see #createReport(ReportForm, Claims)
*/
@Operation(summary = "스터디 보고서에 들어갈 인증 이미지 업로드")
@PostMapping(path = {"/reports/image", "/reports/{reportIdOr}/image"}, consumes = "multipart/form-data")
@ApiResponse(
responseCode = "200",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(
value = "{\"imagePath\": \"path/to/image\"}"
)
)
)
public ResponseEntity<SingletonMap> uploadImage(
@PathVariable(required = false) Optional<Long> reportIdOr,
@RequestParam MultipartFile image,
@RequestAttribute Claims claims) {
if (Role.isAuthorized(claims, Role.MEMBER)) {
StudyGroup studyGroup = userRepository.findUserByEmail(claims.getSubject())
.orElseThrow(UserNotFoundException::new)
.getStudyGroup();

String pathname = imageService.getImagePaths(image, studyGroup.getTag(), reportIdOr);
SingletonMap response = new SingletonMap("imagePath", pathname);
return ResponseEntity.ok(response);
}
throw new ForbiddenException();
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
package edu.handong.csee.histudy.controller.form;

import edu.handong.csee.histudy.domain.GroupCourse;
import edu.handong.csee.histudy.domain.GroupReport;
import edu.handong.csee.histudy.domain.StudyGroup;
import edu.handong.csee.histudy.domain.User;
import io.jsonwebtoken.Claims;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
Expand All @@ -32,26 +31,16 @@ public class ReportForm {
private List<Long> participants = new ArrayList<>();

/**
* Contains image URL
* 이미지 URL
*
* @see edu.handong.csee.histudy.controller.TeamController#uploadImage(Optional, MultipartFile, Claims)
*/
@Schema(description = "Image URLs of the report", type = "array", example = "[\"https://histudy.s3.ap-northeast-2.amazonaws.com/1.jpg\", \"https://histudy.s3.ap-northeast-2.amazonaws.com/2.jpg\"]")
@Schema(description = "Image URLs of the report", type = "array", example = "[\"/path/to/image1.png\", \"/path/to/image2.png\"]")
private List<String> images;

/**
* Contains course ID
*/
@Schema(description = "Course IDs of the report", type = "array", example = "[1, 2]")
private List<Long> courses = new ArrayList<>();

public GroupReport toEntity(StudyGroup studyGroup, List<User> participants, List<GroupCourse> courses) {
return GroupReport.builder()
.title(title)
.content(content)
.totalMinutes(totalMinutes)
.studyGroup(studyGroup)
.participants(participants)
.images(images)
.courses(courses)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package edu.handong.csee.histudy.exception;

public class FileTransferException extends RuntimeException {
public FileTransferException() {
super("이미지를 저장하는데 실패했습니다.");
}
}
142 changes: 142 additions & 0 deletions src/main/java/edu/handong/csee/histudy/service/ImageService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package edu.handong.csee.histudy.service;

import edu.handong.csee.histudy.domain.Image;
import edu.handong.csee.histudy.exception.FileTransferException;
import edu.handong.csee.histudy.exception.ReportNotFoundException;
import edu.handong.csee.histudy.repository.GroupReportRepository;
import edu.handong.csee.histudy.util.Utils;
import org.apache.commons.io.IOUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

import static org.springframework.util.ResourceUtils.isUrl;

@Service
@Transactional
public class ImageService {

@Value("${custom.resource.location}")
private String imageBaseLocation;
private final GroupReportRepository groupReportRepository;

public ImageService(GroupReportRepository groupReportRepository) {
this.groupReportRepository = groupReportRepository;
}

public String getImagePaths(MultipartFile imageAsFormData, Integer tag, Optional<Long> reportIdOr) {
if (reportIdOr.isPresent()) {
Long id = reportIdOr.get();
Optional<String> sameResource = getSameContent(imageAsFormData, id);

if (sameResource.isPresent()) {
return sameResource.get();
}
}
int year = Utils.getCurrentYear();
int semester = Utils.getCurrentSemester();
String formattedDateTime = Utils.getCurrentFormattedDateTime("yyyyMMdd_HHmmss");

String originalName = Objects.requireNonNullElse(imageAsFormData.getOriginalFilename(), ".jpg");
String extension = originalName.substring(originalName.lastIndexOf("."));

// yyyy-{1|2}-group{%02d}-report_{yyyyMMdd}_{HHmmss}.{extension}
// e.g. 2023-2-group1-report_20230923_123456.jpg
String pathname = String.format("%d-%d-group%02d-report_%s%s",
year,
semester,
tag,
formattedDateTime,
extension);
return saveImage(imageAsFormData, pathname);
}

private String saveImage(
MultipartFile image,
String pathname) {
try {
File file = new File(imageBaseLocation + File.separator + pathname);
File dir = file.getParentFile();

if (!dir.exists()) {
dir.mkdirs();
}
image.transferTo(file);
return pathname;
} catch (IOException e) {
throw new FileTransferException();
}
}

private Optional<String> getSameContent(MultipartFile src, Long reportId) {
List<String> targetPaths = groupReportRepository.findById(reportId)
.orElseThrow(ReportNotFoundException::new)
.getImages()
.stream()
.map(Image::getPath)
.toList();

return targetPaths.stream()
.filter(path -> {
try {
return (isUrl(path))
? contentMatches(src, new URL(path))
: contentMatches(src, Path.of(path));
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
}).findAny();
}

private boolean contentMatches(MultipartFile src, Path targetPath) {
try {
byte[] targetContent = Files.readAllBytes(targetPath);
return contentMatches(src.getBytes(), targetContent);
} catch (IOException e) {
throw new RuntimeException(e);
}
}

private boolean contentMatches(MultipartFile src, URL targetPath) {
try (InputStream in = targetPath.openStream()) {
byte[] targetContent = IOUtils.toByteArray(in);
return contentMatches(src.getBytes(), targetContent);
} catch (IOException e) {
throw new RuntimeException(e);
}
}

private boolean contentMatches(byte[] sourceContent, byte[] targetContent) {
return Arrays.equals(sourceContent, targetContent);
}

public Resource fetchImage(String imageName) {
try {
Path path = Paths.get(imageBaseLocation + imageName);
Resource resource = new UrlResource(path.toUri());

if (resource.exists() && resource.isReadable()) {
return resource;
}
throw new RuntimeException();
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
}
}
14 changes: 11 additions & 3 deletions src/main/java/edu/handong/csee/histudy/service/ReportService.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,17 @@ public ReportDto.ReportInfo createReport(ReportForm form, String email) {
.findAllByStudyGroup(user.getStudyGroup());
groupCourses.removeIf(gc -> !courses.contains(gc.getCourse()));

GroupReport saved = groupReportRepository.save(
form.toEntity(user.getStudyGroup(), participants, groupCourses));

GroupReport report = GroupReport.builder()
.title(form.getTitle())
.content(form.getContent())
.totalMinutes(form.getTotalMinutes())
.studyGroup(user.getStudyGroup())
.participants(participants)
.images(form.getImages())
.courses(groupCourses)
.build();

GroupReport saved = groupReportRepository.save(report);
return new ReportDto.ReportInfo(saved);
}

Expand Down
22 changes: 22 additions & 0 deletions src/main/java/edu/handong/csee/histudy/util/Utils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package edu.handong.csee.histudy.util;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class Utils {

public static int getCurrentSemester() {
int month = LocalDate.now().getMonthValue();
return (month >= 3 && month <= 8) ? 1 : 2;
}

public static int getCurrentYear() {
return LocalDate.now().getYear();
}

public static String getCurrentFormattedDateTime(String pattern) {
return LocalDateTime.now()
.format(DateTimeFormatter.ofPattern(pattern));
}
}