import 'dart:convert'; import 'dart:io'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import 'package:xml/xml.dart'; import 'cloud_storage_provider.dart'; const _secureStorageKeyServerUrl = 'webdav_server_url'; const _secureStorageKeyUsername = 'webdav_username'; const _secureStorageKeyPassword = 'webdav_password'; const _propfindRequestBody = ''' '''; /// One `` entry from a WebDAV PROPFIND multistatus response. /// Not private, and [parseWebDavMultistatus] is a free function, purely so /// the XML parsing can be unit-tested directly against sample responses /// from different server implementations — real WebDAV servers vary in /// namespace prefix (Nextcloud uses `d:`, others `D:` or none at all), /// which is exactly the kind of real-world format variance this app has /// been burned by before with format-specific assumptions. class WebDavEntry { final String path; final bool isCollection; final String? etag; WebDavEntry({required this.path, required this.isCollection, required this.etag}); } /// Parses a WebDAV PROPFIND multistatus XML body into [WebDavEntry]s, with /// each entry's href resolved to an absolute path against [baseUrl]. /// Matches elements by local name only (ignoring namespace prefix), since /// that's the part that varies across server implementations. List parseWebDavMultistatus(String xmlBody, Uri baseUrl) { final document = XmlDocument.parse(xmlBody); return _byLocalName(document, 'response').map((responseEl) { final href = _byLocalName(responseEl, 'href').first.innerText; final isCollection = _byLocalName(responseEl, 'collection').isNotEmpty; final etagEls = _byLocalName(responseEl, 'getetag').toList(); return WebDavEntry( path: baseUrl.resolve(href).path, isCollection: isCollection, etag: etagEls.isEmpty ? null : etagEls.first.innerText, ); }).toList(); } Iterable _byLocalName(XmlNode node, String localName) => node.descendants.whereType().where((e) => e.name.local == localName); class _RawResponse { final int statusCode; final String body; final Map headers; _RawResponse({required this.statusCode, required this.body, required this.headers}); } /// Sends a request with an arbitrary HTTP method (PROPFIND, MKCOL) that /// `package:http`'s GET/PUT/DELETE convenience functions don't support. Future<_RawResponse> _send(String method, Uri uri, {Map? headers, Object? body}) async { final client = http.Client(); try { final request = http.Request(method, uri); if (headers != null) request.headers.addAll(headers); if (body is String) request.body = body; if (body is List) request.bodyBytes = body; final streamed = await client.send(request); final responseBody = await streamed.stream.bytesToString(); return _RawResponse(statusCode: streamed.statusCode, body: responseBody, headers: streamed.headers); } finally { client.close(); } } String _basicAuthHeader(String username, String password) => 'Basic ${base64Encode(utf8.encode('$username:$password'))}'; String _normalizedPath(String path) => path.endsWith('/') ? path.substring(0, path.length - 1) : path; String _nameFromPath(String path) { final segments = _normalizedPath(path).split('/').where((s) => s.isNotEmpty); return segments.isEmpty ? '' : Uri.decodeComponent(segments.last); } /// WebDAV implementation of [CloudStorageProvider], for self-hosted /// personal cloud servers (Nextcloud, ownCloud, a Synology NAS, or any /// generic WebDAV server) rather than a named commercial provider. Unlike /// the OAuth-based providers, there's no browser sign-in flow and no /// developer-console app to register ahead of time — the user supplies a /// server URL, username, and password (an app-specific password is /// recommended on servers that support one, e.g. Nextcloud's Security /// settings) directly via [signInWithCredentials]. class WebDavProvider implements CloudStorageProvider, ManualCredentialCloudStorageProvider { final FlutterSecureStorage _secureStorage; Uri? _baseUrl; String? _username; String? _password; WebDavProvider({FlutterSecureStorage? secureStorage}) : _secureStorage = secureStorage ?? const FlutterSecureStorage(); @override CloudProviderId get id => CloudProviderId.webdav; @override String get displayName => 'WebDAV'; @override bool get isSignedIn => _baseUrl != null; @override String? get accountLabel => isSignedIn ? '$_username@${_baseUrl!.host}' : null; /// Restores a session from credentials saved on a previous /// [signInWithCredentials] call (OS-encrypted storage — Keystore on /// Android, Keychain on iOS), re-verifying them with the same PROPFIND /// check rather than trusting them blindly, since the server config or /// password could have changed since. Any failure here — wrong/expired /// credentials, no network, nothing stored yet — just means "not signed /// in"; this never throws; a real error from a user-initiated attempt /// belongs to [signInWithCredentials], not this silent path. @override Future attemptSilentSignIn() async { try { final serverUrl = await _secureStorage.read(key: _secureStorageKeyServerUrl); final username = await _secureStorage.read(key: _secureStorageKeyUsername); final password = await _secureStorage.read(key: _secureStorageKeyPassword); if (serverUrl == null || username == null || password == null) return false; await _verifiedSignIn(serverUrl: serverUrl, username: username, password: password); return true; } catch (_) { return false; } } @override Future signIn() { throw UnsupportedError( 'WebDAV needs a server URL and credentials — use signInWithCredentials instead.'); } @override Future signInWithCredentials({ required String serverUrl, required String username, required String password, }) async { final label = await _verifiedSignIn(serverUrl: serverUrl, username: username, password: password); await _secureStorage.write(key: _secureStorageKeyServerUrl, value: _baseUrl!.toString()); await _secureStorage.write(key: _secureStorageKeyUsername, value: username); await _secureStorage.write(key: _secureStorageKeyPassword, value: password); return label; } /// Shared by [signInWithCredentials] and [attemptSilentSignIn]: normalizes /// the URL, does a side-effect-free PROPFIND on the root to confirm the /// URL and credentials actually work, and — only once that's confirmed — /// sets this instance's session fields. Future _verifiedSignIn({ required String serverUrl, required String username, required String password, }) async { var normalized = serverUrl.trim(); if (!normalized.contains('://')) normalized = 'https://$normalized'; if (!normalized.endsWith('/')) normalized += '/'; final baseUrl = Uri.parse(normalized); final response = await _send('PROPFIND', baseUrl, headers: { 'Authorization': _basicAuthHeader(username, password), 'Depth': '0', 'Content-Type': 'application/xml; charset=utf-8', }, body: _propfindRequestBody); if (response.statusCode == 401) { throw StateError('WebDAV sign-in failed: invalid username or password.'); } if (response.statusCode != 207 && response.statusCode != 200) { throw StateError('WebDAV sign-in failed: ${response.statusCode} ${response.body}'); } _baseUrl = baseUrl; _username = username; _password = password; return accountLabel!; } @override Future signOut() async { _baseUrl = null; _username = null; _password = null; await _secureStorage.delete(key: _secureStorageKeyServerUrl); await _secureStorage.delete(key: _secureStorageKeyUsername); await _secureStorage.delete(key: _secureStorageKeyPassword); } @override CloudStorageSession beginSession() { if (!isSignedIn) throw CloudNotAuthorizedException(displayName); return WebDavSession(this); } String get _authHeader => _basicAuthHeader(_username!, _password!); } class _WebDavNotFoundException implements Exception {} class WebDavSession implements CloudStorageSession { final WebDavProvider _provider; WebDavSession(this._provider); @override bool get supportsSharedWithMe => false; Uri _uriFor(String id) => id == 'root' ? _provider._baseUrl! : _provider._baseUrl!.replace(path: id); Uri _childUri(Uri parent, String name) { final parentUri = parent.path.endsWith('/') ? parent : parent.replace(path: '${parent.path}/'); return parentUri.resolve(Uri.encodeComponent(name)); } Future> _propfind(Uri uri, {required String depth}) async { final response = await _send('PROPFIND', uri, headers: { 'Authorization': _provider._authHeader, 'Depth': depth, 'Content-Type': 'application/xml; charset=utf-8', }, body: _propfindRequestBody); if (response.statusCode == 404) throw _WebDavNotFoundException(); if (response.statusCode != 207) { throw StateError('WebDAV PROPFIND failed: ${response.statusCode} ${response.body}'); } return parseWebDavMultistatus(response.body, _provider._baseUrl!); } @override Future> listFolders({String? parentId, bool sharedWithMe = false}) async { final uri = _uriFor(parentId ?? 'root'); final selfPath = _normalizedPath(uri.path); List entries; try { entries = await _propfind(uri, depth: '1'); } on _WebDavNotFoundException { return []; } return entries .where((e) => e.isCollection && _normalizedPath(e.path) != selfPath) .map((e) => CloudFolder(id: e.path, name: _nameFromPath(e.path))) .toList(); } @override Future findOrCreateFolder({required String parentId, required String name}) async { final childUri = _childUri(_uriFor(parentId), name); try { final entries = await _propfind(childUri, depth: '0'); if (entries.isNotEmpty && entries.first.isCollection) return childUri.path; } on _WebDavNotFoundException { // Falls through to create it below. } final response = await _send('MKCOL', childUri, headers: {'Authorization': _provider._authHeader}); if (response.statusCode != 200 && response.statusCode != 201) { throw StateError('WebDAV MKCOL failed: ${response.statusCode} ${response.body}'); } return childUri.path; } @override Future moveFolder({required String folderId, required String newParentId}) async { final name = _nameFromPath(folderId); final destination = _childUri(_uriFor(newParentId), name); final response = await _send('MOVE', _uriFor(folderId), headers: { 'Authorization': _provider._authHeader, 'Destination': destination.toString(), 'Overwrite': 'F', }); if (response.statusCode != 201 && response.statusCode != 204) { throw StateError('WebDAV MOVE failed: ${response.statusCode} ${response.body}'); } return destination.path; } @override Future findFile({required String folderId, required String name}) async { final fileUri = _childUri(_uriFor(folderId), name); List entries; try { entries = await _propfind(fileUri, depth: '0'); } on _WebDavNotFoundException { return null; } if (entries.isEmpty) return null; return CloudFileInfo(id: fileUri.path, versionTag: entries.first.etag); } @override Future> downloadFileBytes(String fileId) async { final response = await http.get(_uriFor(fileId), headers: {'Authorization': _provider._authHeader}); if (response.statusCode != 200) { throw StateError('WebDAV download failed: ${response.statusCode} ${response.body}'); } return response.bodyBytes; } @override Future uploadFile({ required String folderId, required String name, String? existingFileId, required File localFile, required String contentType, }) async { final uri = existingFileId != null ? _uriFor(existingFileId) : _childUri(_uriFor(folderId), name); final bytes = await localFile.readAsBytes(); final response = await http.put( uri, headers: {'Authorization': _provider._authHeader, 'Content-Type': contentType}, body: bytes, ); if (response.statusCode != 200 && response.statusCode != 201 && response.statusCode != 204) { throw StateError('WebDAV upload failed: ${response.statusCode} ${response.body}'); } // Many servers return the new ETag directly on the PUT response; fall // back to a follow-up PROPFIND only if it's missing. var etag = response.headers['etag']; if (etag == null) { try { final entries = await _propfind(uri, depth: '0'); etag = entries.isEmpty ? null : entries.first.etag; } on _WebDavNotFoundException { etag = null; } } return CloudFileInfo(id: uri.path, versionTag: etag); } @override Future deleteFile(String fileId) async { final response = await http.delete(_uriFor(fileId), headers: {'Authorization': _provider._authHeader}); if (response.statusCode != 200 && response.statusCode != 204 && response.statusCode != 404) { throw StateError('WebDAV delete failed: ${response.statusCode} ${response.body}'); } } @override Future createLockFile({required String folderId, required String name}) async { final uri = _childUri(_uriFor(folderId), name); final response = await http.put( uri, headers: {'Authorization': _provider._authHeader, 'Content-Type': 'text/plain'}, body: const [], ); if (response.statusCode != 200 && response.statusCode != 201 && response.statusCode != 204) { throw StateError('WebDAV lock creation failed: ${response.statusCode} ${response.body}'); } return uri.path; } @override Future> listLockFiles(String folderId) async { final uri = _uriFor(folderId); final selfPath = _normalizedPath(uri.path); List entries; try { entries = await _propfind(uri, depth: '1'); } on _WebDavNotFoundException { return []; } final locks = []; for (final entry in entries) { if (_normalizedPath(entry.path) == selfPath) continue; final parsed = parseLockFileName(_nameFromPath(entry.path)); if (parsed != null) { locks.add(CloudLockFile(id: entry.path, username: parsed.$1, createdAtUtc: parsed.$2)); } } return locks; } @override void close() {} }