[api] 파일업로드, tus파일 주소변경, md 파일 변경

This commit is contained in:
2026-05-08 17:21:19 +09:00
parent 0fc91fddac
commit 13e41f6e3c
23 changed files with 981 additions and 268 deletions
@@ -78,7 +78,7 @@ public class SecurityConfig {
.accessDeniedHandler(new JwtAccessDeniedHandler())
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/actuator/health", "/sso/**", "/auth/**", "/user/signup", "/user/migrationUserList", "/files/tusHook").permitAll()
.requestMatchers("/", "/actuator/health", "/sso/**", "/auth/**", "/user/signup", "/user/migrationUserList", "/tusFiles/tusHook", "/tusFiles/uploadAuth").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
@@ -98,4 +98,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return null;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
return "/tusFiles/uploadAuth".equals(uri)
|| "/tusFiles/tusHook".equals(uri);
}
}
@@ -2,16 +2,26 @@ package com.alist.api.modules.admin.auth;
import com.alist.api.common.response.ApiResponse;
import com.alist.api.common.response.ApiResponseCode;
import com.alist.api.common.utils.SecurityUtil;
import com.alist.api.common.utils.SessionUtil;
import com.alist.api.modules.admin.auth.form.AdminLoginForm;
import com.alist.api.modules.admin.auth.service.AdminAuthService;
import com.alist.api.modules.admin.auth.vo.AdminLoginVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@Tag(
name = "03. Admin 로그인"
, description = "ADMIN 사이트 로그인입니다. 로그인시 토큰 발급이 함께됩니다."
)
@RestController
@RequestMapping("/admin/auth")
public class AdminAuthController {
@@ -22,6 +32,10 @@ public class AdminAuthController {
this.adminAuthService = adminAuthService;
}
@Operation(
summary = "admin 로그인"
, description = "admin로그인 sso와 다르게 로그인시 토큰이 함께 발급됩니다."
)
@PostMapping("/login")
public ResponseEntity<ApiResponse<AdminLoginVo>> adminLogin(
@Valid @RequestBody AdminLoginForm adminLoginForm
@@ -36,10 +50,14 @@ public class AdminAuthController {
return ApiResponse.entity(result, ApiResponseCode.CODE_2001, "관리자 로그인");
}
@Operation(
summary = "admin 리프레시토큰 발급"
, description = "엑세스 토큰 만료시 리프레시 토큰 발급 용도 api 입니다."
)
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<Map<String, Object>>> refresh(
@CookieValue(name = "adminRefreshToken", required = false) String refreshToken,
HttpServletResponse response
@CookieValue(name = "adminRefreshToken", required = false) String refreshToken
, HttpServletResponse response
) {
AdminLoginVo result = adminAuthService.adminRefresh(refreshToken, response);
@@ -50,10 +68,53 @@ public class AdminAuthController {
return ApiResponse.entity(Map.of("refreshed", true), ApiResponseCode.CODE_200);
}
@Operation(
summary = "admin 로그인 상태 확인",
description = "admin 쿠키 기준으로 현재 공통 로그인 상태가 유효한지 확인하고, access/refresh 토큰 존재 여부도 함께 반환합니다."
)
@GetMapping("/loginChecked")
public ResponseEntity<ApiResponse<Map<String, Object>>> loginChecked(
HttpServletRequest request
) {
Map<String, Object> result = new HashMap<>();
String accessToken = SessionUtil.resolveSsoCookieValue(request, "adminAccessToken");
String refreshToken = SessionUtil.resolveSsoCookieValue(request, "adminRefreshToken");
result.put("isAdminAccessToken", accessToken != null && !accessToken.trim().isEmpty());
result.put("isAdminRefreshToken", refreshToken != null && !refreshToken.trim().isEmpty());
Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
if (userTokenIdx == null) {
result.put("loggedIn", false);
return ApiResponse.entity(result, ApiResponseCode.CODE_200);
}
AdminLoginVo admin = adminAuthService.selectAdminTokenByUserTokenIdx(userTokenIdx);
if (admin == null) {
result.put("loggedIn", false);
return ApiResponse.entity(result, ApiResponseCode.CODE_200);
}
result.put("loggedIn", true);
result.put("userId", admin.getId());
result.put("userIdx", admin.getUserIdx());
result.put("userTokenIdx", userTokenIdx);
result.put("userRole", "ADMIN");
return ApiResponse.entity(result, ApiResponseCode.CODE_200);
}
@Operation(
summary = "admin 로그아웃"
, description = "admin 로그아웃"
)
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Map<String, Object>>> logout(
@CookieValue(name = "adminRefreshToken", required = false) String refreshToken,
HttpServletResponse response
@CookieValue(name = "adminRefreshToken", required = false) String refreshToken
, HttpServletResponse response
) {
adminAuthService.adminLogout(refreshToken, response);
@@ -150,4 +150,12 @@ public class AdminAuthService {
} catch (NumberFormatException ignored) {
}
}
public AdminLoginVo selectAdminTokenByUserTokenIdx(Integer userTokenIdx) {
if (userTokenIdx == null) {
return null;
}
return adminAuthMapper.selectAdminTokenByUserTokenIdx(userTokenIdx);
}
}
@@ -0,0 +1,104 @@
package com.alist.api.modules.file;
import com.alist.api.common.response.ApiResponse;
import com.alist.api.common.response.ApiResponseCode;
import com.alist.api.modules.file.service.FileService;
import com.alist.api.modules.file.vo.FileUploadVo;
import com.alist.api.modules.file.vo.SunEditorUploadVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import java.util.ArrayList;
import java.util.List;
@Tag(
name = "98. 파일 업로드",
description = "DB 기록 없이 파일을 저장하고 uploadPath를 반환하는 단순 업로드 API입니다. SunEditor 이미지는 file-domain URL을 반환합니다."
)
@RestController
@RequestMapping({"/files", "/admin/files"})
public class FileController {
private final FileService fileService;
public FileController(FileService fileService) {
this.fileService = fileService;
}
@Operation(
summary = "단순 파일 업로드",
description = """
DB에 기록하지 않고 파일만 저장합니다.
반환된 uploadPath는 이후 각 업무 테이블에 저장해서 view/download 경로로 사용할 수 있습니다.
folder 값은 저장 폴더 키이며, 설정된 타입이면 해당 정책을 사용하고 설정되지 않은 값이면 전역 정책만 적용합니다.
"""
)
@PostMapping("/upload")
public ResponseEntity<ApiResponse<FileUploadVo>> upload(
@RequestParam String folder,
@RequestPart MultipartFile file
) {
FileUploadVo result = fileService.upload(folder, file);
return ApiResponse.entity(result, ApiResponseCode.CODE_200);
}
@Operation(
summary = "SunEditor 이미지 업로드",
description = """
SunEditor 이미지 업로드 전용 API입니다.
업로드 파일을 저장한 뒤 SunEditor가 요구하는 result 배열 형태로 응답합니다.
응답 URL은 file.upload.view.file-domain 값과 uploadPath를 조합한 공개 file-domain URL입니다.
"""
)
@PostMapping("/suneditor/upload")
public ResponseEntity<SunEditorUploadVo> sunEditorUpload(
@RequestParam(value = "folder", defaultValue = "editor") String folder,
MultipartHttpServletRequest request
) {
List<SunEditorUploadVo.Item> result = new ArrayList<>();
for (MultipartFile file : request.getFileMap().values()) {
FileUploadVo uploaded = fileService.upload(folder, file);
String imageUrl = fileService.buildImageUrl(uploaded.getUploadPath());
result.add(new SunEditorUploadVo.Item(
imageUrl,
uploaded.getOriginalFileName(),
uploaded.getFileSize()
));
}
return ResponseEntity.ok(new SunEditorUploadVo(result));
}
@Operation(
summary = "파일 보기",
description = """
uploadPath 기준으로 파일을 inline 응답합니다.
예: /uploads/notice/2026/05/08/sample.png
단순 업로드 결과의 uploadPath를 그대로 path 파라미터에 전달합니다.
"""
)
@GetMapping("/view")
public ResponseEntity<Resource> view(@RequestParam String path) {
return fileService.resource(path, true);
}
@Operation(
summary = "파일 다운로드",
description = """
uploadPath 기준으로 파일을 attachment 응답합니다.
예: /uploads/notice/2026/05/08/sample.pdf
단순 업로드 결과의 uploadPath를 그대로 path 파라미터에 전달합니다.
"""
)
@GetMapping("/download")
public ResponseEntity<Resource> download(@RequestParam String path) {
return fileService.resource(path, false);
}
}
@@ -0,0 +1,10 @@
package com.alist.api.modules.file.config;
import com.alist.api.modules.file.properties.FileUploadProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@EnableConfigurationProperties(FileUploadProperties.class)
@Configuration
public class FileUploadConfig {
}
@@ -0,0 +1,45 @@
package com.alist.api.modules.file.properties;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.unit.DataSize;
import java.util.List;
import java.util.Map;
@Getter
@Setter
@ConfigurationProperties(prefix = "file.upload")
public class FileUploadProperties {
private String rootPath;
private DataSize maxSize;
private List<String> allowedExtensions;
private Map<String, UploadType> types;
private View view = new View();
@Getter
@Setter
public static class UploadType {
private String folder;
private DataSize maxSize;
private boolean imageOnly;
private Resize resize = new Resize();
}
@Getter
@Setter
public static class Resize {
private boolean enabled;
private Integer width;
private Integer height;
private Integer maxWidth;
private Float quality = 0.9f;
}
@Getter
@Setter
public static class View {
private String fileDomain;
}
}
@@ -0,0 +1,240 @@
package com.alist.api.modules.file.service;
import com.alist.api.modules.file.properties.FileUploadProperties;
import com.alist.api.modules.file.vo.FileUploadVo;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.LocalDate;
import java.util.Locale;
import java.util.UUID;
@Service
public class FileService {
private static final String PUBLIC_PREFIX = "/uploads";
private final FileUploadProperties properties;
private final FileUploadImageService fileUploadImageService;
public FileService(FileUploadProperties properties, FileUploadImageService fileUploadImageService) {
this.properties = properties;
this.fileUploadImageService = fileUploadImageService;
}
public FileUploadVo upload(String folder, MultipartFile file) {
if (folder == null || folder.isBlank()) throw new IllegalArgumentException("invalid folder");
if (file == null || file.isEmpty()) throw new IllegalArgumentException("empty file");
FileUploadProperties.UploadType uploadType = resolveUploadType(folder);
String originalFileName = cleanFileName(file.getOriginalFilename());
String ext = extractExt(originalFileName);
validateExtension(ext);
validateSize(file.getSize(), uploadType);
boolean image = isImageExtension(ext);
if (uploadType.isImageOnly() && !image) throw new IllegalArgumentException("image only");
LocalDate now = LocalDate.now();
String storedFileName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path savePath = Paths.get(
properties.getRootPath(),
uploadType.getFolder(),
String.valueOf(now.getYear()),
"%02d".formatted(now.getMonthValue()),
"%02d".formatted(now.getDayOfMonth()),
storedFileName
).normalize().toAbsolutePath();
ensureUnderRoot(savePath);
try {
Files.createDirectories(savePath.getParent());
FileUploadImageService.ImageSize imageSize = null;
if (image && uploadType.getResize() != null && uploadType.getResize().isEnabled()) {
imageSize = fileUploadImageService.resizeAndSave(file, savePath, uploadType.getResize(), ext);
} else {
file.transferTo(savePath);
if (image) imageSize = fileUploadImageService.readSize(savePath);
}
FileUploadVo vo = new FileUploadVo();
vo.setUploadPath(toUploadPath(savePath));
vo.setOriginalFileName(originalFileName);
vo.setStoredFileName(storedFileName);
vo.setFileExtension(ext);
vo.setContentType(file.getContentType());
vo.setFileSize(Files.size(savePath));
vo.setWidth(imageSize == null ? null : imageSize.width());
vo.setHeight(imageSize == null ? null : imageSize.height());
return vo;
} catch (IOException e) {
throw new IllegalStateException("file save failed", e);
}
}
public ResponseEntity<Resource> resource(String uploadPath, boolean inline) {
try {
Path path = resolveUploadPath(uploadPath);
if (!Files.exists(path) || !Files.isRegularFile(path)) {
return ResponseEntity.notFound().build();
}
String contentType = Files.probeContentType(path);
if (contentType == null || contentType.isBlank()) {
contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
ContentDisposition disposition = inline
? ContentDisposition.inline().filename(path.getFileName().toString(), StandardCharsets.UTF_8).build()
: ContentDisposition.attachment().filename(path.getFileName().toString(), StandardCharsets.UTF_8).build();
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.contentLength(Files.size(path))
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.body(new InputStreamResource(Files.newInputStream(path)));
} catch (IOException e) {
throw new IllegalStateException("file read failed", e);
}
}
private Path resolveUploadPath(String uploadPath) {
if (uploadPath == null || uploadPath.isBlank()) throw new IllegalArgumentException("empty path");
String path = uploadPath.trim().replace("\\", "/");
if (!path.startsWith(PUBLIC_PREFIX + "/")) throw new IllegalArgumentException("invalid path");
String relative = path.substring((PUBLIC_PREFIX + "/").length());
if (relative.contains("..") || relative.startsWith("/") || relative.contains(":")) {
throw new IllegalArgumentException("invalid path");
}
Path root = rootPath();
Path resolved = root.resolve(relative).normalize().toAbsolutePath();
if (!resolved.startsWith(root)) throw new IllegalArgumentException("invalid path");
return resolved;
}
private String toUploadPath(Path savePath) {
Path root = rootPath();
Path absolutePath = savePath.normalize().toAbsolutePath();
if (!absolutePath.startsWith(root)) throw new IllegalArgumentException("invalid path");
return PUBLIC_PREFIX + "/" + root.relativize(absolutePath).toString().replace("\\", "/");
}
private void ensureUnderRoot(Path path) {
if (!path.startsWith(rootPath())) throw new IllegalArgumentException("invalid save path");
}
private Path rootPath() {
return Paths.get(properties.getRootPath()).normalize().toAbsolutePath();
}
private void validateSize(long fileSize, FileUploadProperties.UploadType uploadType) {
long limit = uploadType.getMaxSize() == null
? properties.getMaxSize().toBytes()
: uploadType.getMaxSize().toBytes();
if (fileSize > limit) throw new IllegalArgumentException("file size exceeded");
}
private void validateExtension(String ext) {
if (ext.isBlank() || properties.getAllowedExtensions().stream().noneMatch(ext::equalsIgnoreCase)) {
throw new IllegalArgumentException("invalid extension");
}
}
private String cleanFileName(String fileName) {
if (fileName == null || fileName.isBlank()) throw new IllegalArgumentException("empty file name");
return Paths.get(fileName).getFileName().toString();
}
private String extractExt(String fileName) {
int idx = fileName.lastIndexOf('.');
if (idx < 0 || idx == fileName.length() - 1) return "";
return fileName.substring(idx + 1).trim().toLowerCase(Locale.ROOT);
}
private boolean isImageExtension(String ext) {
return "jpg".equals(ext) || "jpeg".equals(ext) || "png".equals(ext) || "gif".equals(ext);
}
private FileUploadProperties.UploadType resolveUploadType(String folder) {
String key = normalizeFolder(folder);
FileUploadProperties.UploadType configured = properties.getTypes() == null
? null
: properties.getTypes().get(key);
if (configured != null) {
configured.setFolder(normalizeFolder(configured.getFolder()));
return configured;
}
FileUploadProperties.UploadType fallback = new FileUploadProperties.UploadType();
fallback.setFolder(key);
fallback.setMaxSize(null);
fallback.setImageOnly(false);
fallback.setResize(new FileUploadProperties.Resize());
fallback.getResize().setEnabled(false);
return fallback;
}
private String normalizeFolder(String folder) {
if (folder == null || folder.isBlank()) {
throw new IllegalArgumentException("invalid folder");
}
String key = folder.trim().replace("\\", "/");
while (key.startsWith("/")) key = key.substring(1);
while (key.endsWith("/")) key = key.substring(0, key.length() - 1);
if (key.isBlank()
|| key.contains("..")
|| key.contains(":")
|| key.startsWith("http://")
|| key.startsWith("https://")) {
throw new IllegalArgumentException("invalid folder");
}
if (!key.matches("^[a-zA-Z0-9/_-]+$")) {
throw new IllegalArgumentException("invalid folder");
}
return key;
}
public String buildImageUrl(String uploadPath) {
return joinUrl(properties.getView().getFileDomain(), uploadPath);
}
private String joinUrl(String domain, String path) {
if (domain == null || domain.isBlank()) {
throw new IllegalStateException("file.upload.view.file-domain is empty");
}
String base = domain.endsWith("/") ? domain.substring(0, domain.length() - 1) : domain;
String p = path.startsWith("/") ? path : "/" + path;
return base + p;
}
}
@@ -0,0 +1,138 @@
package com.alist.api.modules.file.service;
import com.alist.api.modules.file.properties.FileUploadProperties;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Iterator;
@Service
public class FileUploadImageService {
public record ImageSize(int width, int height) {
}
public ImageSize resizeAndSave(
MultipartFile file,
Path savePath,
FileUploadProperties.Resize resize,
String ext
) throws IOException {
BufferedImage source = ImageIO.read(file.getInputStream());
if (source == null) throw new IllegalArgumentException("invalid image");
BufferedImage target;
if (resize.getWidth() != null && resize.getHeight() != null) {
target = cropAndResize(source, resize.getWidth(), resize.getHeight());
} else if (resize.getMaxWidth() != null && source.getWidth() > resize.getMaxWidth()) {
target = resizeByMaxWidth(source, resize.getMaxWidth());
} else {
target = source;
}
writeImage(target, imageFormat(ext), savePath, resize.getQuality());
return new ImageSize(target.getWidth(), target.getHeight());
}
public ImageSize readSize(Path path) throws IOException {
BufferedImage image = ImageIO.read(path.toFile());
if (image == null) return null;
return new ImageSize(image.getWidth(), image.getHeight());
}
private BufferedImage cropAndResize(BufferedImage source, int targetWidth, int targetHeight) {
double targetRatio = (double) targetWidth / targetHeight;
int sourceWidth = source.getWidth();
int sourceHeight = source.getHeight();
double sourceRatio = (double) sourceWidth / sourceHeight;
int cropWidth = sourceWidth;
int cropHeight = sourceHeight;
if (sourceRatio > targetRatio) {
cropWidth = (int) Math.round(sourceHeight * targetRatio);
} else {
cropHeight = (int) Math.round(sourceWidth / targetRatio);
}
BufferedImage cropped = source.getSubimage(
(sourceWidth - cropWidth) / 2,
(sourceHeight - cropHeight) / 2,
cropWidth,
cropHeight
);
return resize(cropped, targetWidth, targetHeight);
}
private BufferedImage resizeByMaxWidth(BufferedImage source, int maxWidth) {
int targetWidth = maxWidth;
int targetHeight = (int) Math.round((double) source.getHeight() * targetWidth / source.getWidth());
return resize(source, targetWidth, targetHeight);
}
private BufferedImage resize(BufferedImage source, int width, int height) {
BufferedImage target = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = target.createGraphics();
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
graphics.drawImage(source, 0, 0, width, height, null);
graphics.dispose();
return target;
}
private void writeImage(BufferedImage image, String format, Path savePath, Float quality) throws IOException {
if (!"jpeg".equals(format)) {
boolean written = ImageIO.write(image, format, savePath.toFile());
if (!written) throw new IllegalArgumentException("unsupported image format");
return;
}
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
if (!writers.hasNext()) {
throw new IllegalArgumentException("unsupported image format");
}
ImageWriter writer = writers.next();
ImageWriteParam param = writer.getDefaultWriteParam();
if (param.canWriteCompressed()) {
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(normalizeQuality(quality));
}
try (ImageOutputStream output = ImageIO.createImageOutputStream(savePath.toFile())) {
writer.setOutput(output);
writer.write(null, new IIOImage(image, null, null), param);
} finally {
writer.dispose();
}
}
private float normalizeQuality(Float quality) {
if (quality == null) return 0.9f;
if (quality < 0.0f) return 0.0f;
if (quality > 1.0f) return 1.0f;
return quality;
}
private String imageFormat(String ext) {
if ("jpg".equalsIgnoreCase(ext)) return "jpeg";
return ext.toLowerCase();
}
}
@@ -0,0 +1,17 @@
package com.alist.api.modules.file.vo;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class FileUploadVo {
private String uploadPath;
private String originalFileName;
private String storedFileName;
private String fileExtension;
private String contentType;
private Long fileSize;
private Integer width;
private Integer height;
}
@@ -0,0 +1,30 @@
package com.alist.api.modules.file.vo;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
@Getter
@Setter
public class SunEditorUploadVo {
private List<Item> result;
public SunEditorUploadVo(List<Item> result) {
this.result = result;
}
@Getter
@Setter
public static class Item {
private String url;
private String name;
private Long size;
public Item(String url, String name, Long size) {
this.url = url;
this.name = name;
this.size = size;
}
}
}
@@ -5,7 +5,7 @@ import com.alist.api.common.response.ApiResponseCode;
import com.alist.api.common.utils.SecurityUtil;
import com.alist.api.modules.tusFile.dto.*;
import com.alist.api.modules.tusFile.form.*;
import com.alist.api.modules.tusFile.service.FileService;
import com.alist.api.modules.tusFile.service.TusFileService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
@@ -30,12 +30,12 @@ import java.nio.file.Path;
, description = "TUS 기반 파일 업로드 초기화, 권한 검증, 상태 조회, 훅 처리, 취소 API"
)
@RestController
@RequestMapping("/files")
public class FileController {
private final FileService fileService;
@RequestMapping("/tusFiles")
public class TusFileController {
private final TusFileService tusFileService;
public FileController(FileService fileService) {
this.fileService = fileService;
public TusFileController(TusFileService tusFileService) {
this.tusFileService = tusFileService;
}
@Operation(
@@ -54,7 +54,7 @@ public class FileController {
return ApiResponse.entity(new FileUploadDto(), ApiResponseCode.CODE_401);
}
Integer userIdx = fileService.selectUserIdxByUserTokenIdx(userTokenIdx);
Integer userIdx = tusFileService.selectUserIdxByUserTokenIdx(userTokenIdx);
if (userIdx == null) {
return ApiResponse.entity(new FileUploadDto(), ApiResponseCode.CODE_401);
@@ -63,7 +63,7 @@ public class FileController {
fileUploadDto.setUserIdx(userIdx);
fileUploadDto.setUserTokenIdx(userTokenIdx);
FileUploadDto fileUploadResult = fileService.insertFileInit(fileUploadDto);
FileUploadDto fileUploadResult = tusFileService.insertFileInit(fileUploadDto);
return ApiResponse.entity(fileUploadResult, ApiResponseCode.CODE_200);
}
@@ -92,7 +92,7 @@ public class FileController {
uploadTokenDto.setUploadMetadataRaw(uploadMetadata);
uploadTokenDto.setUploadLengthRaw(uploadLength);
UploadTokenDto uploadTokenCheck = fileService.isUploadTokenCheck(uploadTokenDto);
UploadTokenDto uploadTokenCheck = tusFileService.isUploadTokenCheck(uploadTokenDto);
if (uploadTokenCheck.getResultCode() == 401) {
return ApiResponse.entity("", ApiResponseCode.CODE_401);
@@ -111,7 +111,7 @@ public class FileController {
public ResponseEntity<ApiResponse<UploadStatusDto>> uploadStatus(
@Valid @RequestBody UploadStatusForm uploadStatusForm
) {
UploadStatusDto status = fileService.getUploadStatus(uploadStatusForm.toUploadStatusDto());
UploadStatusDto status = tusFileService.getUploadStatus(uploadStatusForm.toUploadStatusDto());
if (status == null) {
return ApiResponse.entity(new UploadStatusDto(), ApiResponseCode.CODE_2003);
}
@@ -124,10 +124,10 @@ public class FileController {
)
@PostMapping("/tusHook")
public ResponseEntity<ApiResponse<String>> tusHook(@RequestBody TusHookForm tusHookForm) {
boolean accepted = fileService.updateFileUploadStatus(tusHookForm.toTusHookDto());
boolean accepted = tusFileService.updateFileUploadStatus(tusHookForm.toTusHookDto());
if (accepted) {
fileService.saveUploadStatusRedisTusHook(tusHookForm.toTusHookDto());
tusFileService.saveUploadStatusRedisTusHook(tusHookForm.toTusHookDto());
}
return ApiResponse.entity("", ApiResponseCode.CODE_200);
@@ -152,7 +152,7 @@ public class FileController {
uploadCancelDto.setFileUuid(uploadCancelForm.getFileUuid().trim());
uploadCancelDto.setUserTokenIdx(userTokenIdx);
boolean canceled = fileService.updateUploadCancel(uploadCancelDto);
boolean canceled = tusFileService.updateUploadCancel(uploadCancelDto);
if (!canceled) {
return ApiResponse.entity("", ApiResponseCode.CODE_2005, "업로드 취소");
@@ -172,7 +172,7 @@ public class FileController {
FileDownloadDto fileDownloadDto = new FileDownloadDto();
fileDownloadDto.setFileMasterIdx(fileMasterIdx);
FileDownloadListDto fileDownloadList = fileService.selectFileDownloadListByFileMasterIdx(fileDownloadDto);
FileDownloadListDto fileDownloadList = tusFileService.selectFileDownloadListByFileMasterIdx(fileDownloadDto);
if (fileDownloadList.getResultCode() == 401) {
return ApiResponse.entity(fileDownloadList, ApiResponseCode.CODE_401);
@@ -196,13 +196,13 @@ public class FileController {
fileDownloadDto.setUserAgent(request.getHeader("User-Agent"));
fileDownloadDto.setReferer(request.getHeader("Referer"));
FileDownloadDto target = fileService.selectFileViewOrDownload(fileDownloadDto);
FileDownloadDto target = tusFileService.selectFileViewOrDownload(fileDownloadDto);
if (target == null) {
return ResponseEntity.notFound().build();
}
Path filePath = fileService.resolveStoredFilePath(target.getSavePath());
Path filePath = tusFileService.resolveStoredFilePath(target.getSavePath());
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
return ResponseEntity.notFound().build();
@@ -239,13 +239,13 @@ public class FileController {
fileDownloadDto.setUserAgent(request.getHeader("User-Agent"));
fileDownloadDto.setReferer(request.getHeader("Referer"));
FileDownloadDto target = fileService.selectFileViewOrDownload(fileDownloadDto);
FileDownloadDto target = tusFileService.selectFileViewOrDownload(fileDownloadDto);
if (target == null) {
return ResponseEntity.notFound().build();
}
Path filePath = fileService.resolveStoredFilePath(target.getSavePath());
Path filePath = tusFileService.resolveStoredFilePath(target.getSavePath());
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
return ResponseEntity.notFound().build();
@@ -275,7 +275,7 @@ public class FileController {
public ResponseEntity<ApiResponse<String>> fileDelete(
@Valid @RequestBody FileDeleteForm fileDeleteForm
) {
boolean deleted = fileService.updateFileDelete(fileDeleteForm.toFileDeleteDto());
boolean deleted = tusFileService.updateFileDelete(fileDeleteForm.toFileDeleteDto());
if (!deleted) {
return ApiResponse.entity("", ApiResponseCode.CODE_2005, "파일 삭제");
@@ -6,7 +6,7 @@ import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface FileMapper {
public interface TusFileMapper {
void insertFileMasterInit(FileUploadDto fileUploadDto);
void insertFileDetailInit(FileUploadItemDto item);
@@ -3,7 +3,7 @@ package com.alist.api.modules.tusFile.service;
import com.alist.api.common.utils.SecurityUtil;
import com.alist.api.config.jwt.JwtTokenProvider;
import com.alist.api.modules.tusFile.dto.*;
import com.alist.api.modules.tusFile.mapper.FileMapper;
import com.alist.api.modules.tusFile.mapper.TusFileMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.HashOperations;
@@ -26,31 +26,31 @@ import java.util.UUID;
@Slf4j
@Service
public class FileService {
@Value("${file.upload.tus-endpoint}")
public class TusFileService {
@Value("${tus-file.upload.tus-endpoint}")
private String tusEndpoint;
@Value("${file.upload.public-base-url}")
@Value("${tus-file.upload.public-base-url}")
private String publicBaseUrl;
@Value("${file.upload.auth-cache.ttl-seconds:20}")
@Value("${tus-file.upload.auth-cache.ttl-seconds:20}")
private long uploadAuthCacheTtlSeconds;
@Value("${file.upload.interrupt-seconds:30}")
@Value("${tus-file.upload.interrupt-seconds:30}")
private long uploadInterruptSeconds;
@Value("${file.upload.tmp-root}")
@Value("${tus-file.upload.tmp-root}")
private String uploadTmpRoot;
@Value("${file.upload.final-root}")
@Value("${tus-file.upload.final-root}")
private String uploadFinalRoot;
private final FileMapper fileMapper;
private final TusFileMapper tusFileMapper;
private final JwtTokenProvider jwtTokenProvider;
private final StringRedisTemplate stringRedisTemplate;
public FileService(FileMapper fileMapper, JwtTokenProvider jwtTokenProvider, StringRedisTemplate stringRedisTemplate) {
this.fileMapper = fileMapper;
public TusFileService(TusFileMapper tusFileMapper, JwtTokenProvider jwtTokenProvider, StringRedisTemplate stringRedisTemplate) {
this.tusFileMapper = tusFileMapper;
this.jwtTokenProvider = jwtTokenProvider;
this.stringRedisTemplate = stringRedisTemplate;
}
@@ -61,7 +61,7 @@ public class FileService {
fileUploadDto.setStatus(0);
fileUploadDto.setTotalCount(fileUploadDto.getItemList().size());
fileMapper.insertFileMasterInit(fileUploadDto);
tusFileMapper.insertFileMasterInit(fileUploadDto);
fileUploadDto.setTusEndpoint(tusEndpoint);
if (fileUploadDto.getItemList() == null || fileUploadDto.getItemList().isEmpty()) {
@@ -74,7 +74,7 @@ public class FileService {
item.setUploadToken(jwtTokenProvider.createUploadToken(fileUploadDto.getUserTokenIdx()));
item.setUserIdx(fileUploadDto.getUserIdx());
item.setStatus(0); // PENDING
fileMapper.insertFileDetailInit(item);
tusFileMapper.insertFileDetailInit(item);
}
return fileUploadDto;
@@ -118,7 +118,7 @@ public class FileService {
log.warn("uploadAuth cache get fail. fallback DB. key={}", cacheKey, redisGetEx);
}
int isOk = fileMapper.selectFileDetailCountByFileUuidUserToKenIdx(uploadTokenDto);
int isOk = tusFileMapper.selectFileDetailCountByFileUuidUserToKenIdx(uploadTokenDto);
int resultCode = (isOk > 0) ? 200 : 403;
uploadTokenDto.setResultCode(resultCode);
@@ -233,14 +233,14 @@ public class FileService {
long offset = tusHookDto.getOffset() == null ? 0L : tusHookDto.getOffset();
long size = tusHookDto.getSize() == null ? 0L : tusHookDto.getSize();
fileMapper.insertFileUploadEventLog(tusHookDto);
tusFileMapper.insertFileUploadEventLog(tusHookDto);
FileUploadMetaDto fileUploadMetaInfo = fileMapper.selectFileUploadMetaByFileUuid(fileUuid);
FileUploadMetaDto fileUploadMetaInfo = tusFileMapper.selectFileUploadMetaByFileUuid(fileUuid);
if (fileUploadMetaInfo == null) return true;
if (isMetadataMismatch(fileUploadMetaInfo, tusHookDto)) {
fileMapper.updateFileDetailCanceledByFileUuid(fileUuid);
fileMapper.updateFileMasterAggregateByFileUuid(fileUuid);
tusFileMapper.updateFileDetailCanceledByFileUuid(fileUuid);
tusFileMapper.updateFileMasterAggregateByFileUuid(fileUuid);
saveCanceledStatusRedis(fileUuid, offset, size);
return false;
}
@@ -250,14 +250,14 @@ public class FileService {
FileUploadMetaDto fileUploadMetaDto = new FileUploadMetaDto();
fileUploadMetaDto.setFileUuid(fileUuid);
fileUploadMetaDto.setTusUploadId(uploadId);
fileMapper.updateTusUploadIdIfNull(fileUploadMetaDto);
tusFileMapper.updateTusUploadIdIfNull(fileUploadMetaDto);
fileUploadMetaInfo = fileMapper.selectFileUploadMetaByFileUuid(fileUuid);
fileUploadMetaInfo = tusFileMapper.selectFileUploadMetaByFileUuid(fileUuid);
}
if (fileUploadMetaInfo.getTusUploadId() == null || fileUploadMetaInfo.getTusUploadId().isBlank()
|| !fileUploadMetaInfo.getTusUploadId().equals(uploadId)) {
fileMapper.updateFileDetailCanceledByFileUuid(fileUuid);
fileMapper.updateFileMasterAggregateByFileUuid(fileUuid);
tusFileMapper.updateFileDetailCanceledByFileUuid(fileUuid);
tusFileMapper.updateFileMasterAggregateByFileUuid(fileUuid);
saveCanceledStatusRedis(fileUuid, offset, size);
return false;
}
@@ -319,11 +319,11 @@ public class FileService {
boolean shouldUpdateDetail = !"post-receive".equals(type);
if (shouldUpdateDetail) {
fileMapper.updateFileDetailStatus(tusHookDto);
tusFileMapper.updateFileDetailStatus(tusHookDto);
}
if ("post-finish".equals(type) || "post-terminate".equals(type)) {
fileMapper.updateFileMasterAggregateByFileUuid(tusHookDto.getFileUuid());
tusFileMapper.updateFileMasterAggregateByFileUuid(tusHookDto.getFileUuid());
}
if ("post-finish".equals(type)) {
@@ -352,7 +352,7 @@ public class FileService {
@Transactional
public void tryMoveNowByFileUuid(String fileUuid) throws IOException {
FileMoveTaskDto task = fileMapper.selectMoveTaskByFileUuid(fileUuid);
FileMoveTaskDto task = tusFileMapper.selectMoveTaskByFileUuid(fileUuid);
if (task == null) return;
if (task.getMoveYn() != null && !"N".equalsIgnoreCase(task.getMoveYn())) return;
@@ -373,7 +373,7 @@ public class FileService {
task.setSavePath(toSavePath(finalPath));
task.setSaveName(finalPath.getFileName().toString());
task.setExt(extractExt(task.getSaveName()));
fileMapper.updateFileMoveSuccess(task);
tusFileMapper.updateFileMoveSuccess(task);
}
@Transactional
@@ -381,7 +381,7 @@ public class FileService {
FileMoveTaskDto task = new FileMoveTaskDto();
task.setFileUuid(fileUuid);
task.setMoveLastError(errMsg == null ? "move failed" : errMsg);
fileMapper.updateFileMovePendingByFileUuid(task); // MOVE_YN='N', TRY_COUNT+1, LAST_ERROR
tusFileMapper.updateFileMovePendingByFileUuid(task); // MOVE_YN='N', TRY_COUNT+1, LAST_ERROR
}
private String truncateErr(String s) {
@@ -567,21 +567,21 @@ public class FileService {
return false;
}
int allowed = fileMapper.selectFileDetailCountByFileUuidAndUserToKenIdx(uploadCancelDto);
int allowed = tusFileMapper.selectFileDetailCountByFileUuidAndUserToKenIdx(uploadCancelDto);
if (allowed <= 0) {
return false;
}
UploadCancelDto progress = fileMapper.selectUploadProgressByFileUuid(uploadCancelDto);
UploadCancelDto progress = tusFileMapper.selectUploadProgressByFileUuid(uploadCancelDto);
long uploaded = (progress != null && progress.getUploadedBytes() != null) ? progress.getUploadedBytes() : 0L;
long total = (progress != null && progress.getSizeBytes() != null) ? progress.getSizeBytes() : 0L;
int updated = fileMapper.updateFileDetailCanceledByFileUuidAndUserTokenIdx(uploadCancelDto);
int updated = tusFileMapper.updateFileDetailCanceledByFileUuidAndUserTokenIdx(uploadCancelDto);
if (updated <= 0) {
return false;
}
fileMapper.updateFileMasterAggregateByFileUuid(uploadCancelDto.getFileUuid());
tusFileMapper.updateFileMasterAggregateByFileUuid(uploadCancelDto.getFileUuid());
saveCanceledStatusRedis(uploadCancelDto.getFileUuid(), uploaded, total); // 필요하면 기존 bytes 조회해서 넣어도
return true;
}
@@ -600,7 +600,7 @@ public class FileService {
fileDownloadList.setFileMasterIdx(fileDownloadDto.getFileMasterIdx());
List<FileDownloadItemDto> itemList = fileMapper.selectFileDownloadListByFileMasterIdx(fileDownloadDto);
List<FileDownloadItemDto> itemList = tusFileMapper.selectFileDownloadListByFileMasterIdx(fileDownloadDto);
for (FileDownloadItemDto item : itemList) {
item.setViewUrl("/files/view/" + item.getFileUuid());
@@ -621,7 +621,7 @@ public class FileService {
fileDownloadDto.setUserTokenIdx(userTokenIdx);
FileDownloadDto fileDetailInfo = fileMapper.selectFileDetailByFileUuid(fileDownloadDto);
FileDownloadDto fileDetailInfo = tusFileMapper.selectFileDetailByFileUuid(fileDownloadDto);
if (fileDetailInfo == null) {
return null;
@@ -633,7 +633,7 @@ public class FileService {
fileDetailInfo.setUserAgent(fileDownloadDto.getUserAgent());
fileDetailInfo.setReferer(fileDownloadDto.getReferer());
fileMapper.insertFileDownloadEventLog(fileDetailInfo);
tusFileMapper.insertFileDownloadEventLog(fileDetailInfo);
return fileDetailInfo;
}
@@ -668,17 +668,17 @@ public class FileService {
fileDeleteDto.setUserTokenIdx(userTokenIdx);
fileDeleteDto.setFileUuid(fileDeleteDto.getFileUuid().trim());
int allowed = fileMapper.selectFileDeleteTargetCount(fileDeleteDto);
int allowed = tusFileMapper.selectFileDeleteTargetCount(fileDeleteDto);
if (allowed <= 0) {
return false;
}
int detailUpdated = fileMapper.updateFileDetailDeleteByFileUuid(fileDeleteDto);
int detailUpdated = tusFileMapper.updateFileDetailDeleteByFileUuid(fileDeleteDto);
if (detailUpdated <= 0) {
return false;
}
fileMapper.updateFileMasterDeleteByFileUuid(fileDeleteDto);
tusFileMapper.updateFileMasterDeleteByFileUuid(fileDeleteDto);
return true;
}
@@ -687,6 +687,6 @@ public class FileService {
if (userTokenIdx == null) {
return null;
}
return fileMapper.selectUserIdxByUserTokenIdx(userTokenIdx);
return tusFileMapper.selectUserIdxByUserTokenIdx(userTokenIdx);
}
}
+43 -4
View File
@@ -7,15 +7,19 @@ spring:
password: 1qaz2wsx!@
driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
hikari:
maximum-pool-size: 10 # 줄이기
minimum-idle: 1 # 최소로
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 10000
idle-timeout: 30000
idle-timeout: 600000
data:
redis:
host: 121.160.234.222
port: 3001
password: 1qaz2wsx!@
servlet:
multipart:
max-file-size: -1
max-request-size: -1
migration:
datasource:
@@ -55,7 +59,7 @@ swagger:
id: alist
password: "1qaz2wsx!@"
file:
tus-file:
upload:
tus-endpoint: https://file-alist.pjt.kr/tus/files/
public-base-url: https://file-alist.pjt.kr
@@ -65,6 +69,41 @@ file:
auth-cache:
ttl-seconds: 20
# types:
# notice:
# folder: notice 저장폴더
# max-size: 20MB 최대 용량
# image-only: false
# resize:
# enabled: true
# width: 800 고정넓이
# height: 600 고정높이
# max-width: 1200 최대 넓이
file:
upload:
view:
file-domain: http://localhost:8110
root-path: ${tus-file.upload.final-root}
max-size: 10MB
allowed-extensions: [jpg, jpeg, png, gif, pdf, hwp, hwpx, doc, docx, xls, xlsx, ppt, pptx, txt, csv, zip]
types:
profile:
folder: profile
max-size: 5MB
image-only: true
resize:
enabled: true
width: 400
height: 400
notice:
folder: notice
max-size: 20MB
image-only: false
resize:
enabled: true
max-width: 1200
springdoc:
api-docs:
enabled: true
+43 -4
View File
@@ -9,15 +9,19 @@ spring:
password: ${DB_PASSWORD}
driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
hikari:
maximum-pool-size: 10 # 줄이기
minimum-idle: 1 # 최소로
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 10000
idle-timeout: 30000
idle-timeout: 600000
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}
password: ${REDIS_PASSWORD}
servlet:
multipart:
max-file-size: -1
max-request-size: -1
migration:
datasource:
@@ -57,7 +61,7 @@ swagger:
id: ${SWAGGER_ID}
password: ${SWAGGER_PASSWORD}
file:
tus-file:
upload:
tus-endpoint: https://file-alist.pjt.kr/tus/files/
public-base-url: https://file-alist.pjt.kr
@@ -67,6 +71,41 @@ file:
auth-cache:
ttl-seconds: 20
# types:
# notice:
# folder: notice 저장폴더
# max-size: 20MB 최대 용량
# image-only: false
# resize:
# enabled: true
# width: 800 고정넓이
# height: 600 고정높이
# max-width: 1200 최대 넓이
file:
upload:
view:
file-domain: https://file-alist.pjt.kr
root-path: ${tus-file.upload.final-root}
max-size: 10MB
allowed-extensions: [jpg, jpeg, png, gif, pdf, hwp, hwpx, doc, docx, xls, xlsx, ppt, pptx, txt, csv, zip]
types:
profile:
folder: profile
max-size: 5MB
image-only: true
resize:
enabled: true
width: 400
height: 400
notice:
folder: notice
max-size: 20MB
image-only: false
resize:
enabled: true
max-width: 1200
springdoc:
api-docs:
enabled: true
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.alist.api.modules.file.mapper.FileMapper">
<mapper namespace="com.alist.api.modules.tusFile.mapper.TusFileMapper">
<insert id="insertFileMasterInit" useGeneratedKeys="true" keyProperty="fileMasterIdx">
/*FileMapper.insertFileMasterInit*/