Skip to content
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
15 changes: 15 additions & 0 deletions src/main/java/com/debatetimer/client/storage/IconFile.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.debatetimer.client.storage;

import java.io.IOException;
import java.io.InputStream;
import org.springframework.core.io.Resource;

/**
* 아이콘 파일. 내용은 필요할 때 스트림으로 읽어 여러 파일을 한꺼번에 메모리에 올리지 않는다.
*/
public record IconFile(String fileName, Resource resource) {

public InputStream open() throws IOException {
return resource.getInputStream();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Component;
Expand All @@ -22,7 +24,8 @@
@Component
public class IconFileStorage {

private static final String BUNDLED_ICON_PATTERN = "classpath:/static/icon/*";
private static final String BUNDLED_ICON_LOCATION = "static/icon/";
private static final String BUNDLED_ICON_PATTERN = "classpath:/" + BUNDLED_ICON_LOCATION + "*";

private final Path uploadDir;
private final List<String> bundledFileNames;
Expand Down Expand Up @@ -86,4 +89,16 @@ public boolean saveIfAbsent(String fileName, byte[] content) {
throw new DTServerErrorException(ServerErrorCode.ICON_STORAGE_ERROR);
}
}

/**
* 기본 아이콘과 업로드한 아이콘을 모두 찾는다. 내용은 읽지 않는다.
*/
public List<IconFile> findAll() {
return Stream.concat(
bundledFileNames.stream()
.map(fileName -> new IconFile(fileName, new ClassPathResource(BUNDLED_ICON_LOCATION + fileName))),
findUploadedFileNames().stream()
.map(fileName -> new IconFile(fileName, new FileSystemResource(uploadDir.resolve(fileName))))
).toList();
}
}
9 changes: 9 additions & 0 deletions src/main/java/com/debatetimer/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.debatetimer.config;

import com.debatetimer.client.storage.IconStorageProperties;
import com.debatetimer.controller.admin.AdminAuthInterceptor;
import com.debatetimer.controller.tool.jwt.AuthManager;
import com.debatetimer.domain.organization.OrganizationIcon;
import com.debatetimer.service.auth.AuthService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

Expand All @@ -18,12 +20,19 @@ public class WebConfig implements WebMvcConfigurer {
private final AuthManager authManager;
private final AuthService authService;
private final IconStorageProperties iconStorageProperties;
private final AdminAuthInterceptor adminAuthInterceptor;

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(new AuthMemberArgumentResolver(authManager, authService));
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(adminAuthInterceptor)
.addPathPatterns(AdminAuthInterceptor.ADMIN_API_PATTERN);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

// 업로드한 아이콘을 먼저 찾고, 없으면 배포에 포함된 기본 아이콘을 찾는다
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.debatetimer.controller.admin;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.cors.CorsUtils;
import org.springframework.web.servlet.HandlerInterceptor;

/**
* 어드민 API 의 비밀번호를 요청 파라미터 바인딩보다 먼저 확인한다.
* 비밀번호 없이 필수 파라미터까지 빠진 요청도 400 이 아니라 401 로 거절하기 위함이다.
*/
@Component
@RequiredArgsConstructor
public class AdminAuthInterceptor implements HandlerInterceptor {

public static final String ADMIN_API_PATTERN = "/api/admin/**";
public static final String ADMIN_PASSWORD_HEADER = "X-Admin-Password";

private final AdminAuthorizer adminAuthorizer;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (CorsUtils.isPreFlightRequest(request)) {
return true;
}
adminAuthorizer.authorize(request.getHeader(ADMIN_PASSWORD_HEADER));
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,41 +7,49 @@
import com.debatetimer.service.admin.AdminIconService;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

@RestController
@RequiredArgsConstructor
public class AdminIconController {

private final AdminAuthorizer adminAuthorizer;
private static final String ICON_ZIP_FILE_NAME = "organization-icons.zip";

private final AdminIconService adminIconService;

@GetMapping("/api/admin/icons")
@ResponseStatus(HttpStatus.OK)
public IconResponses getIcons(
@RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password
) {
adminAuthorizer.authorize(password);
public IconResponses getIcons() {
return adminIconService.findAll();
}

@GetMapping("/api/admin/icons/download")
public ResponseEntity<StreamingResponseBody> downloadIcons() {
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("application/zip"))
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(ICON_ZIP_FILE_NAME).build().toString())
.body(adminIconService::writeZip);
}

@PostMapping(value = "/api/admin/icons", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public IconResponse uploadIcon(
@RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password,
@RequestPart("file") MultipartFile file,
@RequestParam(name = "name", required = false) String name
) {
adminAuthorizer.authorize(password);
String fileName = (name == null || name.isBlank()) ? file.getOriginalFilename() : name;
return adminIconService.upload(fileName, readBytes(file));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,44 +13,35 @@
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AdminOrganizationController {

private final AdminAuthorizer adminAuthorizer;
private final AdminOrganizationService adminOrganizationService;

@GetMapping("/api/admin/organizations")
@ResponseStatus(HttpStatus.OK)
public AdminOrganizationsResponse getOrganizations(
@RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password
) {
adminAuthorizer.authorize(password);
public AdminOrganizationsResponse getOrganizations() {
return adminOrganizationService.findAll();
}

@PostMapping("/api/admin/organizations")
@ResponseStatus(HttpStatus.CREATED)
public AdminOrganizationResponse createOrganization(
@RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password,
@Valid @RequestBody OrganizationCreateRequest request
) {
adminAuthorizer.authorize(password);
return adminOrganizationService.create(request);
}

@PostMapping("/api/admin/organizations/{organizationId}/templates")
@ResponseStatus(HttpStatus.CREATED)
public AdminOrganizationTemplateResponse createTemplate(
@RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password,
@PathVariable long organizationId,
@Valid @RequestBody OrganizationTemplateCreateRequest request
) {
adminAuthorizer.authorize(password);
return adminOrganizationService.createTemplate(organizationId, request);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -16,19 +15,14 @@
@RequiredArgsConstructor
public class AdminSharingController {

public static final String ADMIN_PASSWORD_HEADER = "X-Admin-Password";

private final AdminAuthorizer adminAuthorizer;
private final SharingStatsService sharingStatsService;

@GetMapping("/api/admin/sharing/stats")
@ResponseStatus(HttpStatus.OK)
public SharingStatsResponse getSharingStats(
@RequestHeader(name = ADMIN_PASSWORD_HEADER, required = false) String password,
@RequestParam("from") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam("to") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to
) {
adminAuthorizer.authorize(password);
return sharingStatsService.getDailyStats(from, to);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -19,31 +18,26 @@
@RequiredArgsConstructor
public class AdminStatsController {

private final AdminAuthorizer adminAuthorizer;
private final TableUsageStatsService tableUsageStatsService;
private final MemberSignupStatsService memberSignupStatsService;

@GetMapping("/api/admin/tables/stats")
@ResponseStatus(HttpStatus.OK)
public TableUsageStatsResponse getTableUsageStats(
@RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password,
@RequestParam("unit") StatUnit unit,
@RequestParam("from") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam("to") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to
) {
adminAuthorizer.authorize(password);
return tableUsageStatsService.getStats(unit, from, to);
}

@GetMapping("/api/admin/members/stats")
@ResponseStatus(HttpStatus.OK)
public MemberSignupStatsResponse getMemberSignupStats(
@RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password,
@RequestParam("unit") StatUnit unit,
@RequestParam("from") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam("to") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to
) {
adminAuthorizer.authorize(password);
return memberSignupStatsService.getStats(unit, from, to);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -17,28 +16,21 @@
@RequiredArgsConstructor
public class AdminTableController {

private final AdminAuthorizer adminAuthorizer;
private final AdminTableService adminTableService;

@GetMapping("/api/admin/tables")
@ResponseStatus(HttpStatus.OK)
public AdminTablePageResponse getTables(
@RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password,
@RequestParam(name = "sort", defaultValue = "CREATED_AT") CustomizeTableSortType sort,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size
) {
adminAuthorizer.authorize(password);
return adminTableService.getTables(sort, page, size);
}

@GetMapping("/api/admin/tables/{tableId}")
@ResponseStatus(HttpStatus.OK)
public AdminTableDetailResponse getTable(
@RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password,
@PathVariable long tableId
) {
adminAuthorizer.authorize(password);
public AdminTableDetailResponse getTable(@PathVariable long tableId) {
return adminTableService.getTable(tableId);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.debatetimer.domainrepository.organization;

import com.debatetimer.client.storage.IconFile;
import com.debatetimer.client.storage.IconFileStorage;
import com.debatetimer.domain.organization.OrganizationIcon;
import com.debatetimer.exception.custom.DTClientErrorException;
Expand All @@ -22,6 +23,10 @@ public List<String> findUploadedPaths() {
return toPaths(iconFileStorage.findUploadedFileNames());
}

public List<IconFile> findAllFiles() {
return iconFileStorage.findAll();
}

private List<String> toPaths(List<String> fileNames) {
return fileNames.stream()
.map(fileName -> OrganizationIcon.PATH_PREFIX + fileName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ public enum ClientErrorCode implements ResponseErrorCode {
FIELD_ERROR(HttpStatus.BAD_REQUEST, "입력이 잘못되었습니다."),
URL_PARAMETER_ERROR(HttpStatus.BAD_REQUEST, "입력이 잘못되었습니다."),
METHOD_ARGUMENT_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, "입력한 값의 타입이 잘못되었습니다."),
MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST, "필수 요청 값이 누락되었습니다."),
MESSAGE_NOT_READABLE(HttpStatus.BAD_REQUEST, "요청 본문을 읽을 수 없습니다."),
NO_RESOURCE_FOUND(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."),
METHOD_NOT_SUPPORTED(HttpStatus.METHOD_NOT_ALLOWED, "허용되지 않은 메서드입니다."),
MEDIA_TYPE_NOT_SUPPORTED(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "허용되지 않은 미디어 타입입니다."),
Expand Down
Loading
Loading