A framework for building convergent cross-platform Nextcloud clients using Flutter.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

356 lines
9.7 KiB

import 'dart:convert';
import 'dart:typed_data';
import 'package:dynamite_runtime/http_client.dart';
import 'package:nextcloud/src/webdav/props.dart';
import 'package:nextcloud/src/webdav/webdav.dart';
import 'package:universal_io/io.dart';
import 'package:xml/xml.dart' as xml;
2 years ago
/// Base path used on the server
const String webdavBasePath = '/remote.php/webdav';
2 years ago
/// WebDavClient class
class WebDavClient {
// ignore: public_member_api_docs
WebDavClient(this.rootClient);
2 years ago
// ignore: public_member_api_docs
final DynamiteClient rootClient;
2 years ago
Future<HttpClientResponse> _send(
final String method,
final String url,
final List<int> expectedCodes, {
final Stream<Uint8List>? data,
final Map<String, String>? headers,
}) async {
final request = await HttpClient().openUrl(
method,
Uri.parse(url),
)
..followRedirects = false
..persistentConnection = true;
for (final header in {
HttpHeaders.contentTypeHeader: 'application/xml',
...?rootClient.baseHeaders,
2 years ago
if (headers != null) ...headers,
if (rootClient.authentications.isNotEmpty) ...rootClient.authentications.first.headers,
2 years ago
}.entries) {
request.headers.add(header.key, header.value);
}
if (data != null) {
await request.addStream(data);
}
final response = await request.close();
if (!expectedCodes.contains(response.statusCode)) {
throw DynamiteApiException(
response.statusCode,
response.responseHeaders,
utf8.decode(await response.bodyBytes),
);
2 years ago
}
return response;
}
String _constructPath([final String? path]) => [
rootClient.baseURL,
webdavBasePath,
2 years ago
if (path != null) ...[
path,
],
]
.map((part) {
while (part.startsWith('/')) {
part = part.substring(1);
}
while (part.endsWith('/')) {
part = part.substring(0, part.length - 1); // coverage:ignore-line
2 years ago
}
return part;
})
.where((final part) => part.isNotEmpty)
.join('/');
/// returns the WebDAV capabilities of the server
Future<WebDavStatus> status() async {
final response = await _send(
'OPTIONS',
_constructPath(),
[200],
);
final davCapabilities = response.headers['dav']?.cast<String>().first ?? '';
final davSearchCapabilities = response.headers['dasl']?.cast<String>().first ?? '';
return WebDavStatus(
davCapabilities.split(',').map((final e) => e.trim()).where((final e) => e.isNotEmpty).toSet(),
davSearchCapabilities.split(',').map((final e) => e.trim()).where((final e) => e.isNotEmpty).toSet(),
);
}
/// make a dir with [path] under current dir
2 years ago
Future<HttpClientResponse> mkdir(
final String path, {
2 years ago
final bool safe = true,
}) async {
final expectedCodes = [
201,
if (safe) ...[
301,
405,
],
];
return _send(
'MKCOL',
_constructPath(path),
2 years ago
expectedCodes,
);
}
/// just like mkdir -p
Future<HttpClientResponse?> mkdirs(
final String path, {
2 years ago
final bool safe = true,
}) async {
final dirs = path.trim().split('/')..removeWhere((final value) => value == '');
2 years ago
if (dirs.isEmpty) {
return null;
2 years ago
}
if (path.trim().startsWith('/')) {
dirs[0] = '/${dirs[0]}'; // coverage:ignore-line
2 years ago
}
final prevPath = StringBuffer();
late HttpClientResponse response;
2 years ago
for (final dir in dirs) {
response = await mkdir(
2 years ago
'$prevPath/$dir',
safe: safe,
);
prevPath.write('/$dir');
}
return response;
2 years ago
}
/// remove dir with given [path]
Future<HttpClientResponse> delete(final String path) => _send(
'DELETE',
_constructPath(path),
[204],
);
Map<String, String>? _generateUploadHeaders({
required final DateTime? lastModified,
required final DateTime? created,
required final int? contentLength,
}) {
final headers = <String, String>{
if (lastModified != null) ...{
'X-OC-Mtime': (lastModified.millisecondsSinceEpoch ~/ 1000).toString(),
},
if (created != null) ...{
'X-OC-CTime': (created.millisecondsSinceEpoch ~/ 1000).toString(),
},
if (contentLength != null) ...{
'Content-Length': contentLength.toString(),
},
};
return headers.isNotEmpty ? headers : null;
}
/// upload a new file with [localData] as content to [path]
Future<HttpClientResponse> upload(
final Uint8List localData,
final String path, {
final DateTime? lastModified,
final DateTime? created,
}) =>
uploadStream(
Stream.value(localData),
path,
lastModified: lastModified,
created: created,
contentLength: localData.lengthInBytes,
2 years ago
);
/// upload a new file with [localData] as content to [path]
Future<HttpClientResponse> uploadStream(
final Stream<Uint8List> localData,
final String path, {
final DateTime? lastModified,
final DateTime? created,
final int? contentLength,
}) async =>
_send(
2 years ago
'PUT',
_constructPath(path),
2 years ago
[200, 201, 204],
data: localData,
headers: _generateUploadHeaders(
lastModified: lastModified,
created: created,
contentLength: contentLength,
),
2 years ago
);
/// download [path] and store the response file contents to String
Future<Uint8List> download(final String path) async => (await downloadStream(path)).bodyBytes;
2 years ago
/// download [path] and store the response file contents to ByteStream
Future<HttpClientResponse> downloadStream(final String path) async => _send(
2 years ago
'GET',
_constructPath(path),
2 years ago
[200],
);
Future<WebDavMultistatus> _parseResponse(final HttpClientResponse response) async =>
WebDavMultistatus.fromXmlElement(xml.XmlDocument.parse(await response.body).rootElement);
/// list the directories and files under given [path].
2 years ago
///
/// Optionally populates the given [prop]s on the returned files.
/// [depth] can be '0', '1' or 'infinity'.
Future<WebDavMultistatus> ls(
final String path, {
final WebDavPropfindProp? prop,
final String? depth,
2 years ago
}) async {
assert(depth == null || ['0', '1', 'infinity'].contains(depth), 'Depth has to be 0, 1 or infinity');
2 years ago
final response = await _send(
'PROPFIND',
_constructPath(path),
2 years ago
[207, 301],
data: Stream.value(
Uint8List.fromList(
utf8.encode(
WebDavPropfind(prop: prop ?? WebDavPropfindProp()).toXmlElement(namespaces: namespaces).toXmlString(),
),
),
),
headers: {
if (depth != null) ...{
'Depth': depth,
},
},
2 years ago
);
if (response.statusCode == 301) {
// coverage:ignore-start
return ls(
response.headers['location']!.first,
prop: prop,
depth: depth,
);
// coverage:ignore-end
2 years ago
}
return _parseResponse(response);
2 years ago
}
/// Runs the filter-files report with the given [filterRules] on the
/// [path].
2 years ago
///
/// Optionally populates the given [prop]s on the returned files.
Future<WebDavMultistatus> filter(
final String path,
final WebDavOcFilterRules filterRules, {
final WebDavPropfindProp? prop,
2 years ago
}) async {
final response = await _send(
'REPORT',
_constructPath(path),
2 years ago
[200, 207],
data: Stream.value(
Uint8List.fromList(
utf8.encode(
WebDavOcFilterFiles(
filterRules: filterRules,
prop: prop ?? WebDavPropfindProp(), // coverage:ignore-line
).toXmlElement(namespaces: namespaces).toXmlString(),
),
),
),
2 years ago
);
return _parseResponse(response);
2 years ago
}
/// Update (string) properties of the given [path].
2 years ago
///
/// Returns true if the update was successful.
Future<bool> updateProps(
final String path,
final WebDavProp prop,
2 years ago
) async {
final response = await _send(
'PROPPATCH',
_constructPath(path),
2 years ago
[200, 207],
data: Stream.value(
Uint8List.fromList(
utf8.encode(
WebDavPropertyupdate(set: WebDavSet(prop: prop)).toXmlElement(namespaces: namespaces).toXmlString(),
),
),
),
2 years ago
);
final data = await _parseResponse(response);
for (final a in data.responses) {
for (final b in a.propstats) {
if (!b.status.contains('200')) {
return false;
}
}
}
return true;
2 years ago
}
/// Move a file from [sourcePath] to [destinationPath]
Future<HttpClientResponse> move(
final String sourcePath,
final String destinationPath, {
final bool overwrite = false,
}) =>
_send(
'MOVE',
_constructPath(sourcePath),
[200, 201, 204],
headers: {
'Destination': _constructPath(destinationPath),
'Overwrite': overwrite ? 'T' : 'F',
},
);
/// Copy a file from [sourcePath] to [destinationPath]
Future<HttpClientResponse> copy(
final String sourcePath,
final String destinationPath, {
final bool overwrite = false,
}) =>
_send(
'COPY',
_constructPath(sourcePath),
[200, 201, 204],
headers: {
'Destination': _constructPath(destinationPath),
'Overwrite': overwrite ? 'T' : 'F',
},
);
}
/// WebDAV server status.
class WebDavStatus {
/// Creates a new WebDavStatus.
WebDavStatus(
this.capabilities,
this.searchCapabilities,
);
/// DAV capabilities as advertised by the server in the 'dav' header.
Set<String> capabilities;
/// DAV search and locating capabilities as advertised by the server in the
/// 'dasl' header.
Set<String> searchCapabilities;
}