Remote sync of webdav
This commit is contained in:
parent
f01186df79
commit
483fbeafb6
44 changed files with 4842 additions and 975 deletions
390
lib/services/cloud/webdav_provider.dart
Normal file
390
lib/services/cloud/webdav_provider.dart
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
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() {}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue