import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:xml/xml.dart'; import '../l10n/tr.dart'; import 'remote_fs.dart'; import 'secret_gate.dart'; import 'remote_idle.dart'; /// WebDAV 서버 설정 (환경 설정 > 파일 탐색기 > WebDAV). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). class DavServer { final String id; final String name; /// 예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/me final String url; final String user; final String password; /// 인증서를 확인하지 않음 (자체 서명 인증서를 쓰는 집 NAS 등) final bool insecure; const DavServer({ required this.id, required this.name, required this.url, this.user = '', this.password = '', this.insecure = false, }); /// 탐색기 위쪽에 보일 이름 String get label => name.trim().isNotEmpty ? name.trim() : (Uri.tryParse(url)?.host ?? 'WebDAV'); DavServer copyWith({String? name, String? url, String? user, String? password, bool? insecure}) => DavServer( id: id, name: name ?? this.name, url: url ?? this.url, user: user ?? this.user, password: password ?? this.password, insecure: insecure ?? this.insecure, ); /// 비밀번호는 넣지 않는다 (안전 저장소에 둔다 - 54). 저장해 두었는지만 Map toJson() => {'id': id, 'name': name, 'url': url, 'user': user, 'hasPassword': password.isNotEmpty, 'insecure': insecure}; factory DavServer.fromJson(Map j) => DavServer( id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}', name: j['name'] as String? ?? '', url: j['url'] as String? ?? '', user: j['user'] as String? ?? '', // 예전 설정 파일의 평문 비밀번호 (읽으면 안전 저장소로 옮긴다) password: j['password'] as String? ?? '', insecure: j['insecure'] == true, ); } /// 목록의 한 항목 ([rel]: 서버 기준 경로, "/" 로 시작, 끝 / 없음 · 맨 위는 "/") class DavItem { final String rel; final bool isDir; final int size; final DateTime modified; const DavItem(this.rel, {required this.isDir, this.size = 0, required this.modified}); } /// 124 · 128: 마스터 비밀번호를 넣지 않아 저장된 비밀번호를 쓰지 못함 (원본을 못 읽은 것이 아니라 기다리는 중) class DavLockedException extends DavException { const DavLockedException(super.message); } class DavException implements Exception { final String message; final int? status; /// 서버가 옮기라고 한 주소 (301 · 302 · 307 · 308) final String? location; const DavException(this.message, {this.status, this.location}); /// http 주소의 서버가 https 로 옮기라고 했는지 bool get wantsHttps => location != null && location!.toLowerCase().startsWith('https://'); @override String toString() => message; } /// WebDAV 클라이언트: PROPFIND (목록) · GET · PUT · MKCOL · DELETE · MOVE · COPY. 기본 인증 (Basic). class DavClient implements RemoteClient { final DavServer server; late final HttpClient _http = HttpClient() ..connectionTimeout = const Duration(seconds: 15) ..badCertificateCallback = server.insecure ? (_, _, _) => true : null; DavClient(this.server); late final Uri _base = () { final u = Uri.parse(server.url.trim()); final path = u.path.endsWith('/') ? u.path.substring(0, u.path.length - 1) : u.path; return u.replace(path: path); }(); /// 서버 기준 경로 → 주소 (각 부분을 인코딩) Uri uriOf(String rel, {bool dir = false}) { final parts = rel.split('/').where((x) => x.isNotEmpty).map(Uri.encodeComponent); final tail = parts.join('/'); final path = '${_base.path}/$tail${dir && tail.isNotEmpty ? '/' : ''}'; return _base.replace(path: path); } /// Authorization 헤더 값 (아이디 · 비밀번호가 없으면 null). 플레이어가 스트리밍할 때도 쓴다. String? get authHeader => _auth; String? get _auth => server.user.isEmpty && server.password.isEmpty ? null : 'Basic ${base64Encode(utf8.encode('${server.user}:${server.password}'))}'; /// 관리자 판단 (10/20): 답 (머리) 을 정한 시간 ([RemoteIdle]) 까지만 기다린다 - 서버가 멈추면 그 연결을 끊고 "서버가 답하지 않습니다". /// 답의 몸은 [idleRead] 로 (조각 사이) Future _send(HttpClientRequest req) => idleCall(req.close(), onTimeout: req.abort); Future _open(String method, Uri uri) async { // 124: 저장된 비밀번호로 접속하기 전에 (마스터 비밀번호를 정해 두었으면 묻는다) if (server.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage)); final req = await _http.openUrl(method, uri); final a = _auth; if (a != null) req.headers.set(HttpHeaders.authorizationHeader, a); req.followRedirects = false; return req; } Never _fail(String what, HttpClientResponse res, [String body = '']) { // 다른 주소로 옮기라고 함 (http → https 등): 이유와 할 일을 알린다 if (const [301, 302, 303, 307, 308].contains(res.statusCode)) { final loc = res.headers.value(HttpHeaders.locationHeader); final https = loc != null && loc.toLowerCase().startsWith('https://') && server.url.toLowerCase().startsWith('http://'); throw DavException( https ? trf('서버가 https 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 https:// 로 바꾸세요.', [loc]) : trf('서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.', [loc ?? '-']), status: res.statusCode, location: loc, ); } final hint = switch (res.statusCode) { // 54: 비밀번호가 틀린 것이 아니라 저장된 것이 없다 (동기화 · 복사 오류에도 그대로 보인다) 401 when server.password.isEmpty => ' (${tr('저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요')})', 401 => ' (${tr('아이디 · 비밀번호를 확인하세요')})', 403 => ' (${tr('권한 없음')})', 404 => ' (${tr('없는 경로')})', 405 => ' (${tr('서버가 이 기능을 허용하지 않음')})', 507 => ' (${tr('서버 저장 공간 부족')})', _ => '', }; // 상태 번호 앞의 빈칸은 그대로 둔다 (explainFileError 가 " 401" 등으로 오류 종류를 가른다) throw DavException(trf('WebDAV {0} 실패: {1} {2}{3}', [what, res.statusCode, res.reasonPhrase, hint]), status: res.statusCode); } static const _propfindBody = '' ''; Future _propfind(String rel, {required int depth, String body = _propfindBody}) async { final req = await _open('PROPFIND', uriOf(rel, dir: depth > 0)); req.headers ..set('Depth', '$depth') ..contentType = ContentType('application', 'xml', charset: 'utf-8'); // 길이를 꼭 알린다 (chunked 로 보내면 404 등에서 본문을 읽지 않는 서버가 남은 본문을 다음 요청으로 오해함) final bytes = utf8.encode(body); req.contentLength = bytes.length; req.add(bytes); final res = await _send(req); final text = await idleRead(res).transform(utf8.decoder).join(); if (res.statusCode == 404) throw DavException(trf('없는 경로: {0}', [rel]), status: 404); if (res.statusCode != 207 && res.statusCode != 200) _fail(tr('목록'), res, text); return text; } /// 응답의 href → 서버 기준 경로 String _relOf(String href) { var path = Uri.tryParse(href)?.path ?? href; path = Uri.decodeFull(path); final base = Uri.decodeFull(_base.path); if (path.startsWith(base)) path = path.substring(base.length); path = path.replaceAll(RegExp(r'/+'), '/'); if (path.length > 1 && path.endsWith('/')) path = path.substring(0, path.length - 1); return path.isEmpty ? '/' : (path.startsWith('/') ? path : '/$path'); } List _parse(String xml) { final doc = XmlDocument.parse(xml); final out = []; for (final r in doc.findAllElements('response', namespaceUri: 'DAV:')) { final href = r.findElements('href', namespaceUri: 'DAV:').firstOrNull?.innerText.trim(); if (href == null) continue; // 200 인 propstat 만 XmlElement? prop; for (final ps in r.findElements('propstat', namespaceUri: 'DAV:')) { final st = ps.findElements('status', namespaceUri: 'DAV:').firstOrNull?.innerText ?? ''; if (st.contains(' 200')) prop = ps.findElements('prop', namespaceUri: 'DAV:').firstOrNull; } prop ??= r.findAllElements('prop', namespaceUri: 'DAV:').firstOrNull; final isDir = prop?.findAllElements('collection', namespaceUri: 'DAV:').isNotEmpty ?? href.endsWith('/'); final len = int.tryParse(prop?.findElements('getcontentlength', namespaceUri: 'DAV:').firstOrNull?.innerText.trim() ?? ''); final mod = prop?.findElements('getlastmodified', namespaceUri: 'DAV:').firstOrNull?.innerText.trim(); DateTime when; try { when = mod == null || mod.isEmpty ? DateTime.fromMillisecondsSinceEpoch(0) : HttpDate.parse(mod).toLocal(); } catch (_) { when = DateTime.fromMillisecondsSinceEpoch(0); } out.add(DavItem(_relOf(href), isDir: isDir, size: isDir ? 0 : (len ?? 0), modified: when)); } return out; } /// 폴더 안의 항목 (자기 자신은 뺌) @override Future> list(String rel) async { final self = _norm(rel); final items = _parse(await _propfind(self, depth: 1)); return [for (final i in items) if (i.rel != self) i]; } /// 항목 정보 (없으면 null) @override Future stat(String rel) async { try { final items = _parse(await _propfind(_norm(rel), depth: 0)); return items.isEmpty ? null : items.first; } on DavException catch (e) { if (e.status == 404) return null; rethrow; } } /// 폴더 만들기 (이미 있으면 그대로) Future mkdir(String rel) async { final res = await _send(await _open('MKCOL', uriOf(rel, dir: true))); await idleRead(res).drain(); if (res.statusCode == 201 || res.statusCode == 200) return; if (res.statusCode == 405 && (await stat(rel))?.isDir == true) return; // 이미 있음 _fail(tr('폴더 만들기'), res); } /// 위 폴더까지 차례로 만든다 @override Future mkdirs(String rel) async { var cur = ''; for (final part in rel.split('/').where((x) => x.isNotEmpty)) { cur = '$cur/$part'; if ((await stat(cur))?.isDir == true) continue; await mkdir(cur); } } @override Future delete(String rel) async { final st = await stat(rel); final res = await _send(await _open('DELETE', uriOf(rel, dir: st?.isDir ?? false))); await idleRead(res).drain(); if (res.statusCode == 404) return; if (res.statusCode >= 300) _fail(tr('지우기'), res); } Future _moveOrCopy(String method, String from, String to, {bool overwrite = false}) async { final st = await stat(from); final req = await _open(method, uriOf(from, dir: st?.isDir ?? false)); req.headers ..set('Destination', uriOf(to, dir: st?.isDir ?? false).toString()) ..set('Overwrite', overwrite ? 'T' : 'F'); if (st?.isDir ?? false) req.headers.set('Depth', 'infinity'); final res = await _send(req); await idleRead(res).drain(); if (res.statusCode >= 300) _fail(method == 'MOVE' ? tr('이동') : tr('복사'), res); } @override Future move(String from, String to, {bool overwrite = false}) => _moveOrCopy('MOVE', from, to, overwrite: overwrite); Future copy(String from, String to, {bool overwrite = false}) => _moveOrCopy('COPY', from, to, overwrite: overwrite); /// 받기: 내용을 조각으로 (끝나면 스트림이 닫힘) @override Future>> openRead(String rel) async { // 관리자 판단 (10/20): 답 (머리) 과 조각 사이를 정한 시간까지만 기다린다 - 서버가 멈추면 연결을 끊고 실패 final req = await _open('GET', uriOf(rel)); final res = await _send(req); if (res.statusCode != 200) { await res.drain(); _fail(tr('받기'), res); } return idleRead(res); } /// 올리기 ([length] 를 알면 Content-Length 로) @override Future write(String rel, Stream> data, {int? length}) async { final req = await _open('PUT', uriOf(rel)); if (length != null) req.contentLength = length; // 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하거나 다 보낸 뒤 답이 없으면 실패 (연결을 끊음) final res = await idleWrite(data, (x) async { await req.addStream(x); return req.close(); }, onTimeout: req.abort); await res.drain(); if (res.statusCode >= 300) _fail(tr('올리기'), res); } /// 남은 용량 · 쓴 용량 (바이트, 서버가 알려 주지 않으면 null) @override Future<(int?, int?)> quota() async { try { final xml = await _propfind('/', depth: 0, body: '' ''); final doc = XmlDocument.parse(xml); int? n(String name) => int.tryParse(doc.findAllElements(name, namespaceUri: 'DAV:').firstOrNull?.innerText.trim() ?? ''); return (n('quota-available-bytes'), n('quota-used-bytes')); } catch (_) { return (null, null); } } @override void close() => _http.close(force: true); static String _norm(String rel) { var r = rel.replaceAll('\\', '/').replaceAll(RegExp(r'/+'), '/'); if (!r.startsWith('/')) r = '/$r'; if (r.length > 1 && r.endsWith('/')) r = r.substring(0, r.length - 1); return r; } } /// 설정의 WebDAV 서버들 (id → 클라이언트). 설정이 바뀌면 [configure]. class DavRegistry { static final _clients = {}; static final _servers = {}; static void configure(List servers) { final keep = {for (final s in servers) s.id: s}; for (final id in _clients.keys.toList()) { final old = _servers[id], now = keep[id]; // 비밀번호는 toJson 에 없으므로 따로 비교 (바꾸면 바로 새 비밀번호로 접속 - 40-2) if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) { _clients.remove(id)?.close(); } } _servers ..clear() ..addAll(keep); } static List get servers => _servers.values.toList(); static DavServer? server(String id) => _servers[id]; static DavClient client(String id) { final s = _servers[id]; if (s == null) throw DavException(trf('WebDAV 서버 설정이 없습니다 ({0})', [id])); return _clients.putIfAbsent(id, () => DavClient(s)); } }