353 lines
14 KiB
Dart
353 lines
14 KiB
Dart
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));
|
|
}
|
|
}
|