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

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 org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -64,14 +66,29 @@ public class VerificationIssueImageController {
return service.loadFile(issueId, imageId) return service.loadFile(issueId, imageId)
.map(resource -> { .map(resource -> {
String type = service.contentType(issueId, imageId).orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE); String type = service.contentType(issueId, imageId).orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE);
return ResponseEntity.ok() var builder = ResponseEntity.ok()
.contentType(MediaType.parseMediaType(type)) .contentType(MediaType.parseMediaType(type))
.header(HttpHeaders.CACHE_CONTROL, "private, max-age=3600") .header(HttpHeaders.CACHE_CONTROL, "private, max-age=3600");
.body(resource); 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()); .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}") @DeleteMapping("/{imageId}")
public ResponseEntity<Void> delete( public ResponseEntity<Void> delete(
@PathVariable Long issueId, @PathVariable Long issueId,
@@ -93,7 +93,7 @@ public class VerificationIssueImageService {
String contentType = resolveContentType(file, data); String contentType = resolveContentType(file, data);
validateFile(data.length, contentType); validateFile(data.length, contentType);
String original = sanitizeFileName(file.getOriginalFilename()); String original = resolveOriginalFileName(file, contentType);
String storedName = UUID.randomUUID() + "_" + original; String storedName = UUID.randomUUID() + "_" + original;
Path target = issueDir.resolve(storedName); Path target = issueDir.resolve(storedName);
Files.write(target, data); Files.write(target, data);
@@ -140,6 +140,12 @@ public class VerificationIssueImageService {
.map(GcVerificationIssueImage::getContentType); .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 @Transactional
public boolean delete(Long issueId, Long imageId) throws IOException { public boolean delete(Long issueId, Long imageId) throws IOException {
Optional<GcVerificationIssueImage> opt = imageRepository.findById(imageId) Optional<GcVerificationIssueImage> opt = imageRepository.findById(imageId)
@@ -234,9 +240,7 @@ public class VerificationIssueImageService {
if (header != null && header.length >= 8 if (header != null && header.length >= 8
&& header[0] == (byte) 0xD0 && header[1] == (byte) 0xCF && header[0] == (byte) 0xD0 && header[1] == (byte) 0xCF
&& header[2] == 0x11 && header[3] == (byte) 0xE0) { && header[2] == 0x11 && header[3] == (byte) 0xE0) {
if (fileName != null && fileName.toLowerCase().endsWith(".hwp")) { return "application/x-hwp";
return "application/x-hwp";
}
} }
return null; return null;
} }
@@ -274,10 +278,47 @@ public class VerificationIssueImageService {
} }
} }
private String sanitizeFileName(String name) { /** 업로드 multipart의 원본 파일명·확장자를 그대로 보존 (경로·위험 문자만 제거) */
if (name == null || name.isBlank()) return "attachment.bin"; private String resolveOriginalFileName(MultipartFile file, String contentType) {
String base = Paths.get(name).getFileName().toString(); String name = extractBaseFileName(file.getOriginalFilename());
return base.replaceAll("[^a-zA-Z0-9._\\-가-힣]", "_"); 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) { private VerificationIssueImageDto toDto(GcVerificationIssueImage e) {
@@ -219,8 +219,18 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
onDraftFilesChange?.(draftFiles.filter((_, i) => i !== index)); onDraftFilesChange?.(draftFiles.filter((_, i) => i !== index));
}; };
const openAttachment = (src: string) => { const openAttachment = (src: string, fileName: string, isImage: boolean) => {
window.open(src, '_blank', 'noopener,noreferrer'); 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 = ( const renderAttachmentThumb = (
@@ -358,7 +368,7 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
img.fileName ?? '첨부 파일', img.fileName ?? '첨부 파일',
isImage, isImage,
kind, kind,
() => openAttachment(src), () => openAttachment(src, img.fileName ?? '첨부 파일', isImage),
)} )}
<figcaption className="vbd-attach-caption" title={img.fileName}> <figcaption className="vbd-attach-caption" title={img.fileName}>
{img.fileName} {img.fileName}
@@ -384,7 +394,7 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
p.fileName, p.fileName,
true, true,
p.kind, p.kind,
() => openAttachment(p.src!), () => openAttachment(p.src!, p.fileName, true),
) )
: ( : (
<div className="vbd-attach-thumb vbd-attach-thumb--file"> <div className="vbd-attach-thumb vbd-attach-thumb--file">
@@ -417,7 +427,7 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
img.fileName ?? '첨부 파일', img.fileName ?? '첨부 파일',
isImage, isImage,
kind, kind,
() => openAttachment(src), () => openAttachment(src, img.fileName ?? '첨부 파일', isImage),
)} )}
<figcaption className="vbd-attach-caption" title={img.fileName}> <figcaption className="vbd-attach-caption" title={img.fileName}>
{img.fileName} {img.fileName}
+1 -1
View File
@@ -165,7 +165,7 @@ export async function uploadVerificationIssueImages(
files: File[], files: File[],
): Promise<VerificationIssueImageDto[]> { ): Promise<VerificationIssueImageDto[]> {
const form = new FormData(); 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`, { const res = await fetch(`${API_BASE}/verification-issues/${issueId}/images`, {
method: 'POST', method: 'POST',
headers: authHeadersMultipart(), headers: authHeadersMultipart(),