검증게시판 첨부파일 첨부 및 삭제기능

This commit is contained in:
Macbook
2026-06-13 01:55:19 +09:00
parent 3a4cc1ff3e
commit 67c70bff05
4 changed files with 85 additions and 17 deletions
@@ -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<Void> delete(
@PathVariable Long issueId,
@@ -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<String> 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<GcVerificationIssueImage> 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) {
@@ -219,8 +219,18 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
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<Props> = ({
img.fileName ?? '첨부 파일',
isImage,
kind,
() => openAttachment(src),
() => openAttachment(src, img.fileName ?? '첨부 파일', isImage),
)}
<figcaption className="vbd-attach-caption" title={img.fileName}>
{img.fileName}
@@ -384,7 +394,7 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
p.fileName,
true,
p.kind,
() => openAttachment(p.src!),
() => openAttachment(p.src!, p.fileName, true),
)
: (
<div className="vbd-attach-thumb vbd-attach-thumb--file">
@@ -417,7 +427,7 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
img.fileName ?? '첨부 파일',
isImage,
kind,
() => openAttachment(src),
() => openAttachment(src, img.fileName ?? '첨부 파일', isImage),
)}
<figcaption className="vbd-attach-caption" title={img.fileName}>
{img.fileName}
+1 -1
View File
@@ -165,7 +165,7 @@ export async function uploadVerificationIssueImages(
files: File[],
): Promise<VerificationIssueImageDto[]> {
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(),