package com.goldenchart.service; import com.goldenchart.dto.VerificationIssueImageDto; import com.goldenchart.entity.GcVerificationIssueImage; import com.goldenchart.repository.GcVerificationIssueImageRepository; import com.goldenchart.repository.GcVerificationIssueRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; @Service @Slf4j public class VerificationIssueImageService { private static final Set ALLOWED_IMAGE_TYPES = Set.of( "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp" ); private static final Set ALLOWED_DOCUMENT_TYPES = Set.of( "application/zip", "application/x-zip-compressed", "application/x-hwp", "application/haansoft-hwp", "application/vnd.hancom.hwp", "application/hwp+zip", "application/vnd.hancom.hwpx", "application/x-hwpx" ); private final GcVerificationIssueImageRepository imageRepository; private final GcVerificationIssueRepository issueRepository; private final Path uploadRoot; private final long maxFileSizeBytes; private final int maxImagesPerIssue; public VerificationIssueImageService( GcVerificationIssueImageRepository imageRepository, GcVerificationIssueRepository issueRepository, @Value("${goldenchart.verification.upload-dir:data/verification-images}") String uploadDir, @Value("${goldenchart.verification.max-image-size-mb:5}") int maxImageSizeMb, @Value("${goldenchart.verification.max-images-per-issue:30}") int maxImagesPerIssue) throws IOException { this.imageRepository = imageRepository; this.issueRepository = issueRepository; this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize(); this.maxFileSizeBytes = maxImageSizeMb * 1024L * 1024L; this.maxImagesPerIssue = maxImagesPerIssue; Files.createDirectories(this.uploadRoot); } public List listByIssue(Long issueId) { return imageRepository.findByIssueIdOrderBySortOrderAscCreatedAtAsc(issueId).stream() .map(this::toDto) .toList(); } @Transactional public List upload(Long issueId, List files) throws IOException { if (!issueRepository.existsById(issueId)) { throw new IllegalArgumentException("issue not found: " + issueId); } if (files == null || files.isEmpty()) { throw new IllegalArgumentException("no files"); } long current = imageRepository.findByIssueIdOrderBySortOrderAscCreatedAtAsc(issueId).size(); if (current + files.size() > maxImagesPerIssue) { throw new IllegalArgumentException("max images exceeded: " + maxImagesPerIssue); } Path issueDir = uploadRoot.resolve("issue_" + issueId); Files.createDirectories(issueDir); int sort = (int) current; List saved = new java.util.ArrayList<>(); for (MultipartFile file : files) { if (file == null || file.isEmpty()) continue; byte[] data = file.getBytes(); String contentType = resolveContentType(file, data); validateFile(data.length, contentType); String original = resolveOriginalFileName(file, contentType); String storedName = UUID.randomUUID() + "_" + original; Path target = issueDir.resolve(storedName); Files.write(target, data); String relative = uploadRoot.relativize(target).toString().replace('\\', '/'); GcVerificationIssueImage entity = GcVerificationIssueImage.builder() .issueId(issueId) .fileName(original) .contentType(contentType) .storagePath(relative) .fileSize((long) data.length) .sortOrder(sort++) .build(); saved.add(toDto(imageRepository.save(entity))); } if (saved.isEmpty()) { throw new IllegalArgumentException("no valid attachment files"); } return saved; } public Optional loadFile(Long issueId, Long imageId) { return imageRepository.findById(imageId) .filter(img -> issueId.equals(img.getIssueId())) .map(img -> { try { Path path = uploadRoot.resolve(img.getStoragePath()).normalize(); if (!path.startsWith(uploadRoot) || !Files.exists(path)) { return null; } Resource resource = new UrlResource(path.toUri()); return resource.exists() && resource.isReadable() ? resource : null; } catch (Exception e) { log.warn("image load failed id={}: {}", imageId, e.getMessage()); return null; } }); } public Optional contentType(Long issueId, Long imageId) { return imageRepository.findById(imageId) .filter(img -> issueId.equals(img.getIssueId())) .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) .filter(img -> issueId.equals(img.getIssueId())); if (opt.isEmpty()) return false; GcVerificationIssueImage img = opt.get(); deletePhysicalFile(img.getStoragePath()); imageRepository.delete(img); return true; } @Transactional public void deleteAllForIssue(Long issueId) throws IOException { List images = imageRepository.findByIssueIdOrderBySortOrderAscCreatedAtAsc(issueId); for (GcVerificationIssueImage img : images) { deletePhysicalFile(img.getStoragePath()); } imageRepository.deleteByIssueId(issueId); Path issueDir = uploadRoot.resolve("issue_" + issueId); if (Files.isDirectory(issueDir)) { try { Files.deleteIfExists(issueDir); } catch (IOException ignored) { /* ok */ } } } private void validateFile(long sizeBytes, String contentType) { if (contentType == null || !isAllowedContentType(contentType)) { throw new IllegalArgumentException("unsupported file type"); } if (sizeBytes > maxFileSizeBytes) { throw new IllegalArgumentException("file too large"); } } private static boolean isAllowedContentType(String contentType) { return ALLOWED_IMAGE_TYPES.contains(contentType) || ALLOWED_DOCUMENT_TYPES.contains(contentType); } private String resolveContentType(MultipartFile file, byte[] data) { String type = file.getContentType(); if (type != null && !type.isBlank()) { type = type.split(";")[0].trim().toLowerCase(); if ("image/jpg".equals(type) || "image/pjpeg".equals(type)) { return "image/jpeg"; } if ("image/x-png".equals(type)) { return "image/png"; } if (ALLOWED_IMAGE_TYPES.contains(type)) { return type; } if (ALLOWED_DOCUMENT_TYPES.contains(type)) { return type; } // application/octet-stream 등 — 매직 바이트·확장자로 판별 if ("application/octet-stream".equals(type) || type.startsWith("image/")) { String sniffed = sniffFileType(data, file.getOriginalFilename()); if (sniffed != null) return sniffed; } } String name = file.getOriginalFilename(); if (name != null) { String lower = name.toLowerCase(); if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; if (lower.endsWith(".png")) return "image/png"; if (lower.endsWith(".gif")) return "image/gif"; if (lower.endsWith(".webp")) return "image/webp"; if (lower.endsWith(".bmp")) return "image/bmp"; if (lower.endsWith(".zip")) return "application/zip"; if (lower.endsWith(".hwp")) return "application/x-hwp"; if (lower.endsWith(".hwpx")) return "application/vnd.hancom.hwpx"; } return sniffFileType(data, file.getOriginalFilename()); } private String sniffFileType(byte[] header, String fileName) { String image = sniffImageType(header); if (image != null) return image; if (header != null && header.length >= 4 && header[0] == 0x50 && header[1] == 0x4B && (header[2] == 0x03 || header[2] == 0x05 || header[2] == 0x07)) { if (fileName != null && fileName.toLowerCase().endsWith(".hwpx")) { return "application/vnd.hancom.hwpx"; } return "application/zip"; } if (header != null && header.length >= 8 && header[0] == (byte) 0xD0 && header[1] == (byte) 0xCF && header[2] == 0x11 && header[3] == (byte) 0xE0) { return "application/x-hwp"; } return null; } private String sniffImageType(byte[] header) { if (header == null) return null; if (header.length >= 8 && header[0] == (byte) 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47) { return "image/png"; } if (header.length >= 3 && header[0] == (byte) 0xFF && header[1] == (byte) 0xD8 && header[2] == (byte) 0xFF) { return "image/jpeg"; } if (header.length >= 6 && header[0] == 0x47 && header[1] == 0x49 && header[2] == 0x46) { return "image/gif"; } if (header.length >= 12 && header[0] == 0x52 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x46 && header[8] == 0x57 && header[9] == 0x45 && header[10] == 0x42 && header[11] == 0x50) { return "image/webp"; } if (header.length >= 2 && header[0] == 0x42 && header[1] == 0x4D) { return "image/bmp"; } return null; } private void deletePhysicalFile(String storagePath) throws IOException { Path path = uploadRoot.resolve(storagePath).normalize(); if (path.startsWith(uploadRoot)) { Files.deleteIfExists(path); } } /** 업로드 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) { return VerificationIssueImageDto.builder() .id(e.getId()) .issueId(e.getIssueId()) .fileName(e.getFileName()) .contentType(e.getContentType()) .fileSize(e.getFileSize()) .sortOrder(e.getSortOrder()) .url("/verification-issues/" + e.getIssueId() + "/images/" + e.getId() + "/file") .createdAt(e.getCreatedAt()) .build(); } }