From f5b0a2649d0b70f1b84de87ea155ceb37603dc47 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:26:50 +0000 Subject: [PATCH 1/4] Add client-side file type validation to drop zones --- .jules/palette.md | 4 ++++ CHANGELOG.md | 1 + saas_web.py | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/.jules/palette.md b/.jules/palette.md index 2dcd639e..d55271e2 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -81,3 +81,7 @@ ## 2024-08-04 - 숫자 입력 필드 빈 문자열 상태 초기화 처리 **학습:** 숫자 입력 필드에서 빈 문자열('')을 입력할 때 브라우저는 이전의 유효하지 않은 상태를 암시적으로 유지하므로, 사용자 정의 검증을 명시적으로 초기화하지 않으면 네이티브 HTML5 유효성 검사가 정상 작동하지 않을 수 있음을 확인했습니다. **실행:** 인라인 검증 스크립트 작성 시 빈 문자열 상태를 별도로 확인하여 this.setCustomValidity('') 및 this.removeAttribute('aria-invalid')를 명시적으로 호출하는 로직을 추가해야 합니다. + +## 2024-09-10 - File Type Validation in Drop Zones +**Learning:** HTML `accept` attribute does not reliably prevent invalid files from being dropped. Always add explicit client-side JavaScript validation against `file.type` and provide inline accessibility feedback using `setCustomValidity` and `aria-invalid` to ensure a smooth, accessible user experience. +**Action:** Added explicit client-side validation against `file.type` in the file drop zones to provide immediate feedback when invalid file types are dropped. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9313538b..91e25815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] ### Added +- 파일 드롭 영역에서 지원되지 않는 파일 형식을 드래그 앤 드롭했을 때 즉각적인 경고 및 접근성 피드백을 제공하도록 클라이언트 측 파일 유형 검증 로직을 추가했습니다. - 다중 파일 업로드 선택 시 즉각적인 파일 개수 피드백 및 제한 초과 경고 메시지 추가 - 일괄 업로드 폼에 대상 바이트 프리셋 버튼과 총 파일 크기 미리보기를 추가하여 사용성을 개선했습니다. - 클라이언트 측 폼 검증 시 하드코딩된 '5 GiB' 텍스트를 동적으로 변환되도록 수정하고 일괄 업로드 폼에 최대 크기(MAX_UPLOAD_BYTES) 검증 피드백을 추가했습니다. diff --git a/saas_web.py b/saas_web.py index 63265e94..cb28d3e7 100644 --- a/saas_web.py +++ b/saas_web.py @@ -228,6 +228,13 @@ async def add_security_headers(request: Request, call_next): preview.innerText = ''; return; } + if (file.type && !file.type.startsWith('audio/') && !file.type.startsWith('video/')) { + input.setCustomValidity('Unsupported file type. Please select an audio or video file.'); + input.setAttribute('aria-invalid', 'true'); + preview.innerText = 'Selected file is not an audio or video file.'; + preview.style.color = '#dc3545'; + return; + } const text = formatBinaryBytes(file.size); if (file.size > MAX_UPLOAD_BYTES) { const limitText = formatBinaryBytes(MAX_UPLOAD_BYTES); @@ -329,8 +336,20 @@ async def add_security_headers(request: Request, call_next): } let totalSize = 0; + let invalidTypeCount = 0; for (let i = 0; i < files.length; i++) { totalSize += files[i].size; + if (files[i].type && !files[i].type.startsWith('audio/') && !files[i].type.startsWith('video/')) { + invalidTypeCount++; + } + } + + if (invalidTypeCount > 0) { + input.setCustomValidity('Unsupported file type. Please select audio or video files only.'); + input.setAttribute('aria-invalid', 'true'); + preview.innerText = invalidTypeCount + ' of ' + files.length + ' selected files are not audio or video files.'; + preview.style.color = '#dc3545'; + return; } if (files.length > 20) { From 41ab51e53b0db1f2fcdbe0a3a1bfe08334dd8c60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 03:17:39 +0900 Subject: [PATCH 2/4] docs(ux): remove overgeneralized MIME validation doctrine --- .jules/palette.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.jules/palette.md b/.jules/palette.md index d55271e2..29b3304d 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -77,11 +77,7 @@ **Action:** When using `multiple` file inputs, always implement an `onchange` event listener to validate the file count and file size limits on the client side, using `setCustomValidity` and `aria-invalid` to provide immediate inline feedback before submission. ## 2024-07-13 - 일괄 업로드 폼에 프리셋 버튼 및 파일 크기 미리보기 추가 **Learning:** 일괄 파일 업로드 폼에서 대상 바이트(target_bytes) 입력 필드만 제공하면 사용자가 원하는 용량을 바이트 단위로 정확히 계산하기 어려워 사용성이 떨어집니다. 사용자가 여러 파일을 업로드할 때 총 파일 크기를 파악하지 못해 업로드 제한을 초과하거나 잘못된 대상 바이트를 설정할 위험이 큽니다. -**Action:** 일괄 파일 업로드 폼에도 단일 파일 업로드 폼과 동일하게 대상 바이트를 쉽게 선택할 수 있는 빠른 프리셋 버튼을 추가하고, `onchange` 이벤트 발생 시 선택된 모든 파일의 크기를 합산하여 사람이 읽기 쉬운 단위(MiB, GiB 등)로 미리보기를 제공하도록 JavaScript 로직을 개선했습니다. +**Action:** 일괄 업로드 폼에도 단일 파일 업로드 폼과 동일하게 대상 바이트를 쉽게 선택할 수 있는 빠른 프리셋 버튼을 추가하고, `onchange` 이벤트 발생 시 선택된 모든 파일의 크기를 합산하여 사람이 읽기 쉬운 단위(MiB, GiB 등)로 미리보기를 제공하도록 JavaScript 로직을 개선했습니다. ## 2024-08-04 - 숫자 입력 필드 빈 문자열 상태 초기화 처리 **학습:** 숫자 입력 필드에서 빈 문자열('')을 입력할 때 브라우저는 이전의 유효하지 않은 상태를 암시적으로 유지하므로, 사용자 정의 검증을 명시적으로 초기화하지 않으면 네이티브 HTML5 유효성 검사가 정상 작동하지 않을 수 있음을 확인했습니다. **실행:** 인라인 검증 스크립트 작성 시 빈 문자열 상태를 별도로 확인하여 this.setCustomValidity('') 및 this.removeAttribute('aria-invalid')를 명시적으로 호출하는 로직을 추가해야 합니다. - -## 2024-09-10 - File Type Validation in Drop Zones -**Learning:** HTML `accept` attribute does not reliably prevent invalid files from being dropped. Always add explicit client-side JavaScript validation against `file.type` and provide inline accessibility feedback using `setCustomValidity` and `aria-invalid` to ensure a smooth, accessible user experience. -**Action:** Added explicit client-side validation against `file.type` in the file drop zones to provide immediate feedback when invalid file types are dropped. From 7236f4942cbf999061b2a4ccb68547a253ded4d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 03:18:00 +0900 Subject: [PATCH 3/4] test(ux): expose unknown browser MIME as explicit RED --- tests/test_upload_mime_ux_contract.py | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/test_upload_mime_ux_contract.py diff --git a/tests/test_upload_mime_ux_contract.py b/tests/test_upload_mime_ux_contract.py new file mode 100644 index 00000000..e467dd55 --- /dev/null +++ b/tests/test_upload_mime_ux_contract.py @@ -0,0 +1,31 @@ +import unittest + +try: + from fastapi.testclient import TestClient + + from saas_web import app + + _HAS_FASTAPI = True +except ImportError: + _HAS_FASTAPI = False + + +@unittest.skipUnless( + _HAS_FASTAPI, "fastapi not installed (optional integration dependency)" +) +class UploadMimeUxContractTests(unittest.TestCase): + """Rendered-page contract for browser MIME uncertainty.""" + + def test_unknown_browser_mime_is_not_silently_treated_as_verified_media(self) -> None: + """An empty File.type must receive explicit, non-blocking server-validation copy.""" + html = TestClient(app).get("/").text + + self.assertIn("if (!file.type)", html) + self.assertIn( + "File type could not be identified; it will be validated after upload.", + html, + ) + + +if __name__ == "__main__": + unittest.main() From 4b106434466e8a3759daff9e82ac44402a99261e Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:34:02 +0000 Subject: [PATCH 4/4] Implement three-state UX for empty file.type uploads --- saas_web.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/saas_web.py b/saas_web.py index cb28d3e7..c310e4de 100644 --- a/saas_web.py +++ b/saas_web.py @@ -228,23 +228,28 @@ async def add_security_headers(request: Request, call_next): preview.innerText = ''; return; } - if (file.type && !file.type.startsWith('audio/') && !file.type.startsWith('video/')) { + + let unknownMimeNotice = ''; + if (!file.type) { + unknownMimeNotice = ' File type could not be identified; it will be validated after upload.'; + } else if (!file.type.startsWith('audio/') && !file.type.startsWith('video/')) { input.setCustomValidity('Unsupported file type. Please select an audio or video file.'); input.setAttribute('aria-invalid', 'true'); preview.innerText = 'Selected file is not an audio or video file.'; preview.style.color = '#dc3545'; return; } + const text = formatBinaryBytes(file.size); if (file.size > MAX_UPLOAD_BYTES) { const limitText = formatBinaryBytes(MAX_UPLOAD_BYTES); input.setCustomValidity('File exceeds ' + limitText + ' limit.'); input.setAttribute('aria-invalid', 'true'); - preview.innerText = 'Selected file size: ' + text + ' (exceeds ' + limitText + ' limit)'; + preview.innerText = 'Selected file size: ' + text + ' (exceeds ' + limitText + ' limit)' + unknownMimeNotice; preview.style.color = '#dc3545'; return; } - preview.innerText = 'Selected file size: ' + text; + preview.innerText = 'Selected file size: ' + text + unknownMimeNotice; } document.getElementById('target_bytes').addEventListener('input', function(e) { @@ -337,9 +342,12 @@ async def add_security_headers(request: Request, call_next): let totalSize = 0; let invalidTypeCount = 0; + let unknownTypeCount = 0; for (let i = 0; i < files.length; i++) { totalSize += files[i].size; - if (files[i].type && !files[i].type.startsWith('audio/') && !files[i].type.startsWith('video/')) { + if (!files[i].type) { + unknownTypeCount++; + } else if (!files[i].type.startsWith('audio/') && !files[i].type.startsWith('video/')) { invalidTypeCount++; } } @@ -352,10 +360,15 @@ async def add_security_headers(request: Request, call_next): return; } + let unknownMimeNotice = ''; + if (unknownTypeCount > 0) { + unknownMimeNotice = ' ' + (unknownTypeCount === 1 ? 'File' : unknownTypeCount + ' files') + ' type could not be identified; it will be validated after upload.'; + } + if (files.length > 20) { input.setCustomValidity('Maximum is 20 files per batch.'); input.setAttribute('aria-invalid', 'true'); - preview.innerText = 'Selected ' + files.length + ' files (' + formatBinaryBytes(totalSize) + ', exceeds 20 files limit)'; + preview.innerText = 'Selected ' + files.length + ' files (' + formatBinaryBytes(totalSize) + ', exceeds 20 files limit)' + unknownMimeNotice; preview.style.color = '#dc3545'; return; } @@ -364,11 +377,11 @@ async def add_security_headers(request: Request, call_next): const limitText = formatBinaryBytes(MAX_UPLOAD_BYTES); input.setCustomValidity('Total file size exceeds ' + limitText + ' limit.'); input.setAttribute('aria-invalid', 'true'); - preview.innerText = 'Selected ' + files.length + ' file(s) (' + formatBinaryBytes(totalSize) + ', exceeds ' + limitText + ' limit)'; + preview.innerText = 'Selected ' + files.length + ' file(s) (' + formatBinaryBytes(totalSize) + ', exceeds ' + limitText + ' limit)' + unknownMimeNotice; preview.style.color = '#dc3545'; return; } - preview.innerText = 'Selected ' + files.length + ' file(s) (' + formatBinaryBytes(totalSize) + ')'; + preview.innerText = 'Selected ' + files.length + ' file(s) (' + formatBinaryBytes(totalSize) + ')' + unknownMimeNotice; } document.getElementById('shrink-batch-form').addEventListener('submit', function() {