|
|
|
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;
|
|
|
|
|
|
|
|
/// Base path used on the server
|
|
|
|
const String webdavBasePath = '/remote.php/webdav';
|
|
|
|
|
|
|
|
/// WebDavClient class
|
|
|
|
class WebDavClient {
|
|
|
|
// ignore: public_member_api_docs
|
|
|
|
WebDavClient(this.rootClient);
|
|
|
|
|
|
|
|
// ignore: public_member_api_docs
|
|
|
|
final DynamiteClient rootClient;
|
|
|
|
|
|
|
|
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,
|
|
|
|
if (headers != null) ...headers,
|
|
|
|
if (rootClient.authentications.isNotEmpty) ...rootClient.authentications.first.headers,
|
|
|
|
}.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),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return response;
|
|
|
|
}
|
|
|
|
|
|
|
|
String _constructPath([final String? path]) => [
|
|
|
|
rootClient.baseURL,
|
|
|
|
webdavBasePath,
|
|
|
|
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
|
|
|
|
}
|
|
|
|
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
|
|
|
|
Future<HttpClientResponse> mkdir(
|
|
|
|
final String path, {
|
|
|
|
final bool safe = true,
|
|
|
|
}) async {
|
|
|
|
final expectedCodes = [
|
|
|
|
201,
|
|
|
|
if (safe) ...[
|
|
|
|
301,
|
|
|
|
405,
|
|
|
|
],
|
|
|
|
];
|
|
|
|
return _send(
|
|
|
|
'MKCOL',
|
|
|
|
_constructPath(path),
|
|
|
|
expectedCodes,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/// just like mkdir -p
|
|
|
|
Future<HttpClientResponse?> mkdirs(
|
|
|
|
final String path, {
|
|
|
|
final bool safe = true,
|
|
|
|
}) async {
|
|
|
|
final dirs = path.trim().split('/')..removeWhere((final value) => value == '');
|
|
|
|
if (dirs.isEmpty) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
if (path.trim().startsWith('/')) {
|
|
|
|
dirs[0] = '/${dirs[0]}'; // coverage:ignore-line
|
|
|
|
}
|
|
|
|
final prevPath = StringBuffer();
|
|
|
|
late HttpClientResponse response;
|
|
|
|
for (final dir in dirs) {
|
|
|
|
response = await mkdir(
|
|
|
|
'$prevPath/$dir',
|
|
|
|
safe: safe,
|
|
|
|
);
|
|
|
|
prevPath.write('/$dir');
|
|
|
|
}
|
|
|
|
|
|
|
|
return response;
|
|
|
|
}
|
|
|
|
|
|
|
|
/// 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,
|
|
|
|
);
|
|
|
|
|
|
|
|
/// 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(
|
|
|
|
'PUT',
|
|
|
|
_constructPath(path),
|
|
|
|
[200, 201, 204],
|
|
|
|
data: localData,
|
|
|
|
headers: _generateUploadHeaders(
|
|
|
|
lastModified: lastModified,
|
|
|
|
created: created,
|
|
|
|
contentLength: contentLength,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
|
|
|
|
/// download [path] and store the response file contents to String
|
|
|
|
Future<Uint8List> download(final String path) async => (await downloadStream(path)).bodyBytes;
|
|
|
|
|
|
|
|
/// download [path] and store the response file contents to ByteStream
|
|
|
|
Future<HttpClientResponse> downloadStream(final String path) async => _send(
|
|
|
|
'GET',
|
|
|
|
_constructPath(path),
|
|
|
|
[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].
|
|
|
|
///
|
|
|
|
/// 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,
|
|
|
|
}) async {
|
|
|
|
assert(depth == null || ['0', '1', 'infinity'].contains(depth), 'Depth has to be 0, 1 or infinity');
|
|
|
|
final response = await _send(
|
|
|
|
'PROPFIND',
|
|
|
|
_constructPath(path),
|
|
|
|
[207, 301],
|
|
|
|
data: Stream.value(
|
|
|
|
Uint8List.fromList(
|
|
|
|
utf8.encode(
|
|
|
|
WebDavPropfind(prop: prop ?? WebDavPropfindProp()).toXmlElement(namespaces: namespaces).toXmlString(),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
headers: {
|
|
|
|
if (depth != null) ...{
|
|
|
|
'Depth': depth,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
);
|
|
|
|
if (response.statusCode == 301) {
|
|
|
|
// coverage:ignore-start
|
|
|
|
return ls(
|
|
|
|
response.headers['location']!.first,
|
|
|
|
prop: prop,
|
|
|
|
depth: depth,
|
|
|
|
);
|
|
|
|
// coverage:ignore-end
|
|
|
|
}
|
|
|
|
return _parseResponse(response);
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Runs the filter-files report with the given [filterRules] on the
|
|
|
|
/// [path].
|
|
|
|
///
|
|
|
|
/// Optionally populates the given [prop]s on the returned files.
|
|
|
|
Future<WebDavMultistatus> filter(
|
|
|
|
final String path,
|
|
|
|
final WebDavOcFilterRules filterRules, {
|
|
|
|
final WebDavPropfindProp? prop,
|
|
|
|
}) async {
|
|
|
|
final response = await _send(
|
|
|
|
'REPORT',
|
|
|
|
_constructPath(path),
|
|
|
|
[200, 207],
|
|
|
|
data: Stream.value(
|
|
|
|
Uint8List.fromList(
|
|
|
|
utf8.encode(
|
|
|
|
WebDavOcFilterFiles(
|
|
|
|
filterRules: filterRules,
|
|
|
|
prop: prop ?? WebDavPropfindProp(), // coverage:ignore-line
|
|
|
|
).toXmlElement(namespaces: namespaces).toXmlString(),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
return _parseResponse(response);
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Update (string) properties of the given [path].
|
|
|
|
///
|
|
|
|
/// Returns true if the update was successful.
|
|
|
|
Future<bool> updateProps(
|
|
|
|
final String path,
|
|
|
|
final WebDavProp prop,
|
|
|
|
) async {
|
|
|
|
final response = await _send(
|
|
|
|
'PROPPATCH',
|
|
|
|
_constructPath(path),
|
|
|
|
[200, 207],
|
|
|
|
data: Stream.value(
|
|
|
|
Uint8List.fromList(
|
|
|
|
utf8.encode(
|
|
|
|
WebDavPropertyupdate(set: WebDavSet(prop: prop)).toXmlElement(namespaces: namespaces).toXmlString(),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
|
|
|
/// 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;
|
|
|
|
}
|