I’m dealing with a really unusual and protracted picture add concern that happens solely in Cellular-Safari, and solely when the server is operating on AWS EC2.
I’ve spent a big period of time debugging this, and at this level I’m making an attempt to find out whether or not it is a Safari + community/server setting interplay concern, fairly than an application-level bug.
Picture add works completely on:
Chrome / Firefox (all platforms)
Safari when the server is operating on my native Home windows 11 laptop computer
Picture add fails (corrupted picture) on:
Safari (macOS / iOS)
When the server is deployed on AWS EC2
The picture is already corrupted earlier than any processing, straight on the Spring Boot controller degree
The corruption is seen as: Random horizontal traces Damaged JPEG construction
Totally different hex/binary content material in comparison with the unique file
What I attempted
-
A number of add strategies examined
multipart/form-data
utility/octet-stream
XMLHttpRequest
Sending uncooked File object with out wrapping
No handbook Content material-Sort header
No filename manipulation
→ Similar consequence: Safari + EC2 = corrupted picture
Server specs
-
Examined EC2 situations: t2.micro, t3.xlarge
Similar habits no matter CPU/reminiscence
*** I did not strive it on the safari on the desktop. Solely Cellular-Safari**
@PostMapping(worth = "/api/ckeditor/imageUpload", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE} )
@ResponseBody
public Map<String, Object> saveContentsImage(@RequestPart(worth = "add", required = true) MultipartFile uploadFile, HttpServletRequest req, HttpServletResponse httpsResponse) {
// ────────────────────────────────
// 1️⃣ 기본 요청 정보 로깅
// ────────────────────────────────
String userAgent = req.getHeader("Person-Agent");
String contentType = uploadFile.getContentType();
lengthy fileSize = uploadFile.getSize();
log.data("=== [UPLOAD DEBUG] CKEditor Picture Add Request ===");
log.data("Request Technique : {}", req.getMethod());
log.data("Shopper IP : {}", req.getRemoteAddr());
log.data("Person-Agent : {}", userAgent);
log.data("Detected Browser : {}", detectBrowser(userAgent));
log.data("Content material-Sort (Half) : {}", contentType);
log.data("Content material-Size (Hdr) : {}", req.getHeader("Content material-Size"));
log.data("Multipart Measurement : {} bytes", fileSize);
log.data("=====================================================");
// Safari 업로드 이슈 발생 빈도가 높으므로 별도 디버그 경로에 저장
boolean isSafari = userAgent != null && userAgent.accommodates("Safari") && !userAgent.accommodates("Chrome");
// ================== [DEBUG] Save uncooked file at Controller degree (Person's requested methodology) ==================
strive {
String projectRootPath = System.getProperty("person.dir");
java.io.File debugDir = new java.io.File(projectRootPath, "tmp_debug");
if (!debugDir.exists()) {
debugDir.mkdirs();
}
// Sanitize the unique filename to forestall path traversal points
String originalFilename = org.springframework.util.StringUtils.cleanPath(uploadFile.getOriginalFilename());
// Differentiate the filename to point it is from the controller
java.io.File rawDebugFile = new java.io.File(debugDir, "controller_raw_" + originalFilename);
log.data("CONTROLLER DEBUG: Making an attempt to repeat uploaded file to: {}", rawDebugFile.getAbsolutePath());
// Use InputStream to repeat the file, which doesn't transfer the unique temp file.
strive (java.io.InputStream in = uploadFile.getInputStream();
java.io.OutputStream out = new java.io.FileOutputStream(rawDebugFile)) {
in.transferTo(out);
}
log.data("CONTROLLER DEBUG: File efficiently copied. Measurement: {} bytes", rawDebugFile.size());
} catch (Exception e) {
log.error("CONTROLLER DEBUG: Failed to repeat debug file.", e);
}
// ================== [DEBUG] END ===================
Lengthy sessionCustomerId = SessionUtils.getSessionCustomerId(req);
if (sessionCustomerId == null) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("uploaded", 0);
errorResponse.put("error", Map.of("message", "세션이 만료되었거나 로그인 상태가 아닙니다."));
return errorResponse;
}
String customerId = sessionCustomerId.toString();
String imageUrl = postUtils.saveContentsImage(uploadFile, customerId);
Map<String, Object> response = new HashMap<>();
response.put("uploaded", 1);
response.put("fileName", uploadFile.getOriginalFilename());
response.put("url", imageUrl);
return response;
}
=============================================================================
JS CODE
const knowledge = new FormData();
// *** CSRF 토큰을 FormData에 직접 추가 ***
const csrfToken = doc.querySelector('meta[name="_csrf"]')?.getAttribute('content material');
const csrfParameterName = doc.querySelector('meta[name="_csrf_parameter"]')?.getAttribute('content material') || '_csrf';
if (csrfToken) {
console.log(csrfToken);
knowledge.append(csrfParameterName, csrfToken);
}
// const safeFile = new File([finalBlob], finalFileName, { sort: finalMimeType });
//const safeFile = new File([ab], finalFileName, { sort: finalMimeType });
// ✅ 2) File() 감싸지 말고 그대로 append
//knowledge.append('add', finalBlob, finalFileName);
// 원본 File 그대로 append (Blob 래핑 ❌)
//knowledge.append('add', originalFile);
// 원본 파일의 내용을 기반으로 Content material-Type이 'utility/octet-stream'으로 지정된 새 File 객체를 생성합니다.
const octetStreamFile = new File([finalBlob], finalFileName, {
sort: 'utility/octet-stream'
});
console.log(`[UploadAdapter] Content material-Type을 'utility/octet-stream'으로 강제 변환하여 업로드를 시도합니다.`
);
// 새로 생성된 File 객체를 FormData에 추가합니다.
knowledge.append('add', octetStreamFile);
//const cleanBlob = new Blob([originalFile], { sort: originalFile.sort });
//knowledge.set('add', originalFile, toAsciiFilename(originalFile.title));
//knowledge.set('add', originalFile,toAsciiFilename(originalFile.title));
//knowledge.append("add", safeFile);
// === fetch 업로드 (대체) 시작 ===
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/ckeditor/imageUpload", true);
xhr.withCredentials = true;
// Safari의 multipart/form-data 업로드 안정성을 높이기 위해 수동 boundary 지정 없이 자동 생성하게 둠
// Content material-Type을 직접 지정하지 않음 — 브라우저가 자동으로 생성하도록
xhr.add.onprogress = (occasion) => {
if (occasion.lengthComputable) {
const percentCompleted = Math.spherical((occasion.loaded * 100) / occasion.complete);
const progressBarFill = doc.querySelector('.progressBarFill');
if (progressBarFill) {
progressBarFill.fashion.width = percentCompleted + '%';
}
const progressText = doc.querySelector('.uploadingText');
if (progressText) {
progressText.textContent = `이미지 업로드 중... ${Math.spherical(occasion.loaded / 1024)}KB / ${Math.spherical(occasion.complete / 1024)}KB (${percentCompleted}%)`;
}
}
};




