JJ_MKVMaker/lib/core/webdav.dart

404 lines
17 KiB
Dart

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<String, Object?> toJson() =>
{'id': id, 'name': name, 'url': url, 'user': user, 'hasPassword': password.isNotEmpty, 'insecure': insecure};
factory DavServer.fromJson(Map<Object?, Object?> 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).
/// [s] 의 앞 [start] 바이트를 건너뛰고 [length] 바이트 (null 이면 끝까지)
Stream<List<int>> skipBytes(Stream<List<int>> s, int start, int? length) async* {
var skip = start;
var left = length;
await for (var chunk in s) {
if (skip > 0) {
if (chunk.length <= skip) {
skip -= chunk.length;
continue;
}
chunk = chunk.sublist(skip);
skip = 0;
}
if (left != null) {
if (left <= 0) break;
if (chunk.length > left) chunk = chunk.sublist(0, left);
left -= chunk.length;
}
yield chunk;
if (left != null && left <= 0) break;
}
}
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<HttpClientResponse> _send(HttpClientRequest req) => idleCall(req.close(), onTimeout: req.abort);
Future<HttpClientRequest> _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 = '<?xml version="1.0" encoding="utf-8"?>'
'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getcontentlength/><d:getlastmodified/></d:prop></d:propfind>';
Future<String> _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<DavItem> _parse(String xml) {
final doc = XmlDocument.parse(xml);
final out = <DavItem>[];
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<DavItem>> 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<DavItem?> 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<void> mkdir(String rel) async {
final res = await _send(await _open('MKCOL', uriOf(rel, dir: true)));
await idleRead(res).drain<void>();
if (res.statusCode == 201 || res.statusCode == 200) return;
if (res.statusCode == 405 && (await stat(rel))?.isDir == true) return; // 이미 있음
_fail(tr('폴더 만들기'), res);
}
/// 위 폴더까지 차례로 만든다
@override
Future<void> 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<void> 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<void>();
if (res.statusCode == 404) return;
if (res.statusCode >= 300) _fail(tr('지우기'), res);
}
Future<void> _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<void>();
if (res.statusCode >= 300) _fail(method == 'MOVE' ? tr('이동') : tr('복사'), res);
}
@override
Future<void> move(String from, String to, {bool overwrite = false}) => _moveOrCopy('MOVE', from, to, overwrite: overwrite);
Future<void> copy(String from, String to, {bool overwrite = false}) => _moveOrCopy('COPY', from, to, overwrite: overwrite);
/// 받기: 내용을 조각으로 (끝나면 스트림이 닫힘)
@override
Future<Stream<List<int>>> 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<void>();
_fail(tr('받기'), res);
}
return idleRead(res);
}
/// X-plore 비교 5-1 ② (관리자 지시 10/21): [offset] 부터 [length] 바이트 (null 이면 끝까지) - 원격 썸네일이 동영상의 앞 · 뒤 일부만 읽게
/// (스트리밍 다리의 Range). 서버가 Range 를 모르면 (200) 처음부터 받으며 건너뛴다
Future<Stream<List<int>>> openReadFrom(String rel, {int offset = 0, int? length}) async {
if (offset == 0 && length == null) return openRead(rel);
final req = await _open('GET', uriOf(rel));
req.headers.set(HttpHeaders.rangeHeader, 'bytes=$offset-${length == null ? '' : offset + length - 1}');
final res = await _send(req);
if (res.statusCode == 206) return idleRead(res);
if (res.statusCode != 200) {
await res.drain<void>();
_fail(tr('받기'), res);
}
return skipBytes(idleRead(res), offset, length);
}
/// 올리기 ([length] 를 알면 Content-Length 로)
@override
Future<void> write(String rel, Stream<List<int>> 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<void>();
if (res.statusCode >= 300) _fail(tr('올리기'), res);
}
/// 남은 용량 · 쓴 용량 (바이트, 서버가 알려 주지 않으면 null)
@override
Future<(int?, int?)> quota() async {
try {
final xml = await _propfind('/', depth: 0,
body: '<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop>'
'<d:quota-available-bytes/><d:quota-used-bytes/></d:prop></d:propfind>');
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 = <String, DavClient>{};
static final _servers = <String, DavServer>{};
static void configure(List<DavServer> 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<DavServer> 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));
}
}