Compare commits
2 Commits
bd6fe924de
...
62398846d3
| Author | SHA1 | Date | |
|---|---|---|---|
| 62398846d3 | |||
| 260569225c |
@@ -137,6 +137,6 @@ public class SecurityConfig {
|
||||
/** 완전 제외(필터 자체를 안 탐) */
|
||||
@Bean
|
||||
public WebSecurityCustomizer webSecurityCustomizer() {
|
||||
return (web) -> web.ignoring().requestMatchers("/api/mapsheet/**");
|
||||
return (web) -> web.ignoring().requestMatchers("/api/mapsheet/**", "/api/file-manager/**");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.kamco.cd.training.filemanager;
|
||||
|
||||
import com.kamco.cd.training.config.api.ApiResponseDto;
|
||||
import com.kamco.cd.training.filemanager.dto.FileManagerDto;
|
||||
import com.kamco.cd.training.filemanager.service.FileManagerService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.ExampleObject;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@Slf4j
|
||||
@Tag(name = "파일 관리", description = "/data 디렉토리 파일 관리 API")
|
||||
@RestController
|
||||
@RequestMapping("/api/file-manager")
|
||||
@RequiredArgsConstructor
|
||||
public class FileManagerApiController {
|
||||
|
||||
private final FileManagerService fileManagerService;
|
||||
|
||||
@Operation(
|
||||
summary = "파일 목록 조회",
|
||||
description = "/data 디렉토리 내 파일 및 디렉토리 목록을 조회합니다. recursive=true로 설정하면 하위 디렉토리까지 조회합니다.")
|
||||
@ApiResponses(
|
||||
value = {
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "조회 성공",
|
||||
content =
|
||||
@Content(
|
||||
mediaType = "application/json",
|
||||
schema = @Schema(implementation = FileManagerDto.ListFilesRes.class))),
|
||||
@ApiResponse(responseCode = "400", description = "잘못된 요청 (유효하지 않은 경로)", content = @Content),
|
||||
@ApiResponse(responseCode = "404", description = "디렉토리를 찾을 수 없음", content = @Content),
|
||||
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||
})
|
||||
@GetMapping("/files")
|
||||
public ApiResponseDto<FileManagerDto.ListFilesRes> listFiles(
|
||||
@Parameter(description = "조회할 디렉토리 경로 (기본값: /data)", example = "/data/request")
|
||||
@RequestParam(required = false)
|
||||
String directoryPath,
|
||||
@Parameter(description = "하위 디렉토리 포함 여부", example = "false")
|
||||
@RequestParam(required = false, defaultValue = "false")
|
||||
Boolean recursive) {
|
||||
|
||||
FileManagerDto.ListFilesReq request =
|
||||
FileManagerDto.ListFilesReq.builder()
|
||||
.directoryPath(directoryPath)
|
||||
.recursive(recursive)
|
||||
.build();
|
||||
|
||||
FileManagerDto.ListFilesRes response = fileManagerService.listFiles(request);
|
||||
return ApiResponseDto.ok(response);
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "파일/디렉토리 삭제",
|
||||
description = "지정된 파일 또는 디렉토리를 삭제합니다. recursive=true로 설정하면 디렉토리 내 모든 파일을 삭제합니다.",
|
||||
requestBody =
|
||||
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||
content =
|
||||
@Content(
|
||||
mediaType = "application/json",
|
||||
schema = @Schema(implementation = FileManagerDto.DeleteFileReq.class),
|
||||
examples = {
|
||||
@ExampleObject(
|
||||
name = "단일 파일 삭제",
|
||||
value =
|
||||
"""
|
||||
{
|
||||
"filePaths": ["/data/request/old_file.zip"],
|
||||
"recursive": false
|
||||
}
|
||||
"""),
|
||||
@ExampleObject(
|
||||
name = "여러 파일 삭제",
|
||||
value =
|
||||
"""
|
||||
{
|
||||
"filePaths": ["/data/file1.txt", "/data/file2.txt"],
|
||||
"recursive": false
|
||||
}
|
||||
"""),
|
||||
@ExampleObject(
|
||||
name = "디렉토리 전체 삭제",
|
||||
value =
|
||||
"""
|
||||
{
|
||||
"filePaths": ["/data/old_folder"],
|
||||
"recursive": true
|
||||
}
|
||||
""")
|
||||
})))
|
||||
@ApiResponses(
|
||||
value = {
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "삭제 성공",
|
||||
content =
|
||||
@Content(
|
||||
mediaType = "application/json",
|
||||
schema = @Schema(implementation = FileManagerDto.DeleteFileRes.class))),
|
||||
@ApiResponse(responseCode = "400", description = "잘못된 요청 (유효하지 않은 경로)", content = @Content),
|
||||
@ApiResponse(responseCode = "404", description = "파일을 찾을 수 없음", content = @Content),
|
||||
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||
})
|
||||
@DeleteMapping("/files")
|
||||
public ApiResponseDto<FileManagerDto.DeleteFileRes> deleteFiles(
|
||||
@RequestBody FileManagerDto.DeleteFileReq request) {
|
||||
|
||||
FileManagerDto.DeleteFileRes response = fileManagerService.deleteFiles(request);
|
||||
return ApiResponseDto.ok(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.kamco.cd.training.filemanager.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
public class FileManagerDto {
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "파일 정보")
|
||||
public static class FileInfo {
|
||||
@Schema(description = "파일명", example = "dataset.zip")
|
||||
private String fileName;
|
||||
|
||||
@Schema(description = "파일 전체 경로", example = "/data/request/dataset.zip")
|
||||
private String filePath;
|
||||
|
||||
@Schema(description = "파일 크기 (bytes)", example = "1024000")
|
||||
private Long fileSize;
|
||||
|
||||
@Schema(description = "파일인지 디렉토리인지 여부", example = "true")
|
||||
private Boolean isFile;
|
||||
|
||||
@Schema(description = "디렉토리인지 여부", example = "false")
|
||||
private Boolean isDirectory;
|
||||
|
||||
@Schema(description = "마지막 수정 시간", example = "2026-04-06T15:30:00")
|
||||
private LocalDateTime lastModified;
|
||||
|
||||
@Schema(description = "읽기 권한", example = "true")
|
||||
private Boolean readable;
|
||||
|
||||
@Schema(description = "쓰기 권한", example = "true")
|
||||
private Boolean writable;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "디렉토리 목록 조회 요청")
|
||||
public static class ListFilesReq {
|
||||
@Schema(description = "조회할 디렉토리 경로 (기본값: /data)", example = "/data/request")
|
||||
private String directoryPath;
|
||||
|
||||
@Schema(description = "하위 디렉토리 포함 여부", example = "false")
|
||||
private Boolean recursive;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "디렉토리 목록 조회 응답")
|
||||
public static class ListFilesRes {
|
||||
@Schema(description = "조회된 디렉토리 경로", example = "/data/request")
|
||||
private String directoryPath;
|
||||
|
||||
@Schema(description = "파일 목록")
|
||||
private List<FileInfo> files;
|
||||
|
||||
@Schema(description = "총 파일 개수", example = "10")
|
||||
private Integer totalCount;
|
||||
|
||||
@Schema(description = "총 파일 크기 (bytes)", example = "10240000")
|
||||
private Long totalSize;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "파일 삭제 요청")
|
||||
public static class DeleteFileReq {
|
||||
@Schema(
|
||||
description = "삭제할 파일 또는 디렉토리 경로 목록",
|
||||
example = "[\"/data/request/old_file.zip\", \"/data/tmp/test_folder\"]")
|
||||
private List<String> filePaths;
|
||||
|
||||
@Schema(description = "디렉토리일 경우 하위 파일 포함 삭제 여부", example = "true")
|
||||
private Boolean recursive;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "파일 삭제 응답")
|
||||
public static class DeleteFileRes {
|
||||
@Schema(description = "삭제 성공한 파일 경로 목록")
|
||||
private List<String> deletedFiles;
|
||||
|
||||
@Schema(description = "삭제 실패한 파일 경로 목록")
|
||||
private List<String> failedFiles;
|
||||
|
||||
@Schema(description = "삭제 성공 개수", example = "5")
|
||||
private Integer successCount;
|
||||
|
||||
@Schema(description = "삭제 실패 개수", example = "0")
|
||||
private Integer failureCount;
|
||||
|
||||
@Schema(description = "전체 메시지", example = "5개 파일 삭제 성공")
|
||||
private String message;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "디스크 사용량 조회 응답")
|
||||
public static class DiskUsageRes {
|
||||
@Schema(description = "디렉토리 경로", example = "/data")
|
||||
private String directoryPath;
|
||||
|
||||
@Schema(description = "총 용량 (bytes)", example = "1000000000000")
|
||||
private Long totalSpace;
|
||||
|
||||
@Schema(description = "사용 가능 용량 (bytes)", example = "500000000000")
|
||||
private Long usableSpace;
|
||||
|
||||
@Schema(description = "사용 중인 용량 (bytes)", example = "500000000000")
|
||||
private Long usedSpace;
|
||||
|
||||
@Schema(description = "사용률 (%)", example = "50.0")
|
||||
private Double usagePercentage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.kamco.cd.training.filemanager.service;
|
||||
|
||||
import com.kamco.cd.training.filemanager.dto.FileManagerDto;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FileManagerService {
|
||||
|
||||
private static final String BASE_DATA_PATH = "/data";
|
||||
private static final long MAX_PATH_LENGTH = 500;
|
||||
|
||||
/**
|
||||
* 디렉토리 내 파일 목록 조회
|
||||
*
|
||||
* @param request 조회 요청 정보
|
||||
* @return 파일 목록 응답
|
||||
*/
|
||||
public FileManagerDto.ListFilesRes listFiles(FileManagerDto.ListFilesReq request) {
|
||||
String targetPath =
|
||||
request.getDirectoryPath() != null ? request.getDirectoryPath() : BASE_DATA_PATH;
|
||||
|
||||
boolean recursive = request.getRecursive() != null && request.getRecursive();
|
||||
|
||||
validatePath(targetPath);
|
||||
|
||||
Path directory = Paths.get(targetPath);
|
||||
if (!Files.exists(directory)) {
|
||||
throw new IllegalArgumentException("디렉토리가 존재하지 않습니다: " + targetPath);
|
||||
}
|
||||
|
||||
if (!Files.isDirectory(directory)) {
|
||||
throw new IllegalArgumentException("디렉토리 경로가 아닙니다: " + targetPath);
|
||||
}
|
||||
|
||||
List<FileManagerDto.FileInfo> files = new ArrayList<>();
|
||||
long totalSize = 0;
|
||||
|
||||
try {
|
||||
if (recursive) {
|
||||
// 재귀적으로 모든 하위 파일 조회
|
||||
Files.walkFileTree(
|
||||
directory,
|
||||
new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||
files.add(createFileInfo(file));
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
|
||||
if (!dir.equals(directory)) {
|
||||
files.add(createFileInfo(dir));
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 현재 디렉토리의 파일만 조회
|
||||
try (Stream<Path> stream = Files.list(directory)) {
|
||||
stream.forEach(path -> files.add(createFileInfo(path)));
|
||||
}
|
||||
}
|
||||
|
||||
// 총 파일 크기 계산
|
||||
for (FileManagerDto.FileInfo file : files) {
|
||||
if (file.getIsFile() && file.getFileSize() != null) {
|
||||
totalSize += file.getFileSize();
|
||||
}
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("파일 목록 조회 중 오류 발생: {}", targetPath, e);
|
||||
throw new RuntimeException("파일 목록 조회에 실패했습니다: " + e.getMessage());
|
||||
}
|
||||
|
||||
return FileManagerDto.ListFilesRes.builder()
|
||||
.directoryPath(targetPath)
|
||||
.files(files)
|
||||
.totalCount(files.size())
|
||||
.totalSize(totalSize)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 또는 디렉토리 삭제
|
||||
*
|
||||
* @param request 삭제 요청 정보
|
||||
* @return 삭제 결과
|
||||
*/
|
||||
public FileManagerDto.DeleteFileRes deleteFiles(FileManagerDto.DeleteFileReq request) {
|
||||
List<String> deletedFiles = new ArrayList<>();
|
||||
List<String> failedFiles = new ArrayList<>();
|
||||
|
||||
boolean recursive = request.getRecursive() != null && request.getRecursive();
|
||||
|
||||
for (String filePath : request.getFilePaths()) {
|
||||
try {
|
||||
validatePath(filePath);
|
||||
Path path = Paths.get(filePath);
|
||||
|
||||
if (!Files.exists(path)) {
|
||||
log.warn("삭제하려는 파일이 존재하지 않습니다: {}", filePath);
|
||||
failedFiles.add(filePath + " (파일이 존재하지 않음)");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Files.isDirectory(path)) {
|
||||
if (recursive) {
|
||||
// 디렉토리 및 하위 파일 모두 삭제
|
||||
deleteDirectoryRecursively(path);
|
||||
deletedFiles.add(filePath);
|
||||
} else {
|
||||
// 빈 디렉토리만 삭제
|
||||
if (isDirectoryEmpty(path)) {
|
||||
Files.delete(path);
|
||||
deletedFiles.add(filePath);
|
||||
} else {
|
||||
failedFiles.add(filePath + " (디렉토리가 비어있지 않음)");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 파일 삭제
|
||||
Files.delete(path);
|
||||
deletedFiles.add(filePath);
|
||||
}
|
||||
|
||||
log.info("파일 삭제 성공: {}", filePath);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("파일 삭제 실패: {}", filePath, e);
|
||||
failedFiles.add(filePath + " (" + e.getMessage() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
String message =
|
||||
String.format("%d개 파일 삭제 성공, %d개 파일 삭제 실패", deletedFiles.size(), failedFiles.size());
|
||||
|
||||
return FileManagerDto.DeleteFileRes.builder()
|
||||
.deletedFiles(deletedFiles)
|
||||
.failedFiles(failedFiles)
|
||||
.successCount(deletedFiles.size())
|
||||
.failureCount(failedFiles.size())
|
||||
.message(message)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** FileInfo 객체 생성 */
|
||||
private FileManagerDto.FileInfo createFileInfo(Path path) {
|
||||
try {
|
||||
BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
|
||||
|
||||
return FileManagerDto.FileInfo.builder()
|
||||
.fileName(path.getFileName().toString())
|
||||
.filePath(path.toString())
|
||||
.fileSize(attrs.isRegularFile() ? attrs.size() : null)
|
||||
.isFile(attrs.isRegularFile())
|
||||
.isDirectory(attrs.isDirectory())
|
||||
.lastModified(
|
||||
LocalDateTime.ofInstant(
|
||||
Instant.ofEpochMilli(attrs.lastModifiedTime().toMillis()),
|
||||
ZoneId.systemDefault()))
|
||||
.readable(Files.isReadable(path))
|
||||
.writable(Files.isWritable(path))
|
||||
.build();
|
||||
} catch (IOException e) {
|
||||
log.warn("파일 정보 조회 실패: {}", path, e);
|
||||
return FileManagerDto.FileInfo.builder()
|
||||
.fileName(path.getFileName().toString())
|
||||
.filePath(path.toString())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/** 디렉토리 재귀 삭제 */
|
||||
private void deleteDirectoryRecursively(Path directory) throws IOException {
|
||||
Files.walkFileTree(
|
||||
directory,
|
||||
new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
|
||||
throws IOException {
|
||||
Files.delete(file);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
|
||||
Files.delete(dir);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 디렉토리가 비어있는지 확인 */
|
||||
private boolean isDirectoryEmpty(Path directory) throws IOException {
|
||||
try (Stream<Path> stream = Files.list(directory)) {
|
||||
return stream.findFirst().isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/** 경로 검증 (보안) */
|
||||
private void validatePath(String path) {
|
||||
if (path == null || path.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("경로가 비어있습니다");
|
||||
}
|
||||
|
||||
if (path.length() > MAX_PATH_LENGTH) {
|
||||
throw new IllegalArgumentException("경로가 너무 깁니다");
|
||||
}
|
||||
|
||||
// 경로 순회 공격 방지 - 상대경로 패턴만 제한
|
||||
if (path.contains("..")) {
|
||||
throw new IllegalArgumentException("상대 경로(..)는 사용할 수 없습니다");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user