390 lines
14 KiB
Dart
390 lines
14 KiB
Dart
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 = '''<?xml version="1.0" encoding="utf-8" ?>
|
|
<D:propfind xmlns:D="DAV:">
|
|
<D:prop>
|
|
<D:resourcetype/>
|
|
<D:getetag/>
|
|
</D:prop>
|
|
</D:propfind>''';
|
|
|
|
/// One `<D:response>` 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<WebDavEntry> 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<XmlElement> _byLocalName(XmlNode node, String localName) =>
|
|
node.descendants.whereType<XmlElement>().where((e) => e.name.local == localName);
|
|
|
|
class _RawResponse {
|
|
final int statusCode;
|
|
final String body;
|
|
final Map<String, String> 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<String, String>? 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<int>) 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<bool> 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<String> signIn() {
|
|
throw UnsupportedError(
|
|
'WebDAV needs a server URL and credentials — use signInWithCredentials instead.');
|
|
}
|
|
|
|
@override
|
|
Future<String> 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<String> _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<void> 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<List<WebDavEntry>> _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<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
|
|
final uri = _uriFor(parentId ?? 'root');
|
|
final selfPath = _normalizedPath(uri.path);
|
|
|
|
List<WebDavEntry> 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<String> 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<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
|
final fileUri = _childUri(_uriFor(folderId), name);
|
|
List<WebDavEntry> 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<List<int>> 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<CloudFileInfo> 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<void> 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<String> 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 <int>[],
|
|
);
|
|
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<List<CloudLockFile>> listLockFiles(String folderId) async {
|
|
final uri = _uriFor(folderId);
|
|
final selfPath = _normalizedPath(uri.path);
|
|
|
|
List<WebDavEntry> entries;
|
|
try {
|
|
entries = await _propfind(uri, depth: '1');
|
|
} on _WebDavNotFoundException {
|
|
return [];
|
|
}
|
|
|
|
final locks = <CloudLockFile>[];
|
|
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() {}
|
|
}
|