diff --git a/flutter/README.md b/flutter/README.md index c4e1389..b0f55a4 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -56,13 +56,22 @@ flutter build appbundle --release # Play 에 올리는 꼴 — 아래 서명 - **Android 백업을 껐다**(`android:allowBackup="false"` · `res/xml/data_extraction_rules.xml`) — 보안 저장소의 값은 이 기기의 Keystore 키로 암호화되어 다른 기기로 복원되면 읽지 못하고, 첫 실행 표시까지 복원되면 남은 토큰 지우기가 돌지 않는다. - **iOS Keychain 은 앱을 지워도 남는다** — 일반 저장소(`shared_preferences`)의 첫 실행 표시가 없으면 앱을 띄우기 전에 보안 저장소를 모두 지운다(`core/storage/token_store.dart` 의 `prepare`). - 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각)로 남은 시간을 잰다. +- **받은 메일 본문(WebView)**: 앱이 한 번 더 정화한 문서(`features/mail/domain/mail_html.dart` — 허용 목록 · CSP `default-src 'none'; img-src data:`)만 넣고, + 스크립트 · 파일 · content 주소 접근을 끄고, 주 틀의 탐색은 모두 막는다(링크는 바깥 브라우저 · mailto 는 쓰기 화면). 아래 틀(iframe)의 탐색은 안드로이드가 판정에 넘기지 않는다 — + 아래 틀은 정화(iframe 을 내용째 버림)와 CSP(`default-src 'none'` — frame-src)가 막는다. 외부 이미지는 앱이 Dio(Bearer)로 받아 `data:` 로 넣는다 — 토큰이 WebView 에 가지 않는다. + 문서에 싣는 그림 글은 같은 그림이 여러 번 나오면 나온 만큼 세어 10MB 에서 멈춘다. +- **받은 첨부**: 앱 임시 폴더(`cacheDir`)의 `downloads/` 에 받고, 다른 앱으로 열 때는 FileProvider(`${applicationId}.downloads` — 그 폴더만)의 주소와 읽기 권한 1회로 `ACTION_VIEW` 한다(`MainActivity.kt` 의 파일 채널). + 앱을 띄울 때(지난 실행이 남긴 것) · 메일을 보는 직원이 바뀔 때 모두 지운다. 파일 이름은 경로 · 제어 · 방향 제어 글자를 지우고 줄인다(`core/files/file_names.dart`). + 열 형식은 정리한 이름의 확장자에서 먼저 정하고(보낸 쪽 형식은 확장자를 모를 때만), 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음은 받지 않고 알린다(`core/files/file_types.dart`). + 고르는 창(`createChooser`)으로 열지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다. ## 나중에 넣을 때 주의 - **build_runner(freezed · json_serializable · riverpod_generator)**: 같은 폴더의 `lib/core/api/error_lexicon.g.dart`(백엔드 레지스트리에서 만든 사전 — 직접 고치지 않는다)를 build_runner 가 자기 출력으로 보고 지울 수 있다. 넣을 때는 `build.yaml` 의 `generate_for` 를 모델 폴더로 좁히고, 처음 한 번 돌린 뒤 사전이 그대로인지(`git diff`) 확인한다. -- **첨부 받기(스트림 · 바이트 응답)**: `responseType` 이 `bytes` · `stream` 이면 실패 응답의 본문도 JSON 으로 풀리지 않아 `toApiException` 이 HTTP 상태로만 코드를 정한다(`FILE_007` · `FILE_008` 같은 코드를 잃는다). - 받기를 만들 때 실패 본문(작다)을 끝까지 읽어 UTF-8 · JSON 으로 풀어 표준 실패의 `error.code` 를 꺼낸 뒤 코드를 정한다. 성공 본문은 지금처럼 풀지 않고 그대로 넘긴다(시험이 지킨다). +- **첨부 받기(스트림 · 바이트 응답)**: 실패 응답의 본문은 `ResponseInterceptor.onError` 가 64KB 까지 읽어 UTF-8 · JSON 으로 풀고 표준 실패의 `error.code` 를 꺼낸다 + (`decodeRawErrorBody` — 그 전에는 HTTP 상태로만 정해 `FILE_007` · `FILE_008` 같은 코드를 잃었다). 성공 본문은 풀지 않고 그대로 넘긴다(시험이 지킨다). + 받는 도중 끊기거나 조각 사이 받기 한도(`receiveTimeout` — Dio 가 스트림에 싣는다)를 넘기면 응답이 이미 인터셉터를 지나 아무도 알리지 않으므로 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). ## 라이선스 diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index 0773065..1030971 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -35,6 +35,19 @@ + + + + + when (call.method) { + OPEN_METHOD -> result.success( + openFile(call.argument("path"), call.argument("type")), + ) + else -> result.notImplemented() + } + } + } + + private fun openFile(path: String?, type: String?): String { + if (path == null) return RESULT_FAILED + val file: File + val root: File + try { + root = File(cacheDir, DOWNLOADS_DIR).canonicalFile + file = File(path).canonicalFile + } catch (error: IOException) { + return RESULT_FAILED + } + // 받은 파일 폴더 밖은 열지 않는다 (../ · 심볼릭 링크로 앱의 다른 파일을 건네지 않게) + if (!file.path.startsWith(root.path + File.separator)) return RESULT_FAILED + if (!file.isFile) return RESULT_NOT_FOUND + val uri = try { + FileProvider.getUriForFile(this, packageName + AUTHORITY_SUFFIX, file) + } catch (error: IllegalArgumentException) { + return RESULT_FAILED + } + // 고르는 창(createChooser)은 쓰지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다 + val intent = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, mimeTypeOf(type)) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + return try { + startActivity(intent) + RESULT_OPENED + } catch (error: ActivityNotFoundException) { + RESULT_NO_APP + } catch (error: SecurityException) { + RESULT_FAILED + } + } + + /** + * 넘겨받은 형식 — Dart 가 정한다(`core/files/file_types.dart` 의 `fileOpenTypeOf` — 확장자에서 먼저, 열지 않는 형식은 받기 전에 막는다). + * 여기서는 모양만 다시 본다 — 틀리면 아무 형식(octet-stream) + */ + private fun mimeTypeOf(type: String?): String { + val given = type?.trim()?.lowercase() + return if (given != null && MIME_PATTERN.matches(given)) given else GENERIC_TYPE + } + + private companion object { + /** 짝: Dart `PlatformFileOpener.channel` · `openMethod` */ + const val FILES_CHANNEL = "kr.it.arasystem.app/files" + const val OPEN_METHOD = "open" + + /** 짝: Dart `kDownloadedFilesFolder` · `res/xml/downloaded_file_paths.xml` 의 cache-path */ + const val DOWNLOADS_DIR = "downloads" + + /** 짝: `AndroidManifest.xml` 의 provider authorities(`${applicationId}.downloads`) */ + const val AUTHORITY_SUFFIX = ".downloads" + + /** 짝: Dart `kGenericFileType` · `_typePattern`(`core/files/file_types.dart`) */ + const val GENERIC_TYPE = "application/octet-stream" + val MIME_PATTERN = Regex("^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$") + + const val RESULT_OPENED = "opened" + const val RESULT_NO_APP = "noApp" + const val RESULT_NOT_FOUND = "notFound" + const val RESULT_FAILED = "failed" + } +} diff --git a/flutter/android/app/src/main/res/xml/downloaded_file_paths.xml b/flutter/android/app/src/main/res/xml/downloaded_file_paths.xml new file mode 100644 index 0000000..437824e --- /dev/null +++ b/flutter/android/app/src/main/res/xml/downloaded_file_paths.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/flutter/lib/core/api/response_interceptor.dart b/flutter/lib/core/api/response_interceptor.dart index 114dcaf..2aafc45 100644 --- a/flutter/lib/core/api/response_interceptor.dart +++ b/flutter/lib/core/api/response_interceptor.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:arasystem_app/core/api/api_error_mapper.dart'; import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:dio/dio.dart'; @@ -41,12 +44,75 @@ class ResponseInterceptor extends Interceptor { ); } + /// 실패 — 코드를 정한다. 바이트 · 스트림 응답(첨부 받기 · 그림)의 실패 본문은 JSON 으로 풀리지 않은 채 오므로 + /// 먼저 읽어(작다 — [kMaxErrorBodyBytes] 까지) 표준 실패의 `error.code` 를 꺼낸다. 성공 본문은 건드리지 않는다. + /// + /// 무슨 일이 있어도 `handler.next` 로 끝낸다(인증 인터셉터와 같은 규칙) — 비동기 처리기가 던지면 요청이 끝나지 않고 매달린다 @override - void onError(DioException err, ErrorInterceptorHandler handler) { + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { if (err.error is ApiException) { handler.next(err); return; } - handler.next(err.copyWith(error: toApiException(err))); + DioException failure; + try { + final Response? response = err.response; + if (response != null) { + final Map? decoded = await decodeRawErrorBody( + response.data, + ); + if (decoded != null) response.data = decoded; + } + failure = err.copyWith(error: toApiException(err)); + } on Object { + // 실패 본문을 읽다 뜻밖에 실패했다 — 코드는 최종 폴백으로 정하고 다음 인터셉터로 넘긴다 + failure = err.copyWith( + error: ApiException.fromCode( + kFallbackCode, + statusCode: err.response?.statusCode, + ), + ); + } + handler.next(failure); } } + +/// 바이트 · 스트림 응답의 실패 본문을 읽는 최대 크기 — 표준 실패 본문은 1KB 안팎이다 +const int kMaxErrorBodyBytes = 64 * 1024; + +/// 바이트(`List`) · 스트림(`ResponseBody`) 실패 본문 → JSON 객체. 이미 풀린 본문 · 너무 큼 · JSON 이 아님은 null. +/// 스트림은 끝까지(한도 안) 읽어 연결을 놓아 준다 +Future?> decodeRawErrorBody(Object? data) async { + final List? bytes; + if (data is ResponseBody) { + bytes = await _readLimited(data.stream); + } else if (data is List) { + bytes = data.length <= kMaxErrorBodyBytes ? data : null; + } else { + return null; + } + if (bytes == null || bytes.isEmpty) return null; + try { + final Object? json = jsonDecode(utf8.decode(bytes, allowMalformed: true)); + return json is Map ? json : null; + } on FormatException { + return null; + } +} + +Future?> _readLimited(Stream> stream) async { + final BytesBuilder builder = BytesBuilder(copy: false); + try { + await for (final List chunk in stream) { + builder.add(chunk); + // 한도를 넘으면 그만 읽는다(구독을 끊어 연결을 놓는다) — 코드는 상태로 정한다 + if (builder.length > kMaxErrorBodyBytes) return null; + } + } on Object { + return null; + } + return builder.takeBytes(); +} diff --git a/flutter/lib/core/files/downloaded_files.dart b/flutter/lib/core/files/downloaded_files.dart new file mode 100644 index 0000000..1d7fa90 --- /dev/null +++ b/flutter/lib/core/files/downloaded_files.dart @@ -0,0 +1,215 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:arasystem_app/core/files/file_names.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; + +/// 받은 파일을 두는 폴더 이름 — 앱 임시 폴더(Android `cacheDir`) 아래. +/// **짝: `android/app/src/main/res/xml/downloaded_file_paths.xml` 의 `cache-path` 와 `MainActivity.kt` 의 `DOWNLOADS_DIR`** — 바꾸면 셋 다 +const String kDownloadedFilesFolder = 'downloads'; + +/// 받은 파일 하나의 자리 — 받는 동안 조각을 이어 쓰고, 다 받으면 닫는다 +abstract class DownloadTarget { + /// 기기 안의 경로 — 다른 앱으로 열 때 쓴다(로그 · 알림에 싣지 않는다) + String get path; + + /// 받은 조각을 이어 쓴다 + void add(List chunk); + + /// 다 받았다 — 쓴 것을 마저 내보내고 닫는다 + Future close(); + + /// 받다 말았다(실패 · 취소) — 닫고 지운다 + Future discard(); +} + +/// 받은 파일을 잠깐 두는 곳 — 앱이 다른 앱에 파일을 건네려고 둔다(모듈 공통). +/// +/// 정리하는 때: 앱을 띄울 때(main — 지난 실행이 남긴 것) · 메일을 보는 직원이 바뀔 때(로그아웃 · 세션 끝 · 다른 직원) · +/// 기기가 저장 공간이 모자랄 때(안드로이드가 임시 폴더를 비운다). 다른 앱이 아직 읽는 중일 수 있어 연 직후에는 지우지 않는다. +abstract class DownloadedFileStore { + /// 새 파일 자리 — 받을 때마다 새 폴더 하나(같은 이름이어도 덮지 않는다). 이름은 [safeFileName] 으로 정리한다 + Future create(String fileName); + + /// 받아 둔 파일이 아직 있는가 (기기가 임시 폴더를 비웠을 수 있다) + Future exists(String path); + + /// 모두 지운다 + Future purge(); +} + +/// 받은 파일을 두는 곳 — 앱 임시 폴더의 [kDownloadedFilesFolder] 아래 +final Provider downloadedFileStoreProvider = + Provider((Ref ref) => CacheDirFileStore()); + +/// 앱 임시 폴더(path_provider `getTemporaryDirectory` — Android `cacheDir`)를 쓰는 [DownloadedFileStore]. +/// +/// 지우기와 새 자리 만들기는 부른 차례대로 하나씩 한다 — 앱을 띄울 때의 정리가 막 만든 자리를 지우지 않게. +class CacheDirFileStore implements DownloadedFileStore { + CacheDirFileStore({ + Future Function() baseDirectory = getTemporaryDirectory, + Random? random, + }) : _baseDirectory = baseDirectory, + _random = random ?? Random.secure(); + + final Future Function() _baseDirectory; + final Random _random; + Future _queue = Future.value(); + + /// 받을 때마다 만드는 폴더 이름의 길이(16진수) + static const int _folderNameLength = 16; + + Future _serial(Future Function() task) { + final Future next = _queue.then((_) => task()); + _queue = next.then((_) {}, onError: (Object _) {}); + return next; + } + + /// 받은 파일 폴더 — 임시 폴더를 알 수 없으면(플러그인 · 기기 사정) 저장하지 못한 것으로 다룬다([FileSystemException]) + Future _root() async { + final Directory base; + try { + base = await _baseDirectory(); + } on FileSystemException { + rethrow; + } on Exception { + throw const FileSystemException('임시 폴더를 찾지 못했습니다'); + } + return Directory('${base.path}/$kDownloadedFilesFolder'); + } + + @override + Future create(String fileName) => _serial(() async { + final Directory root = await _root(); + final Directory folder = Directory('${root.path}/${_folderName()}'); + await folder.create(recursive: true); + final File file = File('${folder.path}/${safeFileName(fileName)}'); + return _FileTarget(file, file.openWrite()); + }); + + @override + Future exists(String path) => File(path).exists(); + + @override + Future purge() => _serial(() async { + try { + final Directory root = await _root(); + if (await root.exists()) await root.delete(recursive: true); + } on FileSystemException { + // 임시 폴더를 모르거나 다른 앱이 열고 있는 등 — 다음 정리 · 기기가 지운다 + } + }); + + String _folderName() => List.generate( + _folderNameLength, + (_) => _random.nextInt(16).toRadixString(16), + ).join(); +} + +class _FileTarget implements DownloadTarget { + _FileTarget(this._file, this._sink) { + // 쓰기가 실패하면(폴더가 지워짐 · 저장 공간) 그 실패는 close · discard 가 받는다 — 그 전에 잡히지 않은 오류로 알려지지 않게 + unawaited(_sink.done.then((_) {}, onError: (Object _) {})); + } + + final File _file; + final IOSink _sink; + bool _closed = false; + + @override + String get path => _file.path; + + @override + void add(List chunk) => _sink.add(chunk); + + /// 닫기가 남은 쓰기를 마저 내보낸다 — `flush` 는 부르지 않는다(쓰기가 이미 실패한 IOSink 의 flush 는 끝나지 않는다. + /// close 는 그 실패를 [FileSystemException] 으로 돌려준다) + @override + Future close() async { + if (_closed) return; + _closed = true; + await _sink.close(); + } + + @override + Future discard() async { + if (!_closed) { + _closed = true; + try { + await _sink.close(); + } on FileSystemException { + // 지우는 것이 목적이다 + } + } + try { + await _file.parent.delete(recursive: true); + } on FileSystemException { + // 이미 없거나 다른 앱이 열었다 — 다음 정리 · 기기가 지운다 + } + } +} + +/// 받은 파일을 다른 앱으로 연 결과 +enum FileOpenResult { + /// 다른 앱을 띄웠다(또는 고르는 창) + opened, + + /// 이 형식을 열 앱이 없다 + noApp, + + /// 파일이 없다(기기가 임시 폴더를 비웠다) + notFound, + + /// 그 밖의 실패(이 기기에서 열 수 없음 — iOS 는 아직 없다) + failed, +} + +/// 받은 파일을 다른 앱으로 연다 — 시험은 덮어쓴다 +abstract class FileOpener { + Future open({required String path, required String mimeType}); +} + +final Provider fileOpenerProvider = Provider( + (Ref ref) => const PlatformFileOpener(), +); + +/// 안드로이드 `ACTION_VIEW`(`MainActivity.kt` 의 파일 채널) — 앱의 FileProvider 가 [kDownloadedFilesFolder] 안의 파일만 건넨다. +/// +/// 패키지를 더하지 않고 앱 안에 둔 까닭: 파일 여는 플러그인은 앱에 저장소 · 사진 권한(Play 의 사진 · 동영상 권한 정책)과 +/// 기기 전체를 가리키는 FileProvider 경로를 더하고, 공유(share) 플러그인은 "열기"가 아니라 "보내기" 창이다. +/// 이 채널은 받은 파일 폴더 한 곳만 읽게 건넨다(읽기 권한 1회 — `FLAG_GRANT_READ_URI_PERMISSION`). iOS 는 붙일 때 만든다 +class PlatformFileOpener implements FileOpener { + const PlatformFileOpener(); + + /// 짝: `MainActivity.kt` 의 `FILES_CHANNEL` · `OPEN_METHOD` + static const MethodChannel channel = MethodChannel( + 'kr.it.arasystem.app/files', + ); + static const String openMethod = 'open'; + + @override + Future open({ + required String path, + required String mimeType, + }) async { + try { + final String? result = await channel.invokeMethod( + openMethod, + {'path': path, 'type': mimeType}, + ); + return switch (result) { + 'opened' => FileOpenResult.opened, + 'noApp' => FileOpenResult.noApp, + 'notFound' => FileOpenResult.notFound, + _ => FileOpenResult.failed, + }; + } on PlatformException { + return FileOpenResult.failed; + } on MissingPluginException { + return FileOpenResult.failed; + } + } +} diff --git a/flutter/lib/core/files/file_names.dart b/flutter/lib/core/files/file_names.dart new file mode 100644 index 0000000..946d263 --- /dev/null +++ b/flutter/lib/core/files/file_names.dart @@ -0,0 +1,71 @@ +/// 받은 파일을 기기에 둘 때의 이름 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈). +/// +/// 이름은 보낸 쪽이 정한 글이라 믿지 않는다 — 경로 문자 · 제어 문자 · 방향 제어 문자를 지우고 길이를 줄인다 +/// (서버의 첨부 이름 정리 `attachment.policy.ts` 와 같은 결 — 확장자는 남긴다). 저장 경로는 앱이 만든 폴더 안 한 칸뿐이다. +library; + +import 'dart:convert'; + +/// 이름이 비면 쓰는 이름 +const String kFallbackFileName = '첨부파일'; + +/// 저장 이름의 최대 UTF-8 바이트 — 안드로이드 파일 시스템(ext4 · f2fs)의 한 칸 한도(255)보다 넉넉히 작게 +const int kMaxFileNameBytes = 200; + +/// 남기는 확장자의 최대 길이 (점 뒤) +const int _maxExtensionLength = 16; + +/// 지우는 글자 — 제어 문자(C0 · DEL · C1) +bool _isControl(int rune) => rune <= 0x1F || (rune >= 0x7F && rune <= 0x9F); + +/// 지우는 글자 — 방향 제어 · 보이지 않는 글자(오른쪽에서 왼쪽 표시로 "…fdp.exe" 를 "…exe.pdf" 처럼 보이게 하는 속임 이름) +bool _isInvisibleFormat(int rune) => + rune == 0x061C || + (rune >= 0x200B && rune <= 0x200F) || + (rune >= 0x202A && rune <= 0x202E) || + (rune >= 0x2060 && rune <= 0x2069) || + rune == 0xFEFF; + +/// 밑줄로 바꾸는 글자 — 경로 구분 · 다른 시스템이 이름에 받지 않는 글자 +const String _replacedCharacters = r'/\:*?"<>|'; + +/// 기기에 둘 파일 이름 — 비거나 `.` · `..` 뿐이면 [fallback] +String safeFileName(String name, {String fallback = kFallbackFileName}) { + final StringBuffer cleaned = StringBuffer(); + for (final int rune in name.runes) { + if (_isControl(rune) || _isInvisibleFormat(rune)) continue; + final String char = String.fromCharCode(rune); + cleaned.write(_replacedCharacters.contains(char) ? '_' : char); + } + // 앞뒤 공백 · 점을 지운다 — 앞의 점은 숨은 파일, 끝의 점 · 공백은 다른 시스템이 받지 않는다 + String result = cleaned.toString().trim(); + result = result.replaceAll(RegExp(r'^[.\s]+|[.\s]+$'), ''); + if (result.isEmpty) return fallback; + return _fitBytes(result, kMaxFileNameBytes); +} + +/// UTF-8 [maxBytes] 안으로 줄인다 — 확장자(점 뒤 짧은 영문 · 숫자)는 남기고 앞부분을 글자 단위로 자른다 +String _fitBytes(String name, int maxBytes) { + if (utf8.encode(name).length <= maxBytes) return name; + final int dot = name.lastIndexOf('.'); + final String extension = + dot > 0 && + name.length - dot - 1 <= _maxExtensionLength && + RegExp(r'^[A-Za-z0-9]+$').hasMatch(name.substring(dot + 1)) + ? name.substring(dot) + : ''; + final String base = extension.isEmpty ? name : name.substring(0, dot); + final int budget = maxBytes - utf8.encode(extension).length; + final StringBuffer kept = StringBuffer(); + int used = 0; + for (final int rune in base.runes) { + final int size = utf8.encode(String.fromCharCode(rune)).length; + if (used + size > budget) break; + kept.writeCharCode(rune); + used += size; + } + final String trimmed = kept.toString().trimRight(); + return trimmed.isEmpty + ? '$kFallbackFileName$extension' + : '$trimmed$extension'; +} diff --git a/flutter/lib/core/files/file_types.dart b/flutter/lib/core/files/file_types.dart new file mode 100644 index 0000000..794a34e --- /dev/null +++ b/flutter/lib/core/files/file_types.dart @@ -0,0 +1,95 @@ +/// 받은 파일을 다른 앱으로 열 때의 형식(MIME)과 앱에서 열지 않는 형식 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈). +/// +/// 형식은 보낸 쪽이 정한 글이라 확장자보다 믿지 않는다 — 열 형식은 정리한 이름([safeFileName])의 확장자에서 먼저 정하고 +/// (`package:mime` 의 표), 확장자를 모를 때만 서버가 알려 준 형식(첨부의 `type`)을 쓴다. 안드로이드는 이 형식으로 열 앱을 고른다. +/// +/// 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음은 앱에서 열지 않는다 — 다른 앱(브라우저)이 그 파일의 스크립트를 돌리거나 +/// 기기에 설치 · 등록하는 창을 띄우지 않게. 확장자 · 확장자에서 나온 형식 · 서버 형식 가운데 하나라도 걸리면 받지도 않는다. +/// 고르는 창(`Intent.createChooser`)으로 여는 길도 쓰지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다 +library; + +import 'package:arasystem_app/core/files/file_names.dart'; +import 'package:mime/mime.dart'; + +/// 형식을 모를 때 — 안드로이드는 이 형식을 받는 앱이 드물다(열 앱이 없다고 알린다) +const String kGenericFileType = 'application/octet-stream'; + +/// 앱에서 열지 않는 확장자 (소문자 — 정리한 이름의 마지막 점 뒤) +const Set kBlockedOpenExtensions = { + // 웹 문서 — 브라우저가 열면 스크립트가 돈다 + 'html', 'htm', 'xhtml', 'xht', 'shtml', 'mht', 'mhtml', // + // SVG — 그림이지만 스크립트를 담는다 + 'svg', 'svgz', // + // 설치 파일 + 'apk', 'apks', 'xapk', // + // 인증서 · 키 묶음 — 열면 기기에 설치하는 창이 뜬다 + 'p12', 'pfx', 'cer', 'crt', 'der', 'pem', 'p7b', 'p7c', +}; + +/// 앱에서 열지 않는 형식 — 위 확장자에서 나오는 형식(`package:mime` 의 표 — 시험이 빠짐없이 맞춰 본다)과 +/// 서버 · 보낸 쪽이 같은 갈래에 흔히 쓰는 형식 +const Set kBlockedOpenTypes = { + // 웹 문서 · MHTML + 'text/html', 'application/xhtml+xml', 'multipart/related', // + 'application/x-mimearchive', // + // SVG + 'image/svg+xml', // + // 설치 파일 + 'application/vnd.android.package-archive', // + // 인증서 · 키 묶음 + 'application/x-pkcs12', 'application/pkcs12', 'application/pkix-cert', // + 'application/x-x509-ca-cert', 'application/x-x509-user-cert', // + 'application/x-x509-server-cert', 'application/x-pem-file', // + 'application/x-pkcs7-certificates', 'application/pkcs7-mime', // + 'application/x-pkcs7-mime', 'application/x-pkcs7-certreqresp', +}; + +/// 형식 글의 모양 — `종류/이름` (짝: `MainActivity.kt` 의 `MIME_PATTERN` — 앱도 넘겨받은 형식을 이 모양으로 다시 본다) +final RegExp _typePattern = RegExp( + r'^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$', +); + +/// 정리한 이름의 확장자 — 소문자, 없으면 빈 글 +String fileExtensionOf(String fileName) { + final String name = safeFileName(fileName); + final int dot = name.lastIndexOf('.'); + if (dot <= 0 || dot == name.length - 1) return ''; + return name.substring(dot + 1).toLowerCase(); +} + +/// 서버가 알려 준 형식을 비교할 꼴로 — 소문자, `;` 뒤의 매개변수(`charset=` 등)를 뗀다. 모양이 틀리면 null +String? normalizedFileType(String? type) { + if (type == null) return null; + final int semicolon = type.indexOf(';'); + final String value = (semicolon == -1 ? type : type.substring(0, semicolon)) + .trim() + .toLowerCase(); + return _typePattern.hasMatch(value) ? value : null; +} + +/// 확장자에서 나오는 형식 — 모르거나 아무 형식(octet-stream)이면 null +String? fileTypeOfExtension(String extension) { + if (extension.isEmpty) return null; + final String? type = lookupMimeType('file.$extension'); + return type == null || type == kGenericFileType ? null : type; +} + +/// 다른 앱으로 열 때 넘길 형식 — 확장자에서 먼저, 모르면 서버 형식(모양이 맞고 아무 형식이 아닐 때), 그것도 아니면 [kGenericFileType] +String fileOpenTypeOf(String fileName, String? serverType) { + final String? byExtension = fileTypeOfExtension(fileExtensionOf(fileName)); + if (byExtension != null) return byExtension; + final String? given = normalizedFileType(serverType); + return given ?? kGenericFileType; +} + +/// 앱에서 열지 않는 파일인가 — 확장자 · 확장자에서 나온 형식 · 서버 형식 가운데 하나라도 [kBlockedOpenExtensions] · [kBlockedOpenTypes] 에 걸리면 +bool isBlockedFileOpen(String fileName, String? serverType) { + final String extension = fileExtensionOf(fileName); + if (kBlockedOpenExtensions.contains(extension)) return true; + final String? byExtension = fileTypeOfExtension(extension); + if (byExtension != null && kBlockedOpenTypes.contains(byExtension)) { + return true; + } + final String? given = normalizedFileType(serverType); + return given != null && kBlockedOpenTypes.contains(given); +} diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index 0bd4d15..853fe7a 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -120,4 +120,26 @@ abstract final class AppSize { /// [메일 쓰기] 단추 높이 (Material 넓은 단추 그대로) — 목록 아래 여백이 이만큼 + 40 이다 static const double fabHeight = 56; + + // ── 앱 보드에서 정한 크기 (`App1Reader` — 2026-11-05 확정) ── + /// 읽기 화면의 위 막대 높이 — 뒤로 · 삭제 · 더 보기 + static const double readerBarHeight = 56; + + /// 읽기 화면의 아래 행동 막대 높이 — 답장 · 전체 답장 · 전달 + static const double readerActionBarHeight = 64; + + /// 첨부 줄의 최소 높이 + static const double attachmentRowMinHeight = 64; + + /// 첨부 줄의 파일 아이콘 칸 + static const double fileIconBox = 40; + + /// 첨부를 받는 동안의 진행 막대 두께 + static const double progressBarHeight = 4; + + /// 머리 펼침의 칸 이름 열 폭 ("보낸 사람" · "받는 사람" · "숨은참조") + static const double detailsLabelWidth = 64; + + /// 더 보기 메뉴의 폭 + static const double readerMenuWidth = 224; } diff --git a/flutter/lib/core/utils/file_size.dart b/flutter/lib/core/utils/file_size.dart new file mode 100644 index 0000000..1652191 --- /dev/null +++ b/flutter/lib/core/utils/file_size.dart @@ -0,0 +1,33 @@ +/// 파일 크기 표기 — 웹 `shared/utils/format.ts` 의 `formatFileSize` 와 같은 글(순수 함수). +/// +/// "248 KB" · "1.2 MB" · "38 MB" — 1KB 미만은 바이트 그대로, 그 위는 KB · MB · GB · TB. KB 는 정수, MB 이상은 10 미만일 때 소수 한 자리. +library; + +const int _bytesPerUnit = 1024; +const List _units = ['KB', 'MB', 'GB', 'TB']; +const int _decimalThreshold = 10; + +/// 크기 표기 — 음수면 빈 글 +String formatFileSize(int bytes) { + if (bytes < 0) return ''; + if (bytes < _bytesPerUnit) return '$bytes B'; + double value = bytes / _bytesPerUnit; + int unit = 0; + // 반올림 결과가 1024 가 되면("1024 KB") 다음 단위로 올린다 + while (value.round() >= _bytesPerUnit && unit < _units.length - 1) { + value /= _bytesPerUnit; + unit++; + } + final String shown = unit > 0 && value < _decimalThreshold + ? _oneDecimal(value) + : value.round().toString(); + return '$shown ${_units[unit]}'; +} + +/// 소수 한 자리 — 끝의 ".0" 은 뺀다(웹 `Math.round(value * 10) / 10` 이 내는 글과 같다) +String _oneDecimal(double value) { + final double rounded = (value * 10).round() / 10; + return rounded == rounded.roundToDouble() + ? rounded.round().toString() + : rounded.toStringAsFixed(1); +} diff --git a/flutter/lib/core/utils/korean_format.dart b/flutter/lib/core/utils/korean_format.dart index 1152fba..76493e0 100644 --- a/flutter/lib/core/utils/korean_format.dart +++ b/flutter/lib/core/utils/korean_format.dart @@ -37,6 +37,18 @@ String koreanMonthDay(DateTime local) => '${local.month}월 ${local.day}일'; String koreanNumericDate(DateTime local) => '${local.year}. ${local.month}. ${local.day}.'; +/// 요일 한 글자 — 월 · 화 · … · 일 (웹 `{ weekday: 'short' }`) +String koreanWeekday(DateTime local) => + const ['월', '화', '수', '목', '금', '토', '일'][local.weekday - 1]; + +/// 달 · 날 · 요일 · 시각 — "11월 7일 (토) 오전 9:00" (웹 `formatScheduleTime` — 예약 시각. 7일 안이라 연도가 없다) +String koreanScheduleTime(DateTime local) => + '${koreanMonthDay(local)} (${koreanWeekday(local)}) ${koreanTimeOfDay(local)}'; + +/// 연도까지 — "2026년 11월 4일 (수) 오후 4:20" (앱 보드 `App1Reader` 의 머리 펼침 "날짜") +String koreanFullDateTime(DateTime local) => + '${local.year}년 ${koreanScheduleTime(local)}'; + /// 같은 달력 날인가 (둘 다 같은 시간대로 바꾼 값) bool isSameCalendarDay(DateTime a, DateTime b) => a.year == b.year && a.month == b.month && a.day == b.day; diff --git a/flutter/lib/features/mail/data/mail_link_launcher.dart b/flutter/lib/features/mail/data/mail_link_launcher.dart new file mode 100644 index 0000000..644b1f0 --- /dev/null +++ b/flutter/lib/features/mail/data/mail_link_launcher.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 본문의 http · https 링크를 바깥 앱(브라우저 · 그 주소를 받는 앱)으로 여는 함수 — 시험은 덮어쓴다 +typedef MailLinkLauncher = Future Function(Uri uri); + +final Provider mailLinkLauncherProvider = + Provider((Ref ref) => openMailLinkExternally); + +/// 바깥 앱으로 연다 — 열었으면 true. +/// +/// `canLaunchUrl` 은 쓰지 않는다(debug 판 안드로이드가 묻는 주소를 로그에 남긴다 — flutter 스킬). 못 열면 플러그인이 던지는 +/// 예외의 문장에 주소째 들어 있다 — 잡아서 버린다(메일 속 주소에는 추적 번호 · 개인 정보가 들어 있을 수 있다) +Future openMailLinkExternally(Uri uri) async { + try { + return await launchUrl(uri, mode: LaunchMode.externalApplication); + } on Object { + return false; + } +} diff --git a/flutter/lib/features/mail/data/models/message_detail.dart b/flutter/lib/features/mail/data/models/message_detail.dart new file mode 100644 index 0000000..46f1e39 --- /dev/null +++ b/flutter/lib/features/mail/data/models/message_detail.dart @@ -0,0 +1,267 @@ +import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:flutter/foundation.dart'; + +/// 메일 한 통의 첨부 — 계약 2절 `MessageAttachment` +@immutable +class MessageAttachment { + const MessageAttachment({ + required this.id, + required this.name, + required this.type, + required this.size, + this.isInline = false, + }); + + final String id; + + /// 보낸 쪽이 붙인 이름 — 저장할 때는 다시 정리한다(`safeFileName`) + final String name; + + /// 서버가 알려 준 형식(`application/pdf` 등) — 다른 앱으로 열 때 쓴다 + final String type; + + /// 바이트 + final int size; + + /// 본문 안에 그림으로 들어갔다(cid → data:) — 첨부 목록에서 뺀다 + final bool isInline; + + /// 모양이 틀리면 null — `isInline` 이 없으면(옛 서버) 거짓으로 본다 + static MessageAttachment? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + final Object? name = json['name']; + final Object? type = json['type']; + final Object? size = json['size']; + final Object? isInline = json['isInline']; + if (id is! String || + id.isEmpty || + name is! String || + type is! String || + size is! num || + (isInline != null && isInline is! bool)) { + return null; + } + return MessageAttachment( + id: id, + name: name, + type: type, + size: size.toInt(), + isInline: isInline == true, + ); + } + + @override + bool operator ==(Object other) => + other is MessageAttachment && + other.id == id && + other.name == name && + other.type == type && + other.size == size && + other.isInline == isInline; + + @override + int get hashCode => Object.hash(id, name, type, size, isInline); +} + +/// 우리 서버가 넣은 대용량 첨부 하나 — 계약 4절 `MessageLargeFile` (첫 판은 받지 않고 이름 · 크기만 보인다) +@immutable +class MessageLargeFile { + const MessageLargeFile({ + required this.fileId, + required this.name, + required this.size, + }); + + final String fileId; + + /// 서버 기록의 이름 + final String name; + + /// 바이트 + final int size; + + static MessageLargeFile? tryParse(Object? json) { + if (json is! Map) return null; + final Object? fileId = json['fileId']; + final Object? name = json['name']; + final Object? size = json['size']; + if (fileId is! String || name is! String || size is! num) return null; + return MessageLargeFile(fileId: fileId, name: name, size: size.toInt()); + } + + @override + bool operator ==(Object other) => + other is MessageLargeFile && + other.fileId == fileId && + other.name == name && + other.size == size; + + @override + int get hashCode => Object.hash(fileId, name, size); +} + +/// 메일 한 통 — 계약 2절 `MessageDetail` (`GET /api/messages/{id}`) +/// +/// 목록 칸([summary])과 읽기 칸을 함께 둔다. 9단계 뒤에 더해진 칸(`imagesShown` · `isHtmlBodyOmitted` · `isBodyTruncated` · +/// `largeFiles` · 첨부의 `isInline`)은 없으면 거짓 · 빈 목록으로 읽는다(웹과 같다). 대용량 첨부는 하나가 틀려도 그것만 뺀다. +@immutable +class MessageDetail { + const MessageDetail({ + required this.summary, + this.cc = const [], + this.bcc = const [], + this.sentAt, + this.htmlBody, + this.textBody, + this.hasBlockedImages = false, + this.imagesShown = false, + this.isHtmlBodyOmitted = false, + this.isBodyTruncated = false, + this.attachments = const [], + this.largeFiles = const [], + }); + + /// 목록과 같은 칸 — 읽음 · 중요 · 메일함 · 붙잡힌 발송 + final MessageSummary summary; + final List cc; + + /// 숨은참조 — 내가 보낸 메일에만 온다 + final List bcc; + final DateTime? sentAt; + + /// 서버가 정화한 HTML — 앱은 한 번 더 정화한다(`mail_html.dart`) + final String? htmlBody; + final String? textBody; + + /// 외부 이미지를 지웠다 — "외부 이미지 차단" 줄 + final bool hasBlockedImages; + + /// 외부 이미지를 중계 주소로 바꿔 넣었다(`images=show` 또는 설정) — 앱이 그 그림을 받아 넣는다 + final bool imagesShown; + + /// 서식 본문이 한도를 넘어 비웠다 — 글자 본문으로 보인다 + final bool isHtmlBodyOmitted; + + /// 보여 줄 본문이 읽기 한도에서 잘렸다 + final bool isBodyTruncated; + final List attachments; + final List largeFiles; + + String get id => summary.id; + + /// 첨부 목록에 보일 것 — 본문 안에 그림으로 들어간 것은 뺀다(웹 `listedAttachments`) + List get listedAttachments => [ + for (final MessageAttachment attachment in attachments) + if (!attachment.isInline) attachment, + ]; + + /// 목록 칸만 바꾼다 — 읽음 · 중요 · 메일함 · 붙잡힌 발송(본문은 그대로) + MessageDetail withSummary(MessageSummary next) => MessageDetail( + summary: next, + cc: cc, + bcc: bcc, + sentAt: sentAt, + htmlBody: htmlBody, + textBody: textBody, + hasBlockedImages: hasBlockedImages, + imagesShown: imagesShown, + isHtmlBodyOmitted: isHtmlBodyOmitted, + isBodyTruncated: isBodyTruncated, + attachments: attachments, + largeFiles: largeFiles, + ); + + /// 본문 칸만 [other] 의 것으로 — [이미지 보기]로 다시 받은 본문(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹 `showImages`) + MessageDetail withBodyOf(MessageDetail other) => MessageDetail( + summary: summary, + cc: cc, + bcc: bcc, + sentAt: sentAt, + htmlBody: other.htmlBody, + textBody: other.textBody, + hasBlockedImages: other.hasBlockedImages, + imagesShown: other.imagesShown, + isHtmlBodyOmitted: other.isHtmlBodyOmitted, + isBodyTruncated: other.isBodyTruncated, + attachments: other.attachments, + largeFiles: other.largeFiles, + ); + + /// 모양이 틀리면 null — 저장소가 `SYS_001` 로 던진다 + static MessageDetail? tryParse(Object? json) { + if (json is! Map) return null; + final MessageSummary? summary = MessageSummary.tryParse(json); + if (summary == null) return null; + final List? cc = _addresses(json['cc']); + final List? bcc = _addresses(json['bcc']); + final Object? sentAt = json['sentAt']; + final Object? htmlBody = json['htmlBody']; + final Object? textBody = json['textBody']; + final List? attachments = _attachments( + json['attachments'], + ); + final bool? hasBlockedImages = _flag(json['hasBlockedImages']); + final bool? imagesShown = _flag(json['imagesShown']); + final bool? omitted = _flag(json['isHtmlBodyOmitted']); + final bool? truncated = _flag(json['isBodyTruncated']); + final DateTime? sent = sentAt is String ? DateTime.tryParse(sentAt) : null; + if (cc == null || + bcc == null || + (sentAt != null && sent == null) || + (htmlBody != null && htmlBody is! String) || + (textBody != null && textBody is! String) || + attachments == null || + hasBlockedImages == null || + imagesShown == null || + omitted == null || + truncated == null) { + return null; + } + return MessageDetail( + summary: summary, + cc: cc, + bcc: bcc, + sentAt: sent?.toUtc(), + htmlBody: htmlBody as String?, + textBody: textBody as String?, + hasBlockedImages: hasBlockedImages, + imagesShown: imagesShown, + isHtmlBodyOmitted: omitted, + isBodyTruncated: truncated, + attachments: attachments, + largeFiles: _largeFiles(json['largeFiles']), + ); + } + + /// 주소 칸 — 없으면 빈 목록, 모양이 틀리면 null + static List? _addresses(Object? value) => + value == null ? const [] : MailAddress.tryParseList(value); + + /// 참 · 거짓 칸 — 없으면 거짓, 모양이 틀리면 null + static bool? _flag(Object? value) { + if (value == null) return false; + return value is bool ? value : null; + } + + static List? _attachments(Object? value) { + if (value == null) return const []; + if (value is! List) return null; + final List items = []; + for (final Object? item in value) { + final MessageAttachment? attachment = MessageAttachment.tryParse(item); + if (attachment == null) return null; + items.add(attachment); + } + return List.unmodifiable(items); + } + + /// 대용량 첨부 — 11단계 칸이라 모양이 틀린 것은 빼고 읽는다(메일 전체를 열지 못하게 하지 않는다) + static List _largeFiles(Object? value) { + if (value is! List) return const []; + return List.unmodifiable([ + for (final Object? item in value) ?MessageLargeFile.tryParse(item), + ]); + } +} diff --git a/flutter/lib/features/mail/data/repositories/mail_repository.dart b/flutter/lib/features/mail/data/repositories/mail_repository.dart index d01397e..9e71c72 100644 --- a/flutter/lib/features/mail/data/repositories/mail_repository.dart +++ b/flutter/lib/features/mail/data/repositories/mail_repository.dart @@ -1,9 +1,15 @@ +import 'dart:async'; +import 'dart:typed_data'; + import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/core/api/request_flags.dart'; import 'package:arasystem_app/core/api/server_time.dart'; +import 'package:arasystem_app/core/files/downloaded_files.dart'; import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart'; import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; import 'package:dio/dio.dart'; @@ -20,8 +26,24 @@ abstract final class MailApiPaths { /// 새 메일 확인 — 불투명한 상태 글 static const String mailState = '/mail-state'; + + /// 첨부 받기 — 표준 래퍼 없는 파일 + static String attachment(String messageId, String attachmentId) => + '${message(messageId)}/attachments/${Uri.encodeComponent(attachmentId)}'; + + /// 붙잡아 둔 발송 취소(보내기 취소 · 예약 취소) — id 는 보낸 사본의 메일 id (9단계) + static String scheduledMessage(String id) => + '/scheduled-messages/${Uri.encodeComponent(id)}'; + + /// 외부 이미지 중계 — 서버가 본문에 넣은 주소(`/api/mail-images/<표>`)의 표 (9단계) + static String mailImage(String token) => + '/mail-images/${Uri.encodeComponent(token)}'; } +/// 메일 한 통을 외부 이미지가 보이는 본문으로 받는 쿼리 — `?images=show` (계약 2절 "외부 이미지 보기") +const String kMailImagesQuery = 'images'; +const String kMailImagesShow = 'show'; + /// 목록 한 쪽의 기본 수 — 계약 기본값(20)과 같다 const int kMailPageSize = 20; @@ -76,14 +98,35 @@ class MailRepository { return page.withServerTime(serverTimeOf(response)); } - /// 한 통 바꾸기 — 읽음 · 중요 · 옮기기 · 스팸 (읽기 화면이 쓴다) + /// 메일 한 통 — 조회만으로는 읽음 처리하지 않는다(계약 2절). [showImages] 면 외부 이미지를 보이는 본문(`images=show`). + /// 알림은 [silent] 로 끈다 — 처음 여는 메일은 화면이 받지 못함을 직접 그린다. 응답의 서버 시각(`Date`)도 함께 준다 + Future<({MessageDetail detail, DateTime? serverTime})> getMessage( + String id, { + bool showImages = false, + bool silent = true, + }) async { + final Response response = await _dio.get( + MailApiPaths.message(id), + queryParameters: showImages + ? {kMailImagesQuery: kMailImagesShow} + : null, + options: silent ? ApiOptions.silent() : null, + ); + final MessageDetail? detail = MessageDetail.tryParse(response.data); + if (detail == null) throw _malformed(response); + return (detail: detail, serverTime: serverTimeOf(response)); + } + + /// 한 통 바꾸기 — 읽음 · 중요 · 옮기기 · 스팸 (읽기 화면이 쓴다). [silent] 면 실패를 알리지 않는다(연 메일의 읽음 처리) Future updateMessage( String id, - MessageChanges changes, - ) async { + MessageChanges changes, { + bool silent = false, + }) async { final Response response = await _dio.patch( MailApiPaths.message(id), data: changes.toJson(), + options: silent ? ApiOptions.silent() : null, ); final MessageSummary? summary = MessageSummary.tryParse(response.data); if (summary == null) throw _malformed(response); @@ -127,6 +170,130 @@ class MailRepository { return state; } + /// 붙잡아 둔 발송을 취소한다(보내기 취소 · 예약 취소) — 메일은 보내지 않고 임시보관함으로 간다. 이어 쓸 초안 id(같은 메일 id)를 준다. + /// 이미 보냈으면 409 `MAIL_014`, 없으면 404 `RES_001` — 알림은 인터셉터가 띄운다 (계약 2절 "보내기 취소 · 예약 발송") + Future cancelPendingSend(String id) async { + final Response response = await _dio.delete( + MailApiPaths.scheduledMessage(id), + ); + final Object? data = response.data; + final Object? draftId = data is Map + ? data['draftId'] + : null; + if (draftId is! String || draftId.isEmpty) throw _malformed(response); + return draftId; + } + + /// 외부 이미지 하나 — 서버가 바깥에서 대신 받아 준 그림(바이트). 실패는 알리지 않는다(그 자리를 비운다). + /// [maxBytes] 를 넘게 오면 받기를 끊는다(취소 — `DioExceptionType.cancel`). [cancelToken] 을 그만두면(화면을 떠남 · 본문이 바뀜) 이 받기도 끊는다 — + /// 한도로 끊는 토큰은 그림마다 따로 둔다(한 그림의 한도가 다른 그림을 끊지 않게) + Future fetchMailImage( + String token, { + required int maxBytes, + CancelToken? cancelToken, + }) async { + final CancelToken cancel = CancelToken(); + if (cancelToken != null) { + if (cancelToken.isCancelled) cancel.cancel(); + unawaited( + cancelToken.whenCancel.then((DioException _) { + if (!cancel.isCancelled) cancel.cancel(); + }), + ); + } + final Response> response = await _dio.get>( + MailApiPaths.mailImage(token), + options: Options( + responseType: ResponseType.bytes, + extra: {kSilentExtraKey: true}, + ), + cancelToken: cancel, + onReceiveProgress: (int received, int total) { + if (received > maxBytes || total > maxBytes) cancel.cancel(); + }, + ); + final List? data = response.data; + if (data == null) { + throw apiFailure(response.requestOptions, kFallbackCode); + } + return data is Uint8List ? data : Uint8List.fromList(data); + } + + /// 첨부 하나를 [target] 에 받는다 — 조각마다 [onProgress](받은 바이트 · 전체 바이트 — 모르면 null). + /// + /// 실패는 늘 `DioException` 이다 — 서버가 거절했으면 인터셉터가 코드로 알렸고(실패 본문도 코드로 읽는다), + /// 받는 도중 끊기면 `SYS_001`(이때는 응답이 이미 지나가 인터셉터가 알리지 않는다 — [DownloadInterrupted]). + /// [cancelToken] 으로 그만두면 `DioExceptionType.cancel` 이다. 쓰다 만 [target] 은 부르는 쪽이 지운다 + Future downloadAttachment({ + required String messageId, + required String attachmentId, + required DownloadTarget target, + void Function(int received, int? total)? onProgress, + CancelToken? cancelToken, + }) async { + final Response response = await _dio.get( + MailApiPaths.attachment(messageId, attachmentId), + options: Options(responseType: ResponseType.stream), + cancelToken: cancelToken, + ); + final ResponseBody? body = response.data; + if (body == null) { + throw apiFailure(response.requestOptions, kFallbackCode); + } + final int? length = int.tryParse( + response.headers.value(Headers.contentLengthHeader) ?? '', + ); + final int? total = length != null && length > 0 ? length : null; + final RequestOptions request = response.requestOptions; + final Completer done = Completer(); + int received = 0; + final StreamSubscription subscription = body.stream.listen( + (Uint8List chunk) { + target.add(chunk); + received += chunk.length; + onProgress?.call(received, total); + }, + onError: (Object error, StackTrace stackTrace) { + if (done.isCompleted) return; + // 받는 도중의 실패(끊김 · 조각 사이 받기 한도 `receiveTimeout`)는 인터셉터를 지나지 않는다 — 그만둔 것만 그대로 둔다 + final bool cancelled = + error is DioException && error.type == DioExceptionType.cancel; + done.completeError( + cancelled ? error : DownloadInterrupted(request), + stackTrace, + ); + }, + onDone: () { + if (!done.isCompleted) done.complete(); + }, + cancelOnError: true, + ); + // 그만두면 다음 조각을 기다리지 않고 바로 끊는다 + unawaited( + cancelToken?.whenCancel.then((DioException _) async { + if (done.isCompleted) return; + done.completeError( + DioException.requestCancelled( + requestOptions: request, + reason: cancelToken.cancelError?.error, + ), + ); + await subscription.cancel(); + }), + ); + await done.future; + } + DioException _malformed(Response response) => apiFailure(response.requestOptions, kFallbackCode); } + +/// 첨부를 받는 도중 끊겼다 — 응답은 이미 인터셉터를 지나서 아무도 알리지 않았다(부르는 쪽이 `SYS_001` 로 알린다) +class DownloadInterrupted extends DioException { + DownloadInterrupted(RequestOptions requestOptions) + : super( + requestOptions: requestOptions, + type: DioExceptionType.connectionError, + error: ApiException.fromCode(kFallbackCode), + ); +} diff --git a/flutter/lib/features/mail/domain/compose_request.dart b/flutter/lib/features/mail/domain/compose_request.dart new file mode 100644 index 0000000..20987dd --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_request.dart @@ -0,0 +1,200 @@ +/// 쓰기 화면에 넘길 값 — 읽기 화면의 [답장] · [전체 답장] · [전달] · 본문의 mailto 링크 (순수 함수). +/// +/// 쓰기 화면(`App1Compose`)은 다음 차례다. 지금은 가는 길(주소 · 쿼리)과 넘길 값만 정해 둔다 — +/// 주소는 웹과 같은 쿼리(`/mail/compose?replyTo=` · `&all=1` · `?forward=` · `?draft=`)이고, +/// mailto 의 받는 사람 · 제목 · 본문은 주소에 싣지 않고 라우트의 `extra`([ComposePrefill])로 넘긴다 +/// (웹 `compose-prefill.ts` 처럼 — 주소가 기록 · 로그에 남지 않게). +library; + +import 'package:flutter/foundation.dart'; + +/// 쓰기 화면 주소의 쿼리 이름 — 웹 `/mail/compose` 와 같다 +abstract final class ComposeQuery { + static const String draft = 'draft'; + static const String replyTo = 'replyTo'; + + /// [replyTo] 와 함께 `1` 이면 전체 답장 + static const String replyAll = 'all'; + static const String replyAllValue = '1'; + static const String forward = 'forward'; +} + +/// 쓰기 화면을 여는 까닭 +sealed class ComposeIntent { + const ComposeIntent(); +} + +/// 새 메일 (mailto 로 채운 것 포함 — 채울 값은 [ComposePrefill]) +final class NewComposeIntent extends ComposeIntent { + const NewComposeIntent(); +} + +/// 임시 저장본 이어 쓰기 +final class DraftComposeIntent extends ComposeIntent { + const DraftComposeIntent(this.draftId); + + final String draftId; +} + +/// 답장 · 전체 답장 +final class ReplyComposeIntent extends ComposeIntent { + const ReplyComposeIntent(this.messageId, {required this.all}); + + final String messageId; + final bool all; +} + +/// 전달 +final class ForwardComposeIntent extends ComposeIntent { + const ForwardComposeIntent(this.messageId); + + final String messageId; +} + +/// 쓰기 화면 주소의 쿼리 → 여는 까닭 (모르는 값은 새 메일 — 웹과 같은 차례: 이어 쓰기 → 답장 → 전달) +ComposeIntent composeIntentOf(Map query) { + final String? draft = query[ComposeQuery.draft]; + if (draft != null && draft.isNotEmpty) return DraftComposeIntent(draft); + final String? replyTo = query[ComposeQuery.replyTo]; + if (replyTo != null && replyTo.isNotEmpty) { + return ReplyComposeIntent( + replyTo, + all: query[ComposeQuery.replyAll] == ComposeQuery.replyAllValue, + ); + } + final String? forward = query[ComposeQuery.forward]; + if (forward != null && forward.isNotEmpty) { + return ForwardComposeIntent(forward); + } + return const NewComposeIntent(); +} + +/// mailto 링크로 채울 값 — 쓰기 화면이 받는 사람 · 제목 · 본문 칸을 채운다 +@immutable +class ComposePrefill { + const ComposePrefill({ + this.to = const [], + this.cc = const [], + this.bcc = const [], + this.subject = '', + this.body = '', + }); + + final List to; + final List cc; + final List bcc; + final String subject; + final String body; + + @override + bool operator ==(Object other) => + other is ComposePrefill && + listEquals(other.to, to) && + listEquals(other.cc, cc) && + listEquals(other.bcc, bcc) && + other.subject == subject && + other.body == body; + + @override + int get hashCode => Object.hash( + Object.hashAll(to), + Object.hashAll(cc), + Object.hashAll(bcc), + subject, + body, + ); +} + +/// 받는 사람 합계 한도 — 발송 한도와 같다(계약 2절 — 100명) +const int kComposeMaxRecipients = 100; + +/// 제목 한도 — 발송 한도와 같다(255자) +const int kComposeMaxSubject = 255; + +/// mailto 로 채우는 본문 한도 — 링크에 실린 긴 글로 화면이 무거워지지 않게(발송 한도 100,000자보다 짧게 둔다) +const int kComposeMaxPrefillBody = 10000; + +/// 주소 한 칸의 최대 길이 (RFC 5321) +const int _maxAddressLength = 254; + +/// mailto 링크 → 채울 값 — mailto 가 아니면 null. +/// +/// `mailto:a@x,b@x?cc=…&bcc=…&subject=…&body=…`(RFC 6068). 받는 사람은 `@` 가 있고 공백 · 제어 문자가 없는 것만, +/// 겹치면 하나, 합계 [kComposeMaxRecipients] 까지. 제목 · 본문은 한도에서 자른다. 받는 사람이 없어도 된다(빈 쓰기 화면). +ComposePrefill? composePrefillOfMailto(Uri uri) { + if (uri.scheme.toLowerCase() != 'mailto') return null; + final Map query = {}; + final String rawQuery = uri.hasQuery ? uri.query : ''; + if (rawQuery.isNotEmpty) { + for (final String part in rawQuery.split('&')) { + final int equals = part.indexOf('='); + final String key = _decode( + equals == -1 ? part : part.substring(0, equals), + ).toLowerCase(); + final String value = equals == -1 + ? '' + : _decode(part.substring(equals + 1)); + // 같은 이름이 여럿이면 앞의 것 — 받는 사람 칸은 이어 붙인다 + if (key == 'to' || key == 'cc' || key == 'bcc') { + query[key] = query[key] == null ? value : '${query[key]},$value'; + } else { + query.putIfAbsent(key, () => value); + } + } + } + final Set seen = {}; + List addresses(String raw) { + final List out = []; + for (final String piece in raw.split(',')) { + final String address = piece.trim(); + if (!_isPlausibleAddress(address)) continue; + if (seen.length >= kComposeMaxRecipients) break; + if (!seen.add(address.toLowerCase())) continue; + out.add(address); + } + return List.unmodifiable(out); + } + + return ComposePrefill( + to: addresses([_decode(uri.path), query['to'] ?? ''].join(',')), + cc: addresses(query['cc'] ?? ''), + bcc: addresses(query['bcc'] ?? ''), + subject: _limit(_singleLine(query['subject'] ?? ''), kComposeMaxSubject), + body: _limit(query['body'] ?? '', kComposeMaxPrefillBody), + ); +} + +/// 퍼센트 인코딩을 푼다 — 깨진 인코딩은 그대로 둔다 +String _decode(String value) { + try { + return Uri.decodeComponent(value); + } on ArgumentError { + return value; + } on FormatException { + return value; + } +} + +bool _isPlausibleAddress(String address) { + if (address.isEmpty || address.length > _maxAddressLength) return false; + final int at = address.indexOf('@'); + if (at <= 0 || at != address.lastIndexOf('@') || at == address.length - 1) { + return false; + } + return !address.runes.any( + (int rune) => rune <= 0x20 || (rune >= 0x7F && rune <= 0x9F), + ); +} + +/// 제목은 한 줄 — 줄바꿈 · 제어 문자는 띄어쓰기로 +String _singleLine(String value) => + value.replaceAll(RegExp(r'[\u0000-\u001F\u007F]+'), ' ').trim(); + +/// [max] 자(UTF-16)에서 자른다 — 이모지 같은 짝 글자(서로게이트)를 가르지 않는다 +String _limit(String value, int max) { + if (value.length <= max) return value; + final int end = _isHighSurrogate(value.codeUnitAt(max - 1)) ? max - 1 : max; + return value.substring(0, end); +} + +bool _isHighSurrogate(int unit) => unit >= 0xD800 && unit <= 0xDBFF; diff --git a/flutter/lib/features/mail/domain/mail_body_notices.dart b/flutter/lib/features/mail/domain/mail_body_notices.dart new file mode 100644 index 0000000..6b9cd32 --- /dev/null +++ b/flutter/lib/features/mail/domain/mail_body_notices.dart @@ -0,0 +1,32 @@ +/// 읽기 화면의 본문 안내 — 서버가 본문을 다 주지 못했을 때 (계약 2절 "읽기 한도" · 웹 `shared/utils/mail-body-notices.ts` 와 같다). +/// +/// 차례는 생략(서식 없이 · 표시할 수 없음) → 잘림이다. 보여 줄 본문이 없으면("표시할 수 없음") 잘림 안내는 맞지 않아 뺀다. +/// 글자 본문은 공백뿐이면 없는 것으로 본다. +library; + +/// 본문 안내의 종류 +enum MailBodyNotice { + /// 서식 본문을 한도 때문에 비웠다 — 글자 본문을 보인다 + htmlOmitted, + + /// 서식 본문을 비웠고 글자 본문도 없다 + unavailable, + + /// 보여 주는 본문이 읽기 한도에서 잘렸다 + truncated, +} + +/// 본문 위에 둘 안내 — 없으면 빈 목록 +List mailBodyNoticesOf({ + required String? textBody, + required bool isHtmlBodyOmitted, + required bool isBodyTruncated, +}) { + final bool hasText = (textBody ?? '').trim().isNotEmpty; + return [ + if (isHtmlBodyOmitted) + hasText ? MailBodyNotice.htmlOmitted : MailBodyNotice.unavailable, + if (isBodyTruncated && !(isHtmlBodyOmitted && !hasText)) + MailBodyNotice.truncated, + ]; +} diff --git a/flutter/lib/features/mail/domain/mail_html.dart b/flutter/lib/features/mail/domain/mail_html.dart new file mode 100644 index 0000000..0812d29 --- /dev/null +++ b/flutter/lib/features/mail/domain/mail_html.dart @@ -0,0 +1,503 @@ +/// 받은 메일 본문 → WebView 에 넣을 문서 — 보안 핵심 (순수 함수 · 웹 `shared/utils/mail-html.ts` 의 결을 따른다). +/// +/// 방어는 세 겹이다. 어느 한 겹이 뚫려도 나머지가 막도록 셋 다 유지한다. +/// 1) 서버가 정화한 htmlBody (계약 2절 "htmlBody 정화 규칙") +/// 2) 여기서 한 번 더 정화 — HTML5 파서(`package:html`)로 읽어 **허용 목록의 태그 · 속성만** 다시 쓴다. +/// 서버 정화에 빈틈이 있어도 스크립트 · 폼 · 외부 자원 · 이벤트 처리기를 걷어 낸다. 결과 HTML 은 파서의 직렬화가 아니라 +/// 여기서 직접 쓴다(속성 값의 `<` `>` `"` `&` 를 늘 이스케이프 — 아래 그림 주소 바꿔 끼우기가 그 모양에 기댄다). +/// 3) WebView — 스크립트를 끄고(`JavaScriptMode.disabled`), 이 문서 안의 CSP 로 모든 바깥 요청 · 아래 틀을 막고(그림은 `data:` 만), +/// 주 틀의 탐색은 모두 막는다(링크는 앱이 바깥 브라우저 · 쓰기 화면으로 — `mail_links.dart`). 아래 틀(iframe)의 탐색은 안드로이드가 +/// 판정에 넘기지 않으므로 정화(iframe 을 내용째 버림)와 CSP(frame-src)만 막는다. +/// +/// 외부 이미지를 보일 때(서버가 중계 주소 `/api/mail-images/<표>` 로 바꿔 넣은 본문 — imagesShown)도 WebView 는 바깥에 나가지 않는다 — +/// 앱이 Dio(Bearer)로 그림을 받아 `data:` 로 바꿔 넣는다([resolveProxiedImages]). 토큰이 WebView 에 가지 않는다. +library; + +import 'package:arasystem_app/features/mail/domain/mail_images.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html_parser; + +/// WebView 문서 안의 CSP — 스크립트 · 글꼴 · 네트워크 요청을 모두 막고 `data:` 그림과 인라인 스타일만 연다. +/// 웹(`MAIL_FRAME_CSP`)과 같고, `` · 폼 보내기도 막는다(둘은 `default-src` 를 따르지 않는다). +/// 외부 이미지를 보일 때도 같다 — 그림은 앱이 받아 `data:` 로 넣는다(웹처럼 중계 경로를 열지 않는다) +const String kMailDocumentCsp = + "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"; + +/// 서버의 중계 주소 — `/api/mail-images/<표>`. 표는 서버가 암호화한 불투명 글(base64url)이라 한 칸뿐이다 +/// (짝: backend `modules/mail/images/mail-image-token.ts` 의 `TOKEN_PATTERN` · 웹 `PROXIED_IMAGE_PATTERN`) +final RegExp kProxiedImagePattern = RegExp( + r'^/api/mail-images/([A-Za-z0-9_-]+)$', +); + +/// 한 통에서 받아 넣을 중계 그림의 최대 수 — 서버가 한 통에 바꿔 넣는 수와 같다(계약 2절 "외부 이미지 보기" — 100개) +const int kMaxProxiedImages = 100; + +/// 남길 링크 — 계약과 같다(http · https · mailto). 상대 경로 · javascript: 등은 지운다 +final RegExp _safeLink = RegExp(r'^(?:https?|mailto):', caseSensitive: false); + +/// 남길 `data:` 그림 — 서버가 본문에 넣어 주는 그림 형식(PNG · JPEG · GIF · WebP)만. SVG 는 받지 않는다 +final RegExp _dataImage = RegExp( + r'^data:image/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+/=\s]*$', + caseSensitive: false, +); + +/// 남기는 태그 — 메일 본문에 흔한 글 · 표 · 목록 · 인용 태그(DOMPurify 의 HTML 목록에서 폼 · 미디어 · 틀을 뺐다) +const Set _allowedTags = { + 'a', 'abbr', 'acronym', 'address', 'article', 'aside', 'b', 'bdi', 'bdo', // + 'big', 'blockquote', 'br', 'caption', 'center', 'cite', 'code', 'col', // + 'colgroup', 'data', 'dd', 'del', 'details', 'dfn', 'dir', 'div', 'dl', // + 'dt', 'em', 'figcaption', 'figure', 'font', 'footer', 'h1', 'h2', 'h3', // + 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'i', 'img', 'ins', 'kbd', // + 'li', 'main', 'mark', 'menu', 'nav', 'nobr', 'ol', 'p', 'pre', 'q', 'rp', // + 'rt', 'ruby', 's', 'samp', 'section', 'small', 'span', 'strike', 'strong', // + 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', // + 'time', 'tr', 'tt', 'u', 'ul', 'var', 'wbr', +}; + +/// 내용째 버리는 태그 — 스크립트 · 스타일 · 틀 · 미디어 · SVG · 수식 · 폼의 숨은 값(웹 `FORBID_CONTENTS` 와 서버 제거 목록) +const Set _droppedWithContent = { + 'script', 'style', 'template', 'iframe', 'frame', 'frameset', 'object', // + 'embed', 'applet', 'param', 'noscript', 'noembed', 'noframes', 'svg', // + 'math', 'datalist', 'title', 'head', 'xmp', 'plaintext', 'audio', // + 'video', 'source', 'track', 'canvas', 'map', 'area', 'link', 'meta', // + 'base', 'input', 'select', 'dialog', 'slot', +}; + +/// 내용이 없는 태그 — 닫는 태그를 쓰지 않는다 +const Set _voidTags = {'br', 'col', 'hr', 'img', 'wbr'}; + +/// 어느 태그에나 남기는 속성 — 모양(style) · 표 · 글꼴의 옛 속성. 이벤트 처리기 · data-* · id · target · 외부 자원 속성은 없다 +const Set _globalAttributes = { + 'style', 'class', 'title', 'dir', 'lang', 'align', 'valign', 'width', // + 'height', 'bgcolor', 'color', 'border', 'cellpadding', 'cellspacing', // + 'colspan', 'rowspan', 'span', 'face', 'size', 'nowrap', 'frame', 'rules', // + 'summary', 'abbr', 'headers', 'scope', 'hspace', 'vspace', 'clear', // + 'noshade', 'datetime', +}; + +/// 태그마다 더 남기는 속성 +const Map> _tagAttributes = >{ + 'a': {'href'}, + 'img': {'src', 'alt'}, + 'ol': {'start', 'type', 'reversed'}, + 'ul': {'type'}, + 'li': {'type', 'value'}, + 'details': {'open'}, +}; + +/// 속성 값의 최대 길이 — 넘으면 그 속성을 뺀다(`style` 은 표 꾸밈이 길 수 있어 넉넉히 · 주소는 따로) +const int _maxAttributeLength = 1000; +const int _maxStyleLength = 4000; + +/// 링크 주소의 최대 길이 — 중계 주소 한도(2,048자)와 같다 +const int _maxHrefLength = 2048; + +/// 앱이 정화하는 서식 본문의 최대 길이(글자) — 서버의 읽기 한도와 같다(계약 2절 "읽기 한도" — 2,000,000) +const int kMaxSanitizedHtmlLength = 2000000; + +/// 앱이 정화하는 서식 본문의 최대 중첩 비용 — 태그마다 그때의 깊이를 더한 값([htmlNestingCost]). +/// +/// HTML5 파서(`package:html`)는 태그마다 열린 요소를 훑어(범위 검사) 깊이에 비례해 일하므로 깊은 본문은 제곱으로 느려진다 +/// (2026-11-06 이 PC 의 시험 판: 깊이 200 · 2MB 2.2초, 깊이 600 · 2MB 6.4초, div 5,000단 55KB 3.8초 — 서버는 2만 단까지 넘긴다). +/// 이 값이면 정화가 2MB 표 한 통(1.5초)보다 2초 남짓 더 걸리는 데서 멈춘다. 넘으면 서식 없이 글자 본문을 보인다 +/// (서버의 `isHtmlBodyOmitted` 와 같은 안내 — 50단 안팎의 보통 답장 스레드는 한참 아래다) +const int kMaxHtmlNestingCost = 8000000; + +/// 정화한 본문 조각 — `` 안에 넣는다 +class SanitizedMailBody { + const SanitizedMailBody({ + required this.fragment, + required this.proxiedTokens, + this.htmlOmitted = false, + }); + + /// 정화한 HTML 조각 — 중계 그림은 ` proxiedTokens; + + /// 서식 본문이 앱의 정화 한도(길이 · 중첩)를 넘어 글자 본문을 보인다 — 화면은 서버의 생략과 같은 안내를 보인다 + final bool htmlOmitted; +} + +/// 보여 줄 서식 본문 — null 이거나 공백뿐이면 없다(null). 그때는 textBody 를 보인다(웹 `displayedHtmlBody` · 서버와 같은 규칙) +String? displayedHtmlBody(String? htmlBody) => + htmlBody != null && htmlBody.trim().isNotEmpty ? htmlBody : null; + +/// 본문 조각을 만든다 — HTML 이 있으면 정화하고, 없으면(공백뿐 포함) 텍스트를 이스케이프한다. +/// [imagesShown] 이면 서버의 중계 주소 그림을 남긴다(그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다). +/// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다 +SanitizedMailBody mailBodyFragment({ + required String? htmlBody, + required String? textBody, + required bool imagesShown, +}) { + final String? html = displayedHtmlBody(htmlBody); + if (html == null) return _textFragment(textBody); + final String trimmed = html.trim(); + if (trimmed.length > kMaxSanitizedHtmlLength || + htmlNestingCost(trimmed, limit: kMaxHtmlNestingCost) > + kMaxHtmlNestingCost) { + return _textFragment(textBody, htmlOmitted: true); + } + return sanitizeMailHtml(trimmed, imagesShown: imagesShown); +} + +SanitizedMailBody _textFragment(String? textBody, {bool htmlOmitted = false}) => + SanitizedMailBody( + fragment: + '
${escapeHtmlText(textBody ?? '')}
', + proxiedTokens: const [], + htmlOmitted: htmlOmitted, + ); + +/// 정화 전 비용 — 태그(여는 · 닫는)마다 그때의 깊이를 더한다. 글자를 한 번만 훑고 [limit] 을 넘으면 그 자리에서 멈춘다. +/// +/// 파서보다 **깊게** 센다(넉넉한 쪽 — 덜 세면 깊은 본문이 정화를 오래 붙잡는다): +/// - 암묵 닫힘(`

` 뒤의 `

` 등)을 보지 않고, 원시 글(`