JJ_MKVMaker/lib/core/ftp.dart

679 lines
26 KiB
Dart
Raw Permalink Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

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;
/// 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<String, Object?> toJson() => {
'id': id,
'name': name,
'host': host,
'port': port,
'user': user,
'root': root,
'hasPassword': password.isNotEmpty,
'security': security,
'certFingerprint': certFingerprint,
};
factory FtpServer.fromJson(Map<Object?, Object?> 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<InternetAddress?> Function(String host);
/// FTP 명령 채널 하나 (로그인까지). 응답은 줄 단위 ("123-…" 여러 줄 → "123 …" 끝)
class _Session {
final FtpServer server;
final Duration timeout;
final InternetAddress addr;
RawSocket _raw;
StreamSubscription<RawSocketEvent>? _sub;
final _buf = <int>[];
final _lines = <String>[];
Completer<void>? _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<String> _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<void>();
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) {
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<String> 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<RawSecureSocket> _secure(RawSocket raw, StreamSubscription<RawSocketEvent>? 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<void> 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);
}
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<Socket> 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<Socket> 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<void> _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) 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<T> _run<T>(Future<T> Function(_Session s) f) {
final prev = _lock;
final done = Completer<void>();
_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 = <String, String>{};
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<List<(String, bool, int, DateTime)>> _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<List<int>>(<int>[], (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<DavItem>> 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<DavItem?> 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<void> 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<void> 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<void> 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<Stream<List<int>>> openRead(String rel) => openReadFrom(rel);
/// [offset] 부터 [length] 바이트 (null 이면 끝까지) - 따로 연결해 REST 로 (동영상 건너뛰기 · 이어 받기)
Future<Stream<List<int>>> 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<List<int>> read() async* {
var left = length;
try {
await for (var chunk in data) {
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<void> write(String rel, Stream<List<int>> 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);
await sock.addStream(data);
await sock.flush();
// 닫기 (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 = <String, FtpRemote>{};
static final _servers = <String, FtpServer>{};
/// 이름 풀기 (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<FtpServer> 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<FtpServer> 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;