diff --git a/lib/core/ftp.dart b/lib/core/ftp.dart index 10f4c4c..9a4e983 100644 --- a/lib/core/ftp.dart +++ b/lib/core/ftp.dart @@ -1,691 +1,5 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:crypto/crypto.dart' show sha256; - -import '../l10n/tr.dart'; -import 'dns_resolver.dart'; -import 'remote_fs.dart'; -import 'secret_gate.dart'; -import 'webdav.dart' show DavException, DavItem, DavLockedException; -import 'remote_idle.dart'; - -/// FTP · FTPS 서버 설정 (관리자 지시 10/17, l). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). -/// [security]: 'explicit' (FTPS - AUTH TLS, 처음 값) · 'implicit' (FTPS 990) · 'none' (평문 FTP - 연결 · 설정에서 "암호화 안 됨" 경고). -/// 자체 서명 인증서는 처음 연결 때 사용자가 믿은 지문 ([certFingerprint], SHA-256) 만 받는다 (TOFU - SFTP 호스트 키와 같은 판단). -class FtpServer { - const FtpServer({ - required this.id, - required this.name, - required this.host, - this.port = 21, - this.user = '', - this.root = '', - this.password = '', - this.security = 'explicit', - this.certFingerprint = '', - }); - - final String id; - final String name; - final String host; - final int port; - final String user; - - /// 서버의 시작 폴더 (비우면 로그인한 곳) - final String root; - - /// 안전 저장소에서 채움 (설정 파일에는 없음) - final String password; - final String security; - - /// 믿은 자체 서명 인증서의 지문 "SHA256:xx:xx…" (비밀 아님) - final String certFingerprint; - - bool get tls => security != 'none'; - String get label => name.trim().isNotEmpty ? name.trim() : (host.trim().isEmpty ? 'FTP' : host.trim()); - - FtpServer copyWith({String? name, String? host, int? port, String? user, String? root, String? password, String? security, String? certFingerprint}) => - FtpServer( - id: id, - name: name ?? this.name, - host: host ?? this.host, - port: port ?? this.port, - user: user ?? this.user, - root: root ?? this.root, - password: password ?? this.password, - security: security ?? this.security, - certFingerprint: certFingerprint ?? this.certFingerprint, - ); - - /// 비밀 값은 넣지 않는다 (안전 저장소). 저장해 두었는지만 - Map toJson() => { - 'id': id, - 'name': name, - 'host': host, - 'port': port, - 'user': user, - 'root': root, - 'hasPassword': password.isNotEmpty, - 'security': security, - 'certFingerprint': certFingerprint, - }; - - factory FtpServer.fromJson(Map j) => FtpServer( - id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}', - name: j['name'] as String? ?? '', - host: j['host'] as String? ?? '', - port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => j['security'] == 'implicit' ? 990 : 21 }, - user: j['user'] as String? ?? '', - root: j['root'] as String? ?? '', - // 모르는 값 · 없는 값은 FTPS (지침 3 - 덜 위험한 쪽, 관리자 지시 "처음 값은 FTPS 우선") - security: const ['explicit', 'implicit', 'none'].contains(j['security']) ? j['security'] as String : 'explicit', - certFingerprint: j['certFingerprint'] as String? ?? '', - ); -} - -/// FTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데) -enum FtpFailure { unreachable, auth, noSecret, certUnknown, certChanged, noTls, permission, notFound, other } - -class FtpException extends DavException { - final FtpFailure kind; - const FtpException(super.message, this.kind); -} - -/// 처음 보는 (또는 바뀐) 자체 서명 인증서: 화면이 지문을 보이고 믿을지 묻는다. 믿으면 [FtpServer.certFingerprint] 에 저장 -class FtpCertException extends FtpException { - final String fingerprint; - final String subject; - - /// 바뀐 경우 예전에 믿은 지문 - final String? previous; - const FtpCertException(super.message, super.kind, {required this.fingerprint, required this.subject, this.previous}); - bool get changed => previous != null; -} - -/// 인증서 지문 "SHA256:AB:CD:…" -String certSha256(X509Certificate c) => - 'SHA256:${sha256.convert(c.der).bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(':')}'; - -/// 이름 → 연결할 주소 (DNS 강제면 정한 DNS - main 에서 바꾼다). 처음 값: 기기 DNS (주소를 그대로 넘김) -typedef FtpResolve = Future Function(String host); - -/// FTP 명령 채널 하나 (로그인까지). 응답은 줄 단위 ("123-…" 여러 줄 → "123 …" 끝) -class _Session { - final FtpServer server; - final Duration timeout; - final InternetAddress addr; - RawSocket _raw; - StreamSubscription? _sub; - final _buf = []; - final _lines = []; - Completer? _wake; - bool _closed = false; - bool secure = false; - bool mlsd = false; - bool _protP = false; - - /// 로그인 뒤 서버의 지금 폴더 (시작 폴더가 비었을 때 맨 위) - String home = '/'; - - _Session._(this.server, this.timeout, this.addr, this._raw); - - static Future<_Session> open(FtpServer s, Duration timeout, FtpResolve resolve) async { - final addr = await resolve(s.host.trim()) ?? (await InternetAddress.lookup(s.host.trim())).first; - final raw = await RawSocket.connect(addr, s.port, timeout: timeout); - final x = _Session._(s, timeout, addr, raw); - if (s.security == 'implicit') { - x._raw = await x._secure(raw, null); - x.secure = true; - } - x._listen(); - return x; - } - - void _listen() { - _sub = _raw.listen((ev) { - if (ev == RawSocketEvent.read) { - final d = _raw.read(); - if (d != null) _feed(d); - } else if (ev == RawSocketEvent.readClosed || ev == RawSocketEvent.closed) { - _closed = true; - _poke(); - } - }, onError: (_) { - _closed = true; - _poke(); - }); - } - - void _feed(Uint8List d) { - _buf.addAll(d); - while (true) { - final i = _buf.indexOf(10); - if (i < 0) break; - var line = utf8.decode(_buf.sublist(0, i), allowMalformed: true); - if (line.endsWith('\r')) line = line.substring(0, line.length - 1); - _buf.removeRange(0, i + 1); - debugLog?.call('< $line'); - _lines.add(line); - } - _poke(); - } - - void _poke() { - final w = _wake; - _wake = null; - w?.complete(); - } - - Future _line() async { - final end = DateTime.now().add(timeout); - while (_lines.isEmpty) { - if (_closed) throw FtpException(trf('서버 ({0}) 와의 연결이 끊겼습니다', [server.host]), FtpFailure.unreachable); - final left = end.difference(DateTime.now()); - if (left <= Duration.zero) throw TimeoutException(tr('답이 없습니다')); - _wake = Completer(); - await _wake!.future.timeout(left, onTimeout: () {}); - } - return _lines.removeAt(0); - } - - /// 응답 하나 (여러 줄이면 합침): (코드, 글) - Future<(int, String)> reply() async { - final first = await _line(); - final code = int.tryParse(first.length >= 3 ? first.substring(0, 3) : '') ?? 0; - final text = StringBuffer(first.length > 4 ? first.substring(4) : ''); - if (first.length > 3 && first[3] == '-') { - while (true) { - final l = await _line(); - if (l.startsWith('$code ')) { - text.write('\n${l.substring(4)}'); - break; - } - text.write('\n${l.startsWith(' ') ? l.substring(1) : l}'); - } - } - return (code, text.toString()); - } - - /// 시험: 명령 채널 주고받기 (비밀번호 줄은 가림) - static void Function(String line)? debugLog; - - void send(String cmd) { - // 보안 검토 8: 경로 · 이름에 줄바꿈이 있으면 명령이 하나 더 끼어든다 - 보내지 않는다 - if (cmd.contains('\r') || cmd.contains('\n') || cmd.contains('\u0000')) { - throw FtpException(tr('이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다'), FtpFailure.other); - } - debugLog?.call('> ${cmd.startsWith('PASS ') ? 'PASS ***' : cmd}'); - _raw.write(utf8.encode('$cmd\r\n')); - } - - Future<(int, String)> cmd(String c) async { - send(c); - return reply(); - } - - /// [c] 를 보내고 [ok] 코드 (2xx 등) 가 아니면 [FtpException] - Future must(String c, {bool Function(int code)? ok, String? path}) async { - final (code, text) = await cmd(c); - if (ok != null ? ok(code) : code >= 200 && code < 300) return text; - throw failure(code, text, path); - } - - FtpException failure(int code, String text, String? path) { - if (code == 530) return FtpException(tr('아이디나 비밀번호가 맞지 않습니다'), FtpFailure.auth); - if (code == 550 || code == 450) { - final t = text.toLowerCase(); - if (t.contains('permission') || t.contains('denied') || t.contains('access')) { - return FtpException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [path ?? '']), FtpFailure.permission); - } - return FtpException(trf('없는 경로입니다: {0}', [path ?? '']), FtpFailure.notFound); - } - return FtpException('$code $text${path == null ? '' : ' ($path)'}', FtpFailure.other); - } - - /// TLS 로 바꾼다 (명시: AUTH TLS 뒤 · 암시: 연결하자마자). 자체 서명이면 믿은 지문만 - Future _secure(RawSocket raw, StreamSubscription? sub) async { - X509Certificate? bad; - try { - return await RawSecureSocket.secure(raw, subscription: sub, host: server.host.trim(), onBadCertificate: (c) { - bad = c; - return server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint; - }).timeout(timeout); - } on HandshakeException { - final c = bad; - if (c == null) rethrow; - final fp = certSha256(c); - throw FtpCertException( - server.certFingerprint.isEmpty - ? tr('처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요') - : tr('서버 인증서가 바뀌었습니다. 다른 서버로 연결되었을 수 있습니다'), - server.certFingerprint.isEmpty ? FtpFailure.certUnknown : FtpFailure.certChanged, - fingerprint: fp, - subject: c.subject, - previous: server.certFingerprint.isEmpty ? null : server.certFingerprint); - } - } - - /// 인사 · TLS · 로그인 · UTF-8 · 이진 · 시작 폴더 - Future login() async { - final (hello, text) = await reply(); - if (hello != 220) throw failure(hello, text, null); - if (server.security == 'explicit') { - final (code, t) = await cmd('AUTH TLS'); - if (code != 234) { - // FTPS 를 못 하는 서버: 몰래 평문으로 내려가지 않는다 (사용자가 "평문 FTP" 를 골라야) - throw FtpException(trf('서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 "평문 FTP" 를 고르세요', [code, t]), - FtpFailure.noTls); - } - // 보안 검토 2: 234 뒤에 평문으로 끼워 넣은 줄이 있으면 (중간자가 TLS 뒤의 답을 꾸미려는 것) 끊는다 - if (_lines.isNotEmpty || _buf.isNotEmpty) { - throw FtpException(tr('서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다'), FtpFailure.noTls); - } - final sub = _sub; - _sub = null; - _raw = await _secure(_raw, sub); - secure = true; - // RawSecureSocket.secure 가 넘겨받은 구독을 이어 쓰지 않으므로 다시 듣는다 - _listen(); - } - if (secure) { - await must('PBSZ 0'); - await must('PROT P'); - _protP = true; - } - if (server.user.isNotEmpty || server.password.isNotEmpty) { - final (code, t) = await cmd('USER ${server.user.isEmpty ? 'anonymous' : server.user}'); - if (code == 331 || code == 332) { - await must('PASS ${server.password}', ok: (c) => c == 230 || c == 202); - } else if (code != 230) { - throw failure(code, t, null); - } - } else { - await must('USER anonymous', ok: (c) => c == 230 || c == 331); - } - final feat = await cmd('FEAT'); - // MLST 를 알리면 MLSD 도 한다 (RFC 3659 - pyftpdlib 등은 FEAT 에 MLST 만 적는다) - mlsd = feat.$1 == 211 && RegExp(r'^\s*MLS[TD]', multiLine: true).hasMatch(feat.$2.toUpperCase()); - if (feat.$2.toUpperCase().contains('UTF8')) await cmd('OPTS UTF8 ON'); - await must('TYPE I'); - final pwd = await cmd('PWD'); - final m = RegExp(r'"((?:[^"]|"")*)"').firstMatch(pwd.$2); - if (pwd.$1 == 257 && m != null) home = m[1]!.replaceAll('""', '"'); - } - - /// 자료 채널 (EPSV, 안 되면 PASV). 주소는 명령 채널의 서버 주소로 (서버가 알려 준 다른 주소로는 가지 않는다 - FTP 바운스 막기) - Future data() async { - int? port; - final (c1, t1) = await cmd('EPSV'); - if (c1 == 229) { - final m = RegExp(r'\(\|\|\|(\d+)\|\)').firstMatch(t1); - if (m != null) port = int.parse(m[1]!); - } - if (port == null) { - final t = await must('PASV', ok: (c) => c == 227); - final m = RegExp(r'(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)').firstMatch(t); - if (m == null) throw FtpException(trf('서버의 자료 연결 응답을 읽지 못했습니다: {0}', [t]), FtpFailure.other); - port = int.parse(m[5]!) * 256 + int.parse(m[6]!); - } - return Socket.connect(addr, port, timeout: timeout); - } - - /// 자료 연결을 TLS 로 (PROT P). 명령 (RETR · STOR · MLSD) 을 보내고 150/125 를 받은 뒤에 한다 - 서버는 명령을 받고서야 TLS 를 시작한다 (pyftpdlib 등) - Future secureData(Socket sock) async { - if (!_protP) return sock; - final x = await SecureSocket.secure(sock, - host: server.host.trim(), - onBadCertificate: (c) => server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint).timeout(timeout); - return x; - } - - void close() { - if (_closed) return; - _closed = true; - try { - send('QUIT'); - } catch (_) {} - _sub?.cancel(); - _raw.close(); - } -} - -/// FTP · FTPS 클라이언트 (dart:io 로 직접 - 관리자 지시 l, 패키지 없이). 목록 · 정보 · 만들기 · 지우기 · 옮기기는 서버마다 명령 채널 하나를 -/// 열어 두고 차례로 쓰고, 받기 · 올리기는 따로 연결해 (동영상 건너뛰기처럼 여럿이 동시에 읽어도 서로 막지 않게) 끝나면 닫는다. -class FtpRemote implements RemoteClient { - final FtpServer server; - final Duration timeout; - FtpRemote(this.server, {this.timeout = const Duration(seconds: 15)}); - - Future<_Session>? _main; - Future _lock = Future.value(); - - Future<_Session> _connect() async { - final s = server; - if (s.host.trim().isEmpty) throw FtpException(tr('FTP 서버 주소가 없습니다'), FtpFailure.other); - if (s.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage)); - if (s.user.isNotEmpty && s.password.isEmpty) { - throw FtpException(tr('저장된 비밀번호가 없습니다. 서버 설정에서 비밀번호를 넣어 주세요'), FtpFailure.noSecret); - } - _Session? x; - try { - x = await _Session.open(s, timeout, FtpRegistry.resolve); - await x.login(); - return x; - } catch (e) { - x?.close(); - throw _explain(e); - } - } - - Object _explain(Object e) { - if (e is FtpException || e is DavLockedException || e is RemoteIdleTimeout) return e; - if (e is TimeoutException) return FtpException(trf('서버 ({0}) 가 답하지 않습니다', [server.host]), FtpFailure.unreachable); - if (e is SocketException) return FtpException(trf('서버 ({0}) 에 연결할 수 없습니다: {1}', [server.host, e.message]), FtpFailure.unreachable); - if (e is HandshakeException) return FtpException(trf('암호화 (TLS) 연결을 맺지 못했습니다: {0}', [e.message]), FtpFailure.other); - return FtpException('$e', FtpFailure.other); - } - - /// 명령 채널을 차례로 쓴다 (한 번에 하나). 끊겼으면 한 번 다시 연결 - Future _run(Future Function(_Session s) f) { - final prev = _lock; - final done = Completer(); - _lock = done.future; - return prev.then((_) async { - try { - for (var attempt = 0;; attempt++) { - final _Session s; - try { - s = await (_main ??= _connect()); - } catch (_) { - _main = null; - rethrow; - } - try { - return await f(s); - } on FtpException catch (e) { - if (e.kind == FtpFailure.unreachable && attempt == 0) { - s.close(); - _main = null; - continue; - } - rethrow; - } - } - } catch (e) { - if (e is! FtpException && e is! DavLockedException) { - _main = null; - throw _explain(e); - } - rethrow; - } finally { - done.complete(); - } - }); - } - - /// 서버 기준 경로 ("/" 로 시작) → 서버의 절대 경로 - String _abs(_Session s, String rel) { - var base = server.root.trim().isEmpty ? s.home : server.root.trim(); - if (!base.startsWith('/')) base = '/$base'; - if (base.length > 1 && base.endsWith('/')) base = base.substring(0, base.length - 1); - final r = rel == '/' || rel.isEmpty ? '' : rel; - if (base == '/') return r.isEmpty ? '/' : r; - return '$base$r'; - } - - static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name'; - static String _parent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')); - - /// MLSD · MLST 의 한 줄 "type=dir;size=1;modify=20261017120000; 이름" - static (String, bool, int, DateTime)? parseMlsx(String line) { - final sp = line.indexOf(' '); - if (sp < 0) return null; - final facts = {}; - for (final f in line.substring(0, sp).split(';')) { - final eq = f.indexOf('='); - if (eq > 0) facts[f.substring(0, eq).toLowerCase()] = f.substring(eq + 1); - } - final name = line.substring(sp + 1); - final type = (facts['type'] ?? '').toLowerCase(); - if (type == 'cdir' || type == 'pdir' || name == '.' || name == '..') return null; - final m = facts['modify'] ?? ''; - var when = DateTime.fromMillisecondsSinceEpoch(0); - if (m.length >= 14) { - when = DateTime.utc(int.parse(m.substring(0, 4)), int.parse(m.substring(4, 6)), int.parse(m.substring(6, 8)), - int.parse(m.substring(8, 10)), int.parse(m.substring(10, 12)), int.parse(m.substring(12, 14))); - } - final isDir = type == 'dir'; - return (name, isDir, isDir ? 0 : int.tryParse(facts['size'] ?? '') ?? 0, when); - } - - static const _months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; - - /// LIST 의 유닉스 꼴 한 줄 "drwxr-xr-x 1 u g 0 Oct 17 12:00 이름" (MLSD 가 없는 서버) - static (String, bool, int, DateTime)? parseList(String line) { - final m = RegExp(r'^([dl-])[rwxsStT-]{9}\S*\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s(.+)$').firstMatch(line); - if (m == null) return null; - var name = m[6]!; - if (m[1] == 'l' && name.contains(' -> ')) name = name.substring(0, name.indexOf(' -> ')); - if (name == '.' || name == '..') return null; - final mon = _months.indexOf(m[3]!.toLowerCase()) + 1; - final now = DateTime.now(); - final t = m[5]!; - final when = t.contains(':') - ? DateTime(now.year, mon, int.parse(m[4]!), int.parse(t.split(':')[0]), int.parse(t.split(':')[1])) - : DateTime(int.parse(t), mon, int.parse(m[4]!)); - final isDir = m[1] == 'd'; - return (name, isDir, isDir ? 0 : int.parse(m[2]!), when); - } - - /// 자료 채널로 목록 (MLSD 또는 LIST) 을 읽는다 - Future> _listRaw(_Session s, String abs, String rel) async { - final plain = await s.data(); - // LIST 에 -a 같은 옵션을 붙이면 경로로 읽는 서버가 있다 - s.send('${s.mlsd ? 'MLSD' : 'LIST'} $abs'); - final (code, text) = await s.reply(); - if (code != 150 && code != 125) { - plain.destroy(); - throw s.failure(code, text, rel); - } - final sock = await s.secureData(plain); - final bytes = await sock.fold>([], (a, b) => a..addAll(b)); - final (end, et) = await s.reply(); - if (end != 226 && end != 250) throw s.failure(end, et, rel); - final out = <(String, bool, int, DateTime)>[]; - for (final l in const LineSplitter().convert(utf8.decode(bytes, allowMalformed: true))) { - final x = s.mlsd ? parseMlsx(l) : parseList(l); - if (x != null) out.add(x); - } - return out; - } - - @override - Future> list(String rel) => _run((s) async => [ - for (final (n, dir, size, at) in await _listRaw(s, _abs(s, rel), rel)) DavItem(_child(rel, n), isDir: dir, size: size, modified: at), - ]); - - @override - Future stat(String rel) async { - if (rel == '/' || rel.isEmpty) return DavItem('/', isDir: true, modified: DateTime.fromMillisecondsSinceEpoch(0)); - // 위 폴더의 목록에서 찾는다 (MLST · SIZE 를 모든 서버가 같게 답하지 않아서 - 느리지만 확실하게) - try { - for (final x in await list(_parent(rel))) { - if (x.rel == rel) return x; - } - } on FtpException catch (e) { - if (e.kind == FtpFailure.notFound) return null; - rethrow; - } - return null; - } - - @override - Future mkdirs(String rel) async { - if (rel == '/' || rel.isEmpty) return; - if (await stat(rel) != null) return; - await mkdirs(_parent(rel)); - await _run((s) => s.must('MKD ${_abs(s, rel)}', path: rel)); - } - - @override - Future delete(String rel) async { - final st = await stat(rel); - if (st == null) return; - if (st.isDir) { - for (final x in await list(rel)) { - await delete(x.rel); - } - await _run((s) => s.must('RMD ${_abs(s, rel)}', path: rel)); - } else { - await _run((s) => s.must('DELE ${_abs(s, rel)}', path: rel)); - } - } - - @override - Future move(String from, String to, {bool overwrite = false}) async { - if (await stat(to) != null) { - if (!overwrite) throw FtpException(trf('같은 이름이 이미 있습니다: {0}', [to]), FtpFailure.other); - await delete(to); - } - await _run((s) async { - await s.must('RNFR ${_abs(s, from)}', ok: (c) => c == 350, path: from); - await s.must('RNTO ${_abs(s, to)}', path: to); - }); - } - - @override - Future>> openRead(String rel) => openReadFrom(rel); - - /// [offset] 부터 [length] 바이트 (null 이면 끝까지) - 따로 연결해 REST 로 (동영상 건너뛰기 · 이어 받기) - Future>> openReadFrom(String rel, {int offset = 0, int? length}) async { - final s = await _connect(); - Socket? sock; - try { - sock = await s.data(); - if (offset > 0) await s.must('REST $offset', ok: (c) => c == 350, path: rel); - s.send('RETR ${_abs(s, rel)}'); - final (code, text) = await s.reply(); - if (code != 150 && code != 125) throw s.failure(code, text, rel); - sock = await s.secureData(sock); - } catch (e) { - sock?.destroy(); - s.close(); - throw _explain(e); - } - final data = sock; - Stream> read() async* { - var left = length; - try { - // 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 (서버가 멈춰도 영원히 기다리지 않게) - await for (var chunk in idleRead(data, onTimeout: data.destroy)) { - if (left != null) { - if (chunk.length >= left) { - yield chunk.sublist(0, left); - left = 0; - break; - } - left -= chunk.length; - } - yield chunk; - } - if (left == null) { - final (end, et) = await s.reply(); - if (end != 226 && end != 250) throw s.failure(end, et, rel); - } - } finally { - data.destroy(); - s.close(); // 다 읽기 전에 멈추면 이 연결째 닫는다 (ABOR 대신 - 서버마다 답이 달라서) - } - } - - return read(); - } - - @override - Future write(String rel, Stream> data, {int? length}) async { - final s = await _connect(); - try { - final plain = await s.data(); - s.send('STOR ${_abs(s, rel)}'); - final (code, text) = await s.reply(); - if (code != 150 && code != 125) { - plain.destroy(); - throw s.failure(code, text, rel); - } - final sock = await s.secureData(plain); - // 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (연결을 끊음) - await idleWrite(data, (x) async { - await sock.addStream(x); - await sock.flush(); - }, onTimeout: sock.destroy); - // 닫기 (TLS 는 close_notify) 를 보내고, 서버가 답하지 않아도 오래 기다리지 않게 연결째 끊는다 - // 다 보낸 뒤 닫는다 (TLS 는 close_notify). close() 가 끝나도 암호화된 마지막 자료가 아직 나가지 않았을 수 있어 - // 연결이 정말 끝날 때 (done) 까지 기다리고, 서버가 답하지 않을 때만 끊는다 (곧바로 destroy 하면 빈 파일이 되었다) - await sock.close().timeout(const Duration(seconds: 5), onTimeout: () => sock); - await sock.done.timeout(const Duration(seconds: 5), onTimeout: () => sock.destroy()).catchError((_) {}); - final (end, et) = await s.reply(); - if (end != 226 && end != 250) throw s.failure(end, et, rel); - } catch (e) { - throw _explain(e); - } finally { - s.close(); - } - } - - /// FTP 는 남은 공간을 알려 주는 표준 명령이 없다 - @override - Future<(int?, int?)> quota() async => (null, null); - - @override - void close() { - final m = _main; - _main = null; - m?.then((s) => s.close(), onError: (_) {}); - } -} - -/// 설정의 FTP 서버 목록과 클라이언트 (서버마다 하나) -class FtpRegistry { - static final _clients = {}; - static final _servers = {}; - - /// 이름 풀기 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS (null = InternetAddress.lookup) - static FtpResolve resolve = (host) async => InternetAddress.tryParse(host); - - /// 정한 DNS 로 이름을 풀기 (집 안 이름 · 주소는 기기 DNS) - SFTP 와 같게 - static FtpResolve resolveWithDns(DnsResolver resolver) => (host) async { - final ip = InternetAddress.tryParse(host); - if (ip != null) return ip; - if (!resolver.config().forced || isLocalName(host)) return null; - final addrs = await resolver.lookup(host); - return addrs.where((a) => a.type == InternetAddressType.IPv4).firstOrNull ?? addrs.firstOrNull; - }; - - static void configure(List servers) { - final keep = {for (final s in servers) s.id: s}; - for (final id in _clients.keys.toList()) { - final old = _servers[id], now = keep[id]; - if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) { - _clients.remove(id)?.close(); - } - } - _servers - ..clear() - ..addAll(keep); - } - - static List get servers => _servers.values.toList(); - static FtpServer? server(String id) => _servers[id]; - - static FtpRemote client(String id) { - final s = _servers[id]; - if (s == null) throw FtpException(trf('FTP 서버 설정이 없습니다 ({0})', [id]), FtpFailure.other); - return _clients.putIfAbsent(id, () => FtpRemote(s)); - } -} - -/// 시험: 명령 채널 기록 (비밀번호 가림) -void debugFtpLog(void Function(String line)? f) => _Session.debugLog = f; +// 약속 (나누기 ftp): FTP · FTPS (dart:io 로 직접 - 관리자 지시 l, 패키지 없이) - 부르는 곳은 이 파일 하나만 가져온다. +// 일은 lib/core/ftp/ 의 모듈이 한다: ftp_server (설정 · 오류 · 지문) · ftp_session (명령 채널 하나) · ftp_remote (원격 저장소 · 목록). +export 'ftp/ftp_server.dart'; +export 'ftp/ftp_session.dart' show debugFtpLog; +export 'ftp/ftp_remote.dart'; diff --git a/lib/core/ftp/ftp_remote.dart b/lib/core/ftp/ftp_remote.dart new file mode 100644 index 0000000..e14591d --- /dev/null +++ b/lib/core/ftp/ftp_remote.dart @@ -0,0 +1,352 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import '../../l10n/tr.dart'; +import '../dns_resolver.dart'; +import '../remote_fs.dart'; +import '../secret_gate.dart'; +import '../webdav.dart' show DavItem, DavLockedException; +import '../remote_idle.dart'; +import 'ftp_server.dart'; +import 'ftp_session.dart'; + +// 약속 (나누기 ftp): FTP · FTPS 원격 저장소 ([RemoteClient]) 와 서버마다 하나인 클라이언트 목록 ([FtpRegistry]). +// 입력: 서버 기준 경로 ("/" 로 시작). 출력: 목록 · 정보 ([DavItem]) · 읽기 흐름 · 쓰기 · 남은 공간 (FTP 는 모름). +// 오류: [FtpException] (연결 못함 · 로그인 · 권한 · 없는 경로 · 같은 이름) · [FtpCertException] · 잠금 [DavLockedException] · +// 조각 사이 시간 넘김 [RemoteIdleTimeout]. 명령 채널이 끊기면 한 번 다시 연결한다. + +/// FTP · FTPS 클라이언트 (dart:io 로 직접 - 관리자 지시 l, 패키지 없이). 목록 · 정보 · 만들기 · 지우기 · 옮기기는 서버마다 명령 채널 하나를 +/// 열어 두고 차례로 쓰고, 받기 · 올리기는 따로 연결해 (동영상 건너뛰기처럼 여럿이 동시에 읽어도 서로 막지 않게) 끝나면 닫는다. +class FtpRemote implements RemoteClient { + final FtpServer server; + final Duration timeout; + FtpRemote(this.server, {this.timeout = const Duration(seconds: 15)}); + + Future? _main; + Future _lock = Future.value(); + + Future _connect() async { + final s = server; + if (s.host.trim().isEmpty) throw FtpException(tr('FTP 서버 주소가 없습니다'), FtpFailure.other); + if (s.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage)); + if (s.user.isNotEmpty && s.password.isEmpty) { + throw FtpException(tr('저장된 비밀번호가 없습니다. 서버 설정에서 비밀번호를 넣어 주세요'), FtpFailure.noSecret); + } + FtpSession? x; + try { + x = await FtpSession.open(s, timeout, FtpRegistry.resolve); + await x.login(); + return x; + } catch (e) { + x?.close(); + throw _explain(e); + } + } + + Object _explain(Object e) { + if (e is FtpException || e is DavLockedException || e is RemoteIdleTimeout) return e; + if (e is TimeoutException) return FtpException(trf('서버 ({0}) 가 답하지 않습니다', [server.host]), FtpFailure.unreachable); + if (e is SocketException) return FtpException(trf('서버 ({0}) 에 연결할 수 없습니다: {1}', [server.host, e.message]), FtpFailure.unreachable); + if (e is HandshakeException) return FtpException(trf('암호화 (TLS) 연결을 맺지 못했습니다: {0}', [e.message]), FtpFailure.other); + return FtpException('$e', FtpFailure.other); + } + + /// 명령 채널을 차례로 쓴다 (한 번에 하나). 끊겼으면 한 번 다시 연결 + Future _run(Future Function(FtpSession s) f) { + final prev = _lock; + final done = Completer(); + _lock = done.future; + return prev.then((_) async { + try { + for (var attempt = 0;; attempt++) { + final FtpSession s; + try { + s = await (_main ??= _connect()); + } catch (_) { + _main = null; + rethrow; + } + try { + return await f(s); + } on FtpException catch (e) { + if (e.kind == FtpFailure.unreachable && attempt == 0) { + s.close(); + _main = null; + continue; + } + rethrow; + } + } + } catch (e) { + if (e is! FtpException && e is! DavLockedException) { + _main = null; + throw _explain(e); + } + rethrow; + } finally { + done.complete(); + } + }); + } + + /// 서버 기준 경로 ("/" 로 시작) → 서버의 절대 경로 + String _abs(FtpSession s, String rel) { + var base = server.root.trim().isEmpty ? s.home : server.root.trim(); + if (!base.startsWith('/')) base = '/$base'; + if (base.length > 1 && base.endsWith('/')) base = base.substring(0, base.length - 1); + final r = rel == '/' || rel.isEmpty ? '' : rel; + if (base == '/') return r.isEmpty ? '/' : r; + return '$base$r'; + } + + static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name'; + static String _parent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')); + + /// MLSD · MLST 의 한 줄 "type=dir;size=1;modify=20261017120000; 이름" + static (String, bool, int, DateTime)? parseMlsx(String line) { + final sp = line.indexOf(' '); + if (sp < 0) return null; + final facts = {}; + for (final f in line.substring(0, sp).split(';')) { + final eq = f.indexOf('='); + if (eq > 0) facts[f.substring(0, eq).toLowerCase()] = f.substring(eq + 1); + } + final name = line.substring(sp + 1); + final type = (facts['type'] ?? '').toLowerCase(); + if (type == 'cdir' || type == 'pdir' || name == '.' || name == '..') return null; + final m = facts['modify'] ?? ''; + var when = DateTime.fromMillisecondsSinceEpoch(0); + if (m.length >= 14) { + when = DateTime.utc(int.parse(m.substring(0, 4)), int.parse(m.substring(4, 6)), int.parse(m.substring(6, 8)), + int.parse(m.substring(8, 10)), int.parse(m.substring(10, 12)), int.parse(m.substring(12, 14))); + } + final isDir = type == 'dir'; + return (name, isDir, isDir ? 0 : int.tryParse(facts['size'] ?? '') ?? 0, when); + } + + static const _months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; + + /// LIST 의 유닉스 꼴 한 줄 "drwxr-xr-x 1 u g 0 Oct 17 12:00 이름" (MLSD 가 없는 서버) + static (String, bool, int, DateTime)? parseList(String line) { + final m = RegExp(r'^([dl-])[rwxsStT-]{9}\S*\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s(.+)$').firstMatch(line); + if (m == null) return null; + var name = m[6]!; + if (m[1] == 'l' && name.contains(' -> ')) name = name.substring(0, name.indexOf(' -> ')); + if (name == '.' || name == '..') return null; + final mon = _months.indexOf(m[3]!.toLowerCase()) + 1; + final now = DateTime.now(); + final t = m[5]!; + final when = t.contains(':') + ? DateTime(now.year, mon, int.parse(m[4]!), int.parse(t.split(':')[0]), int.parse(t.split(':')[1])) + : DateTime(int.parse(t), mon, int.parse(m[4]!)); + final isDir = m[1] == 'd'; + return (name, isDir, isDir ? 0 : int.parse(m[2]!), when); + } + + /// 자료 채널로 목록 (MLSD 또는 LIST) 을 읽는다 + Future> _listRaw(FtpSession s, String abs, String rel) async { + final plain = await s.data(); + // LIST 에 -a 같은 옵션을 붙이면 경로로 읽는 서버가 있다 + s.send('${s.mlsd ? 'MLSD' : 'LIST'} $abs'); + final (code, text) = await s.reply(); + if (code != 150 && code != 125) { + plain.destroy(); + throw s.failure(code, text, rel); + } + final sock = await s.secureData(plain); + final bytes = await sock.fold>([], (a, b) => a..addAll(b)); + final (end, et) = await s.reply(); + if (end != 226 && end != 250) throw s.failure(end, et, rel); + final out = <(String, bool, int, DateTime)>[]; + for (final l in const LineSplitter().convert(utf8.decode(bytes, allowMalformed: true))) { + final x = s.mlsd ? parseMlsx(l) : parseList(l); + if (x != null) out.add(x); + } + return out; + } + + @override + Future> list(String rel) => _run((s) async => [ + for (final (n, dir, size, at) in await _listRaw(s, _abs(s, rel), rel)) DavItem(_child(rel, n), isDir: dir, size: size, modified: at), + ]); + + @override + Future stat(String rel) async { + if (rel == '/' || rel.isEmpty) return DavItem('/', isDir: true, modified: DateTime.fromMillisecondsSinceEpoch(0)); + // 위 폴더의 목록에서 찾는다 (MLST · SIZE 를 모든 서버가 같게 답하지 않아서 - 느리지만 확실하게) + try { + for (final x in await list(_parent(rel))) { + if (x.rel == rel) return x; + } + } on FtpException catch (e) { + if (e.kind == FtpFailure.notFound) return null; + rethrow; + } + return null; + } + + @override + Future mkdirs(String rel) async { + if (rel == '/' || rel.isEmpty) return; + if (await stat(rel) != null) return; + await mkdirs(_parent(rel)); + await _run((s) => s.must('MKD ${_abs(s, rel)}', path: rel)); + } + + @override + Future delete(String rel) async { + final st = await stat(rel); + if (st == null) return; + if (st.isDir) { + for (final x in await list(rel)) { + await delete(x.rel); + } + await _run((s) => s.must('RMD ${_abs(s, rel)}', path: rel)); + } else { + await _run((s) => s.must('DELE ${_abs(s, rel)}', path: rel)); + } + } + + @override + Future move(String from, String to, {bool overwrite = false}) async { + if (await stat(to) != null) { + if (!overwrite) throw FtpException(trf('같은 이름이 이미 있습니다: {0}', [to]), FtpFailure.other); + await delete(to); + } + await _run((s) async { + await s.must('RNFR ${_abs(s, from)}', ok: (c) => c == 350, path: from); + await s.must('RNTO ${_abs(s, to)}', path: to); + }); + } + + @override + Future>> openRead(String rel) => openReadFrom(rel); + + /// [offset] 부터 [length] 바이트 (null 이면 끝까지) - 따로 연결해 REST 로 (동영상 건너뛰기 · 이어 받기) + Future>> openReadFrom(String rel, {int offset = 0, int? length}) async { + final s = await _connect(); + Socket? sock; + try { + sock = await s.data(); + if (offset > 0) await s.must('REST $offset', ok: (c) => c == 350, path: rel); + s.send('RETR ${_abs(s, rel)}'); + final (code, text) = await s.reply(); + if (code != 150 && code != 125) throw s.failure(code, text, rel); + sock = await s.secureData(sock); + } catch (e) { + sock?.destroy(); + s.close(); + throw _explain(e); + } + final data = sock; + Stream> read() async* { + var left = length; + try { + // 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 (서버가 멈춰도 영원히 기다리지 않게) + await for (var chunk in idleRead(data, onTimeout: data.destroy)) { + if (left != null) { + if (chunk.length >= left) { + yield chunk.sublist(0, left); + left = 0; + break; + } + left -= chunk.length; + } + yield chunk; + } + if (left == null) { + final (end, et) = await s.reply(); + if (end != 226 && end != 250) throw s.failure(end, et, rel); + } + } finally { + data.destroy(); + s.close(); // 다 읽기 전에 멈추면 이 연결째 닫는다 (ABOR 대신 - 서버마다 답이 달라서) + } + } + + return read(); + } + + @override + Future write(String rel, Stream> data, {int? length}) async { + final s = await _connect(); + try { + final plain = await s.data(); + s.send('STOR ${_abs(s, rel)}'); + final (code, text) = await s.reply(); + if (code != 150 && code != 125) { + plain.destroy(); + throw s.failure(code, text, rel); + } + final sock = await s.secureData(plain); + // 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (연결을 끊음) + await idleWrite(data, (x) async { + await sock.addStream(x); + await sock.flush(); + }, onTimeout: sock.destroy); + // 닫기 (TLS 는 close_notify) 를 보내고, 서버가 답하지 않아도 오래 기다리지 않게 연결째 끊는다 + // 다 보낸 뒤 닫는다 (TLS 는 close_notify). close() 가 끝나도 암호화된 마지막 자료가 아직 나가지 않았을 수 있어 + // 연결이 정말 끝날 때 (done) 까지 기다리고, 서버가 답하지 않을 때만 끊는다 (곧바로 destroy 하면 빈 파일이 되었다) + await sock.close().timeout(const Duration(seconds: 5), onTimeout: () => sock); + await sock.done.timeout(const Duration(seconds: 5), onTimeout: () => sock.destroy()).catchError((_) {}); + final (end, et) = await s.reply(); + if (end != 226 && end != 250) throw s.failure(end, et, rel); + } catch (e) { + throw _explain(e); + } finally { + s.close(); + } + } + + /// FTP 는 남은 공간을 알려 주는 표준 명령이 없다 + @override + Future<(int?, int?)> quota() async => (null, null); + + @override + void close() { + final m = _main; + _main = null; + m?.then((s) => s.close(), onError: (_) {}); + } +} + +/// 설정의 FTP 서버 목록과 클라이언트 (서버마다 하나) +class FtpRegistry { + static final _clients = {}; + static final _servers = {}; + + /// 이름 풀기 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS (null = InternetAddress.lookup) + static FtpResolve resolve = (host) async => InternetAddress.tryParse(host); + + /// 정한 DNS 로 이름을 풀기 (집 안 이름 · 주소는 기기 DNS) - SFTP 와 같게 + static FtpResolve resolveWithDns(DnsResolver resolver) => (host) async { + final ip = InternetAddress.tryParse(host); + if (ip != null) return ip; + if (!resolver.config().forced || isLocalName(host)) return null; + final addrs = await resolver.lookup(host); + return addrs.where((a) => a.type == InternetAddressType.IPv4).firstOrNull ?? addrs.firstOrNull; + }; + + static void configure(List servers) { + final keep = {for (final s in servers) s.id: s}; + for (final id in _clients.keys.toList()) { + final old = _servers[id], now = keep[id]; + if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) { + _clients.remove(id)?.close(); + } + } + _servers + ..clear() + ..addAll(keep); + } + + static List get servers => _servers.values.toList(); + static FtpServer? server(String id) => _servers[id]; + + static FtpRemote client(String id) { + final s = _servers[id]; + if (s == null) throw FtpException(trf('FTP 서버 설정이 없습니다 ({0})', [id]), FtpFailure.other); + return _clients.putIfAbsent(id, () => FtpRemote(s)); + } +} diff --git a/lib/core/ftp/ftp_server.dart b/lib/core/ftp/ftp_server.dart new file mode 100644 index 0000000..bcf9f6a --- /dev/null +++ b/lib/core/ftp/ftp_server.dart @@ -0,0 +1,109 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart' show sha256; + +import '../webdav.dart' show DavException; + +// 약속 (나누기 ftp): FTP · FTPS 서버 설정 (저장 꼴) · 실패 종류 · 오류 · 인증서 지문. +// 입력: 설정 JSON (비밀 값 없음) · 인증서. 출력: [FtpServer] · JSON · "SHA256:AB:…" 지문. +// 오류: 던지지 않는다 ([FtpServer.fromJson] 은 모르는 값을 처음 값 (FTPS · 21/990) 으로). [FtpException] · [FtpCertException] 은 다른 ftp 모듈이 던진다. + +/// FTP · FTPS 서버 설정 (관리자 지시 10/17, l). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). +/// [security]: 'explicit' (FTPS - AUTH TLS, 처음 값) · 'implicit' (FTPS 990) · 'none' (평문 FTP - 연결 · 설정에서 "암호화 안 됨" 경고). +/// 자체 서명 인증서는 처음 연결 때 사용자가 믿은 지문 ([certFingerprint], SHA-256) 만 받는다 (TOFU - SFTP 호스트 키와 같은 판단). +class FtpServer { + const FtpServer({ + required this.id, + required this.name, + required this.host, + this.port = 21, + this.user = '', + this.root = '', + this.password = '', + this.security = 'explicit', + this.certFingerprint = '', + }); + + final String id; + final String name; + final String host; + final int port; + final String user; + + /// 서버의 시작 폴더 (비우면 로그인한 곳) + final String root; + + /// 안전 저장소에서 채움 (설정 파일에는 없음) + final String password; + final String security; + + /// 믿은 자체 서명 인증서의 지문 "SHA256:xx:xx…" (비밀 아님) + final String certFingerprint; + + bool get tls => security != 'none'; + String get label => name.trim().isNotEmpty ? name.trim() : (host.trim().isEmpty ? 'FTP' : host.trim()); + + FtpServer copyWith({String? name, String? host, int? port, String? user, String? root, String? password, String? security, String? certFingerprint}) => + FtpServer( + id: id, + name: name ?? this.name, + host: host ?? this.host, + port: port ?? this.port, + user: user ?? this.user, + root: root ?? this.root, + password: password ?? this.password, + security: security ?? this.security, + certFingerprint: certFingerprint ?? this.certFingerprint, + ); + + /// 비밀 값은 넣지 않는다 (안전 저장소). 저장해 두었는지만 + Map toJson() => { + 'id': id, + 'name': name, + 'host': host, + 'port': port, + 'user': user, + 'root': root, + 'hasPassword': password.isNotEmpty, + 'security': security, + 'certFingerprint': certFingerprint, + }; + + factory FtpServer.fromJson(Map j) => FtpServer( + id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}', + name: j['name'] as String? ?? '', + host: j['host'] as String? ?? '', + port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => j['security'] == 'implicit' ? 990 : 21 }, + user: j['user'] as String? ?? '', + root: j['root'] as String? ?? '', + // 모르는 값 · 없는 값은 FTPS (지침 3 - 덜 위험한 쪽, 관리자 지시 "처음 값은 FTPS 우선") + security: const ['explicit', 'implicit', 'none'].contains(j['security']) ? j['security'] as String : 'explicit', + certFingerprint: j['certFingerprint'] as String? ?? '', + ); +} + +/// FTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데) +enum FtpFailure { unreachable, auth, noSecret, certUnknown, certChanged, noTls, permission, notFound, other } + +class FtpException extends DavException { + final FtpFailure kind; + const FtpException(super.message, this.kind); +} + +/// 처음 보는 (또는 바뀐) 자체 서명 인증서: 화면이 지문을 보이고 믿을지 묻는다. 믿으면 [FtpServer.certFingerprint] 에 저장 +class FtpCertException extends FtpException { + final String fingerprint; + final String subject; + + /// 바뀐 경우 예전에 믿은 지문 + final String? previous; + const FtpCertException(super.message, super.kind, {required this.fingerprint, required this.subject, this.previous}); + bool get changed => previous != null; +} + +/// 인증서 지문 "SHA256:AB:CD:…" +String certSha256(X509Certificate c) => + 'SHA256:${sha256.convert(c.der).bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(':')}'; + +/// 이름 → 연결할 주소 (DNS 강제면 정한 DNS - main 에서 바꾼다). 처음 값: 기기 DNS (주소를 그대로 넘김) +typedef FtpResolve = Future Function(String host); diff --git a/lib/core/ftp/ftp_session.dart b/lib/core/ftp/ftp_session.dart new file mode 100644 index 0000000..bea9ec2 --- /dev/null +++ b/lib/core/ftp/ftp_session.dart @@ -0,0 +1,255 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import '../../l10n/tr.dart'; +import 'ftp_server.dart'; + +// 약속 (나누기 ftp): FTP 명령 채널 하나 - 연결 · TLS (명시 · 암시, 믿은 지문만) · 로그인 · 명령 주고받기 · 자료 채널 (EPSV / PASV). +// 입력: [FtpServer] · 시간 제한 · 이름 풀기 ([FtpResolve]). 출력: 응답 (코드, 글) · 자료 소켓. +// 오류: 끊김 · 로그인 실패 · 권한 · 없는 경로 → [FtpException] (종류 [FtpFailure]) · 처음 보는 / 바뀐 인증서 → [FtpCertException] · +// 답 없음 → [TimeoutException]. 줄바꿈이 든 명령은 보내지 않고 [FtpException]. + +/// FTP 명령 채널 하나 (로그인까지). 응답은 줄 단위 ("123-…" 여러 줄 → "123 …" 끝) +class FtpSession { + final FtpServer server; + final Duration timeout; + final InternetAddress addr; + RawSocket _raw; + StreamSubscription? _sub; + final _buf = []; + final _lines = []; + Completer? _wake; + bool _closed = false; + bool secure = false; + bool mlsd = false; + bool _protP = false; + + /// 로그인 뒤 서버의 지금 폴더 (시작 폴더가 비었을 때 맨 위) + String home = '/'; + + FtpSession._(this.server, this.timeout, this.addr, this._raw); + + static Future open(FtpServer s, Duration timeout, FtpResolve resolve) async { + final addr = await resolve(s.host.trim()) ?? (await InternetAddress.lookup(s.host.trim())).first; + final raw = await RawSocket.connect(addr, s.port, timeout: timeout); + final x = FtpSession._(s, timeout, addr, raw); + if (s.security == 'implicit') { + x._raw = await x._secure(raw, null); + x.secure = true; + } + x._listen(); + return x; + } + + void _listen() { + _sub = _raw.listen((ev) { + if (ev == RawSocketEvent.read) { + final d = _raw.read(); + if (d != null) _feed(d); + } else if (ev == RawSocketEvent.readClosed || ev == RawSocketEvent.closed) { + _closed = true; + _poke(); + } + }, onError: (_) { + _closed = true; + _poke(); + }); + } + + void _feed(Uint8List d) { + _buf.addAll(d); + while (true) { + final i = _buf.indexOf(10); + if (i < 0) break; + var line = utf8.decode(_buf.sublist(0, i), allowMalformed: true); + if (line.endsWith('\r')) line = line.substring(0, line.length - 1); + _buf.removeRange(0, i + 1); + debugLog?.call('< $line'); + _lines.add(line); + } + _poke(); + } + + void _poke() { + final w = _wake; + _wake = null; + w?.complete(); + } + + Future _line() async { + final end = DateTime.now().add(timeout); + while (_lines.isEmpty) { + if (_closed) throw FtpException(trf('서버 ({0}) 와의 연결이 끊겼습니다', [server.host]), FtpFailure.unreachable); + final left = end.difference(DateTime.now()); + if (left <= Duration.zero) throw TimeoutException(tr('답이 없습니다')); + _wake = Completer(); + await _wake!.future.timeout(left, onTimeout: () {}); + } + return _lines.removeAt(0); + } + + /// 응답 하나 (여러 줄이면 합침): (코드, 글) + Future<(int, String)> reply() async { + final first = await _line(); + final code = int.tryParse(first.length >= 3 ? first.substring(0, 3) : '') ?? 0; + final text = StringBuffer(first.length > 4 ? first.substring(4) : ''); + if (first.length > 3 && first[3] == '-') { + while (true) { + final l = await _line(); + if (l.startsWith('$code ')) { + text.write('\n${l.substring(4)}'); + break; + } + text.write('\n${l.startsWith(' ') ? l.substring(1) : l}'); + } + } + return (code, text.toString()); + } + + /// 시험: 명령 채널 주고받기 (비밀번호 줄은 가림) + static void Function(String line)? debugLog; + + void send(String cmd) { + // 보안 검토 8: 경로 · 이름에 줄바꿈이 있으면 명령이 하나 더 끼어든다 - 보내지 않는다 + if (cmd.contains('\r') || cmd.contains('\n') || cmd.contains('\u0000')) { + throw FtpException(tr('이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다'), FtpFailure.other); + } + debugLog?.call('> ${cmd.startsWith('PASS ') ? 'PASS ***' : cmd}'); + _raw.write(utf8.encode('$cmd\r\n')); + } + + Future<(int, String)> cmd(String c) async { + send(c); + return reply(); + } + + /// [c] 를 보내고 [ok] 코드 (2xx 등) 가 아니면 [FtpException] + Future must(String c, {bool Function(int code)? ok, String? path}) async { + final (code, text) = await cmd(c); + if (ok != null ? ok(code) : code >= 200 && code < 300) return text; + throw failure(code, text, path); + } + + FtpException failure(int code, String text, String? path) { + if (code == 530) return FtpException(tr('아이디나 비밀번호가 맞지 않습니다'), FtpFailure.auth); + if (code == 550 || code == 450) { + final t = text.toLowerCase(); + if (t.contains('permission') || t.contains('denied') || t.contains('access')) { + return FtpException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [path ?? '']), FtpFailure.permission); + } + return FtpException(trf('없는 경로입니다: {0}', [path ?? '']), FtpFailure.notFound); + } + return FtpException('$code $text${path == null ? '' : ' ($path)'}', FtpFailure.other); + } + + /// TLS 로 바꾼다 (명시: AUTH TLS 뒤 · 암시: 연결하자마자). 자체 서명이면 믿은 지문만 + Future _secure(RawSocket raw, StreamSubscription? sub) async { + X509Certificate? bad; + try { + return await RawSecureSocket.secure(raw, subscription: sub, host: server.host.trim(), onBadCertificate: (c) { + bad = c; + return server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint; + }).timeout(timeout); + } on HandshakeException { + final c = bad; + if (c == null) rethrow; + final fp = certSha256(c); + throw FtpCertException( + server.certFingerprint.isEmpty + ? tr('처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요') + : tr('서버 인증서가 바뀌었습니다. 다른 서버로 연결되었을 수 있습니다'), + server.certFingerprint.isEmpty ? FtpFailure.certUnknown : FtpFailure.certChanged, + fingerprint: fp, + subject: c.subject, + previous: server.certFingerprint.isEmpty ? null : server.certFingerprint); + } + } + + /// 인사 · TLS · 로그인 · UTF-8 · 이진 · 시작 폴더 + Future login() async { + final (hello, text) = await reply(); + if (hello != 220) throw failure(hello, text, null); + if (server.security == 'explicit') { + final (code, t) = await cmd('AUTH TLS'); + if (code != 234) { + // FTPS 를 못 하는 서버: 몰래 평문으로 내려가지 않는다 (사용자가 "평문 FTP" 를 골라야) + throw FtpException(trf('서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 "평문 FTP" 를 고르세요', [code, t]), + FtpFailure.noTls); + } + // 보안 검토 2: 234 뒤에 평문으로 끼워 넣은 줄이 있으면 (중간자가 TLS 뒤의 답을 꾸미려는 것) 끊는다 + if (_lines.isNotEmpty || _buf.isNotEmpty) { + throw FtpException(tr('서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다'), FtpFailure.noTls); + } + final sub = _sub; + _sub = null; + _raw = await _secure(_raw, sub); + secure = true; + // RawSecureSocket.secure 가 넘겨받은 구독을 이어 쓰지 않으므로 다시 듣는다 + _listen(); + } + if (secure) { + await must('PBSZ 0'); + await must('PROT P'); + _protP = true; + } + if (server.user.isNotEmpty || server.password.isNotEmpty) { + final (code, t) = await cmd('USER ${server.user.isEmpty ? 'anonymous' : server.user}'); + if (code == 331 || code == 332) { + await must('PASS ${server.password}', ok: (c) => c == 230 || c == 202); + } else if (code != 230) { + throw failure(code, t, null); + } + } else { + await must('USER anonymous', ok: (c) => c == 230 || c == 331); + } + final feat = await cmd('FEAT'); + // MLST 를 알리면 MLSD 도 한다 (RFC 3659 - pyftpdlib 등은 FEAT 에 MLST 만 적는다) + mlsd = feat.$1 == 211 && RegExp(r'^\s*MLS[TD]', multiLine: true).hasMatch(feat.$2.toUpperCase()); + if (feat.$2.toUpperCase().contains('UTF8')) await cmd('OPTS UTF8 ON'); + await must('TYPE I'); + final pwd = await cmd('PWD'); + final m = RegExp(r'"((?:[^"]|"")*)"').firstMatch(pwd.$2); + if (pwd.$1 == 257 && m != null) home = m[1]!.replaceAll('""', '"'); + } + + /// 자료 채널 (EPSV, 안 되면 PASV). 주소는 명령 채널의 서버 주소로 (서버가 알려 준 다른 주소로는 가지 않는다 - FTP 바운스 막기) + Future data() async { + int? port; + final (c1, t1) = await cmd('EPSV'); + if (c1 == 229) { + final m = RegExp(r'\(\|\|\|(\d+)\|\)').firstMatch(t1); + if (m != null) port = int.parse(m[1]!); + } + if (port == null) { + final t = await must('PASV', ok: (c) => c == 227); + final m = RegExp(r'(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)').firstMatch(t); + if (m == null) throw FtpException(trf('서버의 자료 연결 응답을 읽지 못했습니다: {0}', [t]), FtpFailure.other); + port = int.parse(m[5]!) * 256 + int.parse(m[6]!); + } + return Socket.connect(addr, port, timeout: timeout); + } + + /// 자료 연결을 TLS 로 (PROT P). 명령 (RETR · STOR · MLSD) 을 보내고 150/125 를 받은 뒤에 한다 - 서버는 명령을 받고서야 TLS 를 시작한다 (pyftpdlib 등) + Future secureData(Socket sock) async { + if (!_protP) return sock; + final x = await SecureSocket.secure(sock, + host: server.host.trim(), + onBadCertificate: (c) => server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint).timeout(timeout); + return x; + } + + void close() { + if (_closed) return; + _closed = true; + try { + send('QUIT'); + } catch (_) {} + _sub?.cancel(); + _raw.close(); + } +} + +/// 시험: 명령 채널 기록 (비밀번호 가림) +void debugFtpLog(void Function(String line)? f) => FtpSession.debugLog = f; diff --git a/test/ftp_modules_contract_test.dart b/test/ftp_modules_contract_test.dart new file mode 100644 index 0000000..4819f54 --- /dev/null +++ b/test/ftp_modules_contract_test.dart @@ -0,0 +1,98 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/ftp/ftp_remote.dart'; +import 'package:jj_mkvmaker/core/ftp/ftp_server.dart'; +import 'package:jj_mkvmaker/core/ftp/ftp_session.dart'; +import 'package:path/path.dart' as p; + +import 'support/sftp_server.dart' show sftpTestPython; + +/// 나누기 ftp 약속 시험: lib/core/ftp/ 모듈마다 맨 위 약속 - 실제 서버 (tool/ftp_test_server.py, pyftpdlib) · 실제 시험 폴더. +/// 시험용 파이썬이 없으면 서버 시험은 건너뛴다 (ftp_test 와 같음) +const _pw = 'ftp-Pw-7Rk2xQ9t'; + +void main() { + final hasPy = File(sftpTestPython).existsSync(); + late Directory tmp; + Process? proc; + late int port; + late String fp; + + Future start(String mode) async { + final pr = await Process.start(sftpTestPython, ['tool/ftp_test_server.py', '--root', tmp.path, '--mode', mode, '--password', _pw]); + proc = pr; + final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream(); + pr.stderr.drain(); + port = int.parse((await lines.firstWhere((l) => l.startsWith('PORT ')).timeout(const Duration(seconds: 20))).split(' ')[1]); + final f = (await lines.firstWhere((l) => l.startsWith('FINGERPRINT')).timeout(const Duration(seconds: 5))).split(' '); + fp = f.length > 1 ? f[1] : ''; + } + + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_ftpm_')); + tearDown(() async { + final pr = proc; + proc = null; + if (pr != null) { + pr.kill(); + await pr.exitCode.timeout(const Duration(seconds: 5), onTimeout: () => -1); + } + for (var i = 0; tmp.existsSync(); i++) { + try { + tmp.deleteSync(recursive: true); + } on FileSystemException { + if (i > 30) rethrow; + await Future.delayed(const Duration(milliseconds: 100)); + } + } + }); + + FtpServer srv(String security, {String cert = '', String password = _pw}) => + FtpServer(id: 'm1', name: '', host: '127.0.0.1', port: port, user: 'jj', password: password, security: security, certFingerprint: cert); + Future resolve(String h) async => InternetAddress.tryParse(h); + + test('ftp_server: JSON 에 비밀 값 없음 · 모르는 값은 FTPS · 이름', () { + const s = FtpServer(id: 'a', name: ' ', host: 'nas', password: 'secret', security: 'none'); + expect(jsonEncode(s.toJson()), isNot(contains('secret'))); + expect(FtpServer.fromJson(s.toJson()).security, 'none'); + expect(FtpServer.fromJson({'id': 'x', 'security': '?'}).security, 'explicit'); + expect((s.label, s.tls, s.copyWith(security: 'implicit').tls), ('nas', false, true)); + }); + + test('ftp_session: 처음 보는 인증서는 지문과 함께 막음 → 믿으면 로그인 · 명령 · 줄바꿈 명령은 보내지 않음', () async { + await start('explicit'); + final first = await FtpSession.open(srv('explicit'), const Duration(seconds: 10), resolve); + final e = await first.login().then((_) => null, onError: (Object x) => x); + first.close(); + expect(e, isA().having((x) => x.fingerprint, 'fingerprint', fp).having((x) => x.kind, 'kind', FtpFailure.certUnknown)); + final s = await FtpSession.open(srv('explicit', cert: fp), const Duration(seconds: 10), resolve); + addTearDown(s.close); + await s.login(); + expect(s.secure, isTrue); + expect(s.home, '/'); + expect((await s.cmd('NOOP')).$1, 200); + expect(() => s.send('DELE a\r\nDELE b'), throwsA(isA())); + }, skip: hasPy ? false : '시험용 파이썬 없음'); + + test('ftp_remote: 쓰기 · 목록 · 정보 · 부분 읽기 · 옮기기 · 지우기 (평문) · 틀린 비밀번호는 auth', () async { + await start('plain'); + final c = FtpRemote(srv('none')); + addTearDown(c.close); + await c.mkdirs('/가/나'); + await c.write('/가/나/a.txt', Stream.value(utf8.encode('0123456789'))); + expect(File(p.join(tmp.path, '가', '나', 'a.txt')).readAsStringSync(), '0123456789'); + expect((await c.list('/가/나')).map((x) => (x.rel, x.size)), [('/가/나/a.txt', 10)]); + expect(await c.stat('/없음'), isNull); + final part = await (await c.openReadFrom('/가/나/a.txt', offset: 4, length: 3)).expand((x) => x).toList(); + expect(utf8.decode(part), '456'); + await c.move('/가/나/a.txt', '/가/b.txt'); + expect(File(p.join(tmp.path, '가', 'b.txt')).existsSync(), isTrue); + await c.delete('/가'); + expect(Directory(p.join(tmp.path, '가')).existsSync(), isFalse); + final bad = FtpRemote(srv('none', password: 'wrong-pw')); + addTearDown(bad.close); + await expectLater(bad.list('/'), throwsA(isA().having((x) => x.kind, 'kind', FtpFailure.auth))); + }, skip: hasPy ? false : '시험용 파이썬 없음'); +}