All checks were successful
CI / Windows build (push) Successful in 12m32s
Two-way sync of the vault folder to a user-configured WebDAV server, so annotations (which travel with the file) sync with the file. - WebDavSyncService.syncNow: per-file decision — local-only uploads, remote-only downloads, and when BOTH sides changed since the last sync it keeps the loser as <file>.conflict-<mtime> on both sides (last-write-wins by mtime) so no data is ever lost. Creates dirs as needed; deletes are conservative. - The decision logic is pure and unit-tested against a fake WebDAV client; the real client is a thin http adapter (no dio dependency). - Settings: WebDAV URL / user / password / remote folder, Test connection, Sync now (with status + last-synced), and an auto-sync toggle (default OFF). Real server round-trips are device/server-validated. Credentials are in SharedPreferences for now (TODO secure-storage). analyze clean, 432 tests.
349 lines
12 KiB
Dart
349 lines
12 KiB
Dart
// 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<RemoteEntry>> list(String remoteDir);
|
|
|
|
/// Download the bytes of the remote file at [remotePath].
|
|
Future<Uint8List> download(String remotePath);
|
|
|
|
/// Upload [bytes] to [remotePath], creating/overwriting the remote file.
|
|
/// Parent collections must already exist (use [makeCollection]).
|
|
Future<void> upload(String remotePath, Uint8List bytes);
|
|
|
|
/// Create the collection (directory) at [remotePath]. Idempotent: an
|
|
/// already-existing collection is not an error.
|
|
Future<void> makeCollection(String remotePath);
|
|
|
|
/// Probe connectivity + credentials cheaply (PROPFIND Depth:0 on the root).
|
|
/// Throws [WebDavException] on failure; returns normally on success.
|
|
Future<void> 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<String, String> get _headers => {'Authorization': _authHeader};
|
|
|
|
@override
|
|
Future<void> 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<RemoteEntry>> 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<Uint8List> 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<void> 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<void> 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<RemoteEntry> _parseMultiStatus(String body) {
|
|
final doc = XmlDocument.parse(body);
|
|
final basePath = _baseUri.path; // ends with '/'
|
|
final entries = <RemoteEntry>[];
|
|
|
|
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<http.Response> _send(
|
|
String method,
|
|
Uri uri, {
|
|
Map<String, String>? 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<http.Response> _get(Uri uri) async {
|
|
try {
|
|
return await _client.get(uri, headers: _headers).timeout(timeout);
|
|
} catch (e) {
|
|
throw WebDavException('Network error: $e');
|
|
}
|
|
}
|
|
|
|
Future<http.Response> _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);
|
|
}
|