나누기 core B: ftp (691) 를 lib/core/ftp/ 모듈 3개로 (→ 정면 ftp)
- ftp_server (설정 · 실패 종류 · 오류 · 인증서 지문 · 이름 풀기 꼴) - ftp_session (명령 채널 하나: 연결 · TLS · 로그인 · 명령 · 자료 채널 - 예전 _Session 을 FtpSession 으로) - ftp_remote (FtpRemote 원격 저장소 · FtpRegistry) - ftp.dart 는 export 만 (debugFtpLog 포함, 부르는 쪽은 바꾸지 않음) - 약속 시험: test/ftp_modules_contract_test.dart (실제 pyftpdlib 서버 · 시험 폴더) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>split_core
parent
197072b482
commit
8c3b9b7c55
|
|
@ -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<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) {
|
||||
// 보안 검토 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<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);
|
||||
}
|
||||
// 보안 검토 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<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 || 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<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 {
|
||||
// 관리자 판단 (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<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);
|
||||
// 관리자 판단 (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 = <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;
|
||||
// 약속 (나누기 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';
|
||||
|
|
|
|||
|
|
@ -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<FtpSession>? _main;
|
||||
Future<void> _lock = Future.value();
|
||||
|
||||
Future<FtpSession> _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<T> _run<T>(Future<T> Function(FtpSession s) f) {
|
||||
final prev = _lock;
|
||||
final done = Completer<void>();
|
||||
_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 = <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(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<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 {
|
||||
// 관리자 판단 (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<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);
|
||||
// 관리자 판단 (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 = <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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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);
|
||||
|
|
@ -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<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 = '/';
|
||||
|
||||
FtpSession._(this.server, this.timeout, this.addr, this._raw);
|
||||
|
||||
static Future<FtpSession> 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<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) {
|
||||
// 보안 검토 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<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);
|
||||
}
|
||||
// 보안 검토 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<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();
|
||||
}
|
||||
}
|
||||
|
||||
/// 시험: 명령 채널 기록 (비밀번호 가림)
|
||||
void debugFtpLog(void Function(String line)? f) => FtpSession.debugLog = f;
|
||||
|
|
@ -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<void> 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<void>();
|
||||
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<void>.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<InternetAddress?> 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<Object?>((_) => null, onError: (Object x) => x);
|
||||
first.close();
|
||||
expect(e, isA<FtpCertException>().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<FtpException>()));
|
||||
}, 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<FtpException>().having((x) => x.kind, 'kind', FtpFailure.auth)));
|
||||
}, skip: hasPy ? false : '시험용 파이썬 없음');
|
||||
}
|
||||
Loading…
Reference in New Issue