HomeiOS DevelopmentPicture file corrupted solely when uploaded from Cellular Safari to Spring Boot...

Picture file corrupted solely when uploaded from Cellular Safari to Spring Boot on AWS EC2


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**

Picture file corrupted solely when uploaded from Cellular Safari to Spring Boot on AWS EC2

normal hex dump
corrupted hex dump

@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}%)`;
                }
            }
        };

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

- Advertisment -
Google search engine

Most Popular

Recent Comments