110 lines
4.5 KiB
Dart
110 lines
4.5 KiB
Dart
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);
|