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 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() {}
}