diff --git a/backend/src/main/java/com/goldenchart/controller/VerificationIssueImageController.java b/backend/src/main/java/com/goldenchart/controller/VerificationIssueImageController.java index 1464a55..e009318 100644 --- a/backend/src/main/java/com/goldenchart/controller/VerificationIssueImageController.java +++ b/backend/src/main/java/com/goldenchart/controller/VerificationIssueImageController.java @@ -12,6 +12,8 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; @@ -64,14 +66,29 @@ public class VerificationIssueImageController { return service.loadFile(issueId, imageId) .map(resource -> { String type = service.contentType(issueId, imageId).orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE); - return ResponseEntity.ok() + var builder = ResponseEntity.ok() .contentType(MediaType.parseMediaType(type)) - .header(HttpHeaders.CACHE_CONTROL, "private, max-age=3600") - .body(resource); + .header(HttpHeaders.CACHE_CONTROL, "private, max-age=3600"); + if (!type.startsWith("image/")) { + service.fileName(issueId, imageId) + .ifPresent(name -> builder.header( + HttpHeaders.CONTENT_DISPOSITION, + attachmentContentDisposition(name))); + } + return builder.body(resource); }) .orElse(ResponseEntity.notFound().build()); } + private static String attachmentContentDisposition(String fileName) { + String safe = (fileName == null || fileName.isBlank()) ? "download" : fileName; + String ascii = safe.replaceAll("[^\\x20-\\x7E]", "_"); + if (ascii.isBlank()) ascii = "download"; + ascii = ascii.replace("\"", "\\\""); + String encoded = URLEncoder.encode(safe, StandardCharsets.UTF_8).replace("+", "%20"); + return "attachment; filename=\"" + ascii + "\"; filename*=UTF-8''" + encoded; + } + @DeleteMapping("/{imageId}") public ResponseEntity delete( @PathVariable Long issueId, diff --git a/backend/src/main/java/com/goldenchart/service/VerificationIssueImageService.java b/backend/src/main/java/com/goldenchart/service/VerificationIssueImageService.java index 0637501..e5826c9 100644 --- a/backend/src/main/java/com/goldenchart/service/VerificationIssueImageService.java +++ b/backend/src/main/java/com/goldenchart/service/VerificationIssueImageService.java @@ -93,7 +93,7 @@ public class VerificationIssueImageService { String contentType = resolveContentType(file, data); validateFile(data.length, contentType); - String original = sanitizeFileName(file.getOriginalFilename()); + String original = resolveOriginalFileName(file, contentType); String storedName = UUID.randomUUID() + "_" + original; Path target = issueDir.resolve(storedName); Files.write(target, data); @@ -140,6 +140,12 @@ public class VerificationIssueImageService { .map(GcVerificationIssueImage::getContentType); } + public Optional fileName(Long issueId, Long imageId) { + return imageRepository.findById(imageId) + .filter(img -> issueId.equals(img.getIssueId())) + .map(GcVerificationIssueImage::getFileName); + } + @Transactional public boolean delete(Long issueId, Long imageId) throws IOException { Optional opt = imageRepository.findById(imageId) @@ -234,9 +240,7 @@ public class VerificationIssueImageService { if (header != null && header.length >= 8 && header[0] == (byte) 0xD0 && header[1] == (byte) 0xCF && header[2] == 0x11 && header[3] == (byte) 0xE0) { - if (fileName != null && fileName.toLowerCase().endsWith(".hwp")) { - return "application/x-hwp"; - } + return "application/x-hwp"; } return null; } @@ -274,10 +278,47 @@ public class VerificationIssueImageService { } } - private String sanitizeFileName(String name) { - if (name == null || name.isBlank()) return "attachment.bin"; - String base = Paths.get(name).getFileName().toString(); - return base.replaceAll("[^a-zA-Z0-9._\\-가-힣]", "_"); + /** 업로드 multipart의 원본 파일명·확장자를 그대로 보존 (경로·위험 문자만 제거) */ + private String resolveOriginalFileName(MultipartFile file, String contentType) { + String name = extractBaseFileName(file.getOriginalFilename()); + if (name == null || name.isBlank()) { + name = defaultNameForContentType(contentType); + } + return truncateFileName(name, 255); + } + + private static String extractBaseFileName(String name) { + if (name == null || name.isBlank()) return null; + String normalized = name.replace('\\', '/').trim(); + int slash = normalized.lastIndexOf('/'); + String base = slash >= 0 ? normalized.substring(slash + 1) : normalized; + if (base.isBlank() || ".".equals(base) || "..".equals(base)) return null; + base = base.replaceAll("[\\x00-\\x1F<>:\"|?*]", ""); + return base.isBlank() ? null : base; + } + + private static String defaultNameForContentType(String contentType) { + if (contentType == null) return "attachment"; + return switch (contentType) { + case "application/x-hwp", "application/haansoft-hwp", + "application/vnd.hancom.hwp", "application/hwp+zip" -> "attachment.hwp"; + case "application/vnd.hancom.hwpx", "application/x-hwpx" -> "attachment.hwpx"; + case "application/zip", "application/x-zip-compressed" -> "attachment.zip"; + default -> "attachment"; + }; + } + + private static String truncateFileName(String name, int maxLen) { + if (name.length() <= maxLen) return name; + int dot = name.lastIndexOf('.'); + if (dot > 0 && dot < name.length() - 1) { + String ext = name.substring(dot); + int baseMax = maxLen - ext.length(); + if (baseMax > 0) { + return name.substring(0, baseMax) + ext; + } + } + return name.substring(0, maxLen); } private VerificationIssueImageDto toDto(GcVerificationIssueImage e) { diff --git a/frontend/src/components/verificationBoard/VerificationIssueAttachmentsSection.tsx b/frontend/src/components/verificationBoard/VerificationIssueAttachmentsSection.tsx index b494314..2268fe3 100644 --- a/frontend/src/components/verificationBoard/VerificationIssueAttachmentsSection.tsx +++ b/frontend/src/components/verificationBoard/VerificationIssueAttachmentsSection.tsx @@ -219,8 +219,18 @@ const VerificationIssueAttachmentsSection: React.FC = ({ onDraftFilesChange?.(draftFiles.filter((_, i) => i !== index)); }; - const openAttachment = (src: string) => { - window.open(src, '_blank', 'noopener,noreferrer'); + const openAttachment = (src: string, fileName: string, isImage: boolean) => { + if (isImage) { + window.open(src, '_blank', 'noopener,noreferrer'); + return; + } + const a = document.createElement('a'); + a.href = src; + a.download = fileName; + a.rel = 'noopener noreferrer'; + document.body.appendChild(a); + a.click(); + a.remove(); }; const renderAttachmentThumb = ( @@ -358,7 +368,7 @@ const VerificationIssueAttachmentsSection: React.FC = ({ img.fileName ?? '첨부 파일', isImage, kind, - () => openAttachment(src), + () => openAttachment(src, img.fileName ?? '첨부 파일', isImage), )}
{img.fileName} @@ -384,7 +394,7 @@ const VerificationIssueAttachmentsSection: React.FC = ({ p.fileName, true, p.kind, - () => openAttachment(p.src!), + () => openAttachment(p.src!, p.fileName, true), ) : (
@@ -417,7 +427,7 @@ const VerificationIssueAttachmentsSection: React.FC = ({ img.fileName ?? '첨부 파일', isImage, kind, - () => openAttachment(src), + () => openAttachment(src, img.fileName ?? '첨부 파일', isImage), )}
{img.fileName} diff --git a/frontend/src/utils/verificationBoardApi.ts b/frontend/src/utils/verificationBoardApi.ts index 35da0b8..1d6a489 100644 --- a/frontend/src/utils/verificationBoardApi.ts +++ b/frontend/src/utils/verificationBoardApi.ts @@ -165,7 +165,7 @@ export async function uploadVerificationIssueImages( files: File[], ): Promise { const form = new FormData(); - files.forEach(f => form.append('files', f)); + files.forEach(f => form.append('files', f, f.name)); const res = await fetch(`${API_BASE}/verification-issues/${issueId}/images`, { method: 'POST', headers: authHeadersMultipart(),