검증게시판 첨부파일 첨부 및 삭제기능
This commit is contained in:
+20
-3
@@ -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}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user