Skip to content

Commit

Permalink
Merge pull request #140 from HGU-WALAB/HISTUDY-139
Browse files Browse the repository at this point in the history
Feat: 스터디 보고서 이미지 업로드 (#139)
  • Loading branch information
ohinhyuk authored Oct 22, 2023
2 parents 432dd48 + cce77a6 commit 54eff8f
Show file tree
Hide file tree
Showing 7 changed files with 250 additions and 21 deletions.
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));
}
}

0 comments on commit 54eff8f

Please sign in to comment.