// lib/services/webdav_client.dart // // A minimal WebDAV client abstraction for vault sync. The [WebDavClient] // interface is intentionally tiny (the four verbs the sync algorithm needs: // list / download / upload / mkcol) so that: // * the sync ALGORITHM in WebDavSyncService can be unit-tested against a // FAKE in-memory implementation (no real server), and // * the real network adapter ([HttpWebDavClient]) stays a thin shim over // `package:http` + `package:xml` (PROPFIND/GET/PUT/MKCOL). // // Paths handled here are REMOTE paths relative to the configured remote root, // using forward slashes (e.g. `Lecture/Lecture.pdf`). Mapping vault file paths // to/from these remote paths lives in WebDavSyncService. import 'dart:convert'; import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:xml/xml.dart'; /// One remote resource returned by a directory listing (PROPFIND). class RemoteEntry { const RemoteEntry({ required this.path, required this.isDirectory, this.modified, this.size, this.etag, }); /// Remote path RELATIVE to the configured remote root, forward-slashed and /// WITHOUT a leading slash, e.g. `Lecture/Lecture.pdf`. Directories carry no /// trailing slash here (normalized by the client). final String path; /// Whether this entry is a collection (directory) rather than a file. final bool isDirectory; /// Server last-modified time (UTC) if the server reported one. final DateTime? modified; /// Content length in bytes if reported (files only). final int? size; /// Weak/strong ETag if reported (quotes stripped). final String? etag; } /// Thrown by [WebDavClient] implementations for any transport/protocol error. /// Carries a human-readable [message] suitable for surfacing in the UI. class WebDavException implements Exception { WebDavException(this.message, {this.statusCode}); final String message; final int? statusCode; @override String toString() => 'WebDavException($message' '${statusCode != null ? ', status: $statusCode' : ''})'; } /// The four WebDAV operations the sync algorithm depends on. Inject a fake in /// tests; inject [HttpWebDavClient] in production. abstract class WebDavClient { /// List the immediate-and-nested files under [remoteDir] (relative to the /// remote root, `''` meaning the root itself). Returns every FILE found in /// the subtree (directories are created on demand via [makeCollection], so /// callers care about files). Implementations PROPFIND with Depth: infinity /// and flatten the result. A missing remote dir yields an empty list. Future> list(String remoteDir); /// Download the bytes of the remote file at [remotePath]. Future download(String remotePath); /// Upload [bytes] to [remotePath], creating/overwriting the remote file. /// Parent collections must already exist (use [makeCollection]). Future upload(String remotePath, Uint8List bytes); /// Create the collection (directory) at [remotePath]. Idempotent: an /// already-existing collection is not an error. Future makeCollection(String remotePath); /// Probe connectivity + credentials cheaply (PROPFIND Depth:0 on the root). /// Throws [WebDavException] on failure; returns normally on success. Future testConnection(); } /// Real WebDAV adapter over `package:http`. Thin by design — all the sync /// decision logic lives in WebDavSyncService, NOT here. /// /// DEVICE/SERVER-VALIDATED ONLY: this class performs real network round-trips /// and is not exercised in CI (no WebDAV server). The XML/path plumbing below /// is best-effort against common servers (Nextcloud, Apache mod_dav). The sync /// algorithm that consumes it is what the unit tests cover, via a fake client. class HttpWebDavClient implements WebDavClient { HttpWebDavClient({ required String baseUrl, required String username, required String password, String remoteRoot = '', http.Client? httpClient, this.timeout = const Duration(seconds: 30), }) : _client = httpClient ?? http.Client(), _ownsClient = httpClient == null, _baseUri = _normalizeBase(baseUrl, remoteRoot), _authHeader = 'Basic ${base64Encode(utf8.encode('$username:$password'))}'; final http.Client _client; final bool _ownsClient; /// Absolute base URI INCLUDING the remote root path, always ending in `/`. final Uri _baseUri; final String _authHeader; final Duration timeout; /// Combine the server [baseUrl] with the [remoteRoot] folder into a single /// absolute base URI ending in a slash. Tolerates trailing/leading slashes. static Uri _normalizeBase(String baseUrl, String remoteRoot) { var base = baseUrl.trim(); if (!base.endsWith('/')) base = '$base/'; var uri = Uri.parse(base); final root = remoteRoot.trim().replaceAll(RegExp(r'^/+|/+$'), ''); if (root.isNotEmpty) { uri = uri.resolve('${Uri.encodeFull(root)}/'); } return uri; } /// Resolve a remote-root-relative [remotePath] to an absolute URI. Uri _resolve(String remotePath) { final clean = remotePath.replaceAll(RegExp(r'^/+'), ''); if (clean.isEmpty) return _baseUri; // Encode each segment but keep the slashes. final encoded = clean.split('/').map(Uri.encodeComponent).join('/'); return _baseUri.resolve(encoded); } Map get _headers => {'Authorization': _authHeader}; @override Future testConnection() async { final res = await _send('PROPFIND', _baseUri, headers: {'Depth': '0'}); if (res.statusCode < 200 || res.statusCode >= 300) { throw WebDavException( 'Server responded ${res.statusCode}', statusCode: res.statusCode, ); } } @override Future> list(String remoteDir) async { final uri = _resolve(remoteDir.endsWith('/') ? remoteDir : '$remoteDir/'); final http.Response res; try { res = await _send('PROPFIND', uri, headers: {'Depth': 'infinity'}); } on WebDavException { rethrow; } if (res.statusCode == 404) return const []; if (res.statusCode < 200 || res.statusCode >= 300) { throw WebDavException( 'PROPFIND failed (${res.statusCode})', statusCode: res.statusCode, ); } return _parseMultiStatus(res.body); } @override Future download(String remotePath) async { final res = await _get(_resolve(remotePath)); if (res.statusCode < 200 || res.statusCode >= 300) { throw WebDavException( 'Download failed (${res.statusCode})', statusCode: res.statusCode, ); } return res.bodyBytes; } @override Future upload(String remotePath, Uint8List bytes) async { final res = await _put(_resolve(remotePath), bytes); if (res.statusCode < 200 || res.statusCode >= 300) { throw WebDavException( 'Upload failed (${res.statusCode})', statusCode: res.statusCode, ); } } @override Future makeCollection(String remotePath) async { final uri = _resolve(remotePath.endsWith('/') ? remotePath : '$remotePath/'); final res = await _send('MKCOL', uri); // 201 created; 405 method-not-allowed means it already exists (fine). if (res.statusCode == 201 || res.statusCode == 405) return; if (res.statusCode < 200 || res.statusCode >= 300) { throw WebDavException( 'MKCOL failed (${res.statusCode})', statusCode: res.statusCode, ); } } /// Parse a WebDAV multistatus (PROPFIND) body into FILE entries, dropping /// collections. Paths are made relative to [_baseUri]'s path and stripped of /// a leading slash. List _parseMultiStatus(String body) { final doc = XmlDocument.parse(body); final basePath = _baseUri.path; // ends with '/' final entries = []; for (final response in doc.findAllElements('response', namespace: '*')) { final href = response .findElements('href', namespace: '*') .map((e) => e.innerText.trim()) .firstWhere((_) => true, orElse: () => ''); if (href.isEmpty) continue; // href may be absolute (http://host/dav/Lecture/x.pdf) or root-relative // (/dav/Lecture/x.pdf). Reduce to the server path, then strip basePath. var hrefPath = Uri.parse(href).path; hrefPath = Uri.decodeFull(hrefPath); final decodedBase = Uri.decodeFull(basePath); if (!hrefPath.startsWith(decodedBase)) { // Some servers omit the app prefix; try a looser suffix match. final idx = hrefPath.indexOf(decodedBase); if (idx < 0) continue; hrefPath = hrefPath.substring(idx); } var rel = hrefPath.substring(decodedBase.length); final isDir = rel.endsWith('/'); rel = rel.replaceAll(RegExp(r'^/+|/+$'), ''); if (rel.isEmpty) continue; // the root collection itself final propstat = response.findElements('propstat', namespace: '*'); DateTime? modified; int? size; String? etag; var collection = isDir; for (final ps in propstat) { for (final prop in ps.findElements('prop', namespace: '*')) { final lm = prop .findElements('getlastmodified', namespace: '*') .map((e) => e.innerText.trim()) .firstWhere((_) => true, orElse: () => ''); if (lm.isNotEmpty) modified = _parseHttpDate(lm); final cl = prop .findElements('getcontentlength', namespace: '*') .map((e) => e.innerText.trim()) .firstWhere((_) => true, orElse: () => ''); if (cl.isNotEmpty) size = int.tryParse(cl); final et = prop .findElements('getetag', namespace: '*') .map((e) => e.innerText.trim()) .firstWhere((_) => true, orElse: () => ''); if (et.isNotEmpty) etag = et.replaceAll('"', ''); if (prop.findAllElements('collection', namespace: '*').isNotEmpty) { collection = true; } } } if (collection) continue; // sync only cares about files entries.add(RemoteEntry( path: rel, isDirectory: false, modified: modified?.toUtc(), size: size, etag: etag, )); } return entries; } static DateTime? _parseHttpDate(String s) { try { return parseHttpDate(s); } catch (_) { return null; } } Future _send( String method, Uri uri, { Map? headers, }) async { final req = http.Request(method, uri)..headers.addAll(_headers); if (headers != null) req.headers.addAll(headers); try { final streamed = await _client.send(req).timeout(timeout); return http.Response.fromStream(streamed); } on WebDavException { rethrow; } catch (e) { throw WebDavException('Network error: $e'); } } Future _get(Uri uri) async { try { return await _client.get(uri, headers: _headers).timeout(timeout); } catch (e) { throw WebDavException('Network error: $e'); } } Future _put(Uri uri, Uint8List bytes) async { try { return await _client .put(uri, headers: _headers, body: bytes) .timeout(timeout); } catch (e) { throw WebDavException('Network error: $e'); } } /// Release the underlying [http.Client] if this instance created it. void close() { if (_ownsClient) _client.close(); } } /// Parse an RFC 1123 / RFC 850 / asctime HTTP-date into UTC. Kept local (rather /// than pulling `http_parser`) since only `getlastmodified` needs it. DateTime? parseHttpDate(String input) { final s = input.trim(); // RFC 1123: "Sun, 06 Nov 1994 08:49:37 GMT" final months = { 'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6, 'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12, }; final m = RegExp( r'(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})', ).firstMatch(s); if (m == null) return null; final day = int.parse(m.group(1)!); final month = months[m.group(2)!]; if (month == null) return null; final year = int.parse(m.group(3)!); final hour = int.parse(m.group(4)!); final min = int.parse(m.group(5)!); final sec = int.parse(m.group(6)!); return DateTime.utc(year, month, day, hour, min, sec); }