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.
402 lines
11 KiB
402 lines
11 KiB
2 years ago
|
part of '../../nextcloud.dart';
|
||
2 years ago
|
|
||
|
/// WebDavClient class
|
||
|
class WebDavClient {
|
||
|
// ignore: public_member_api_docs
|
||
|
WebDavClient(
|
||
|
this.baseUrl, {
|
||
|
this.basePath = '',
|
||
|
this.baseHeaders,
|
||
|
});
|
||
|
|
||
|
/// Base URL of the server
|
||
|
final String baseUrl;
|
||
|
|
||
|
/// Base path used on the server
|
||
|
final String basePath;
|
||
|
|
||
|
/// Headers added to each request. Useful for authentication
|
||
|
final Map<String, String>? baseHeaders;
|
||
|
|
||
|
/// XML namespaces supported by this client.
|
||
|
///
|
||
|
/// For Nextcloud namespaces see [WebDav/Requesting properties](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/basic.html#requesting-properties).
|
||
|
final Map<String, String> namespaces = {
|
||
|
'DAV:': 'd',
|
||
|
'http://owncloud.org/ns': 'oc',
|
||
|
'http://nextcloud.org/ns': 'nc',
|
||
|
'http://open-collaboration-services.org/ns': 'ocs',
|
||
|
'http://open-cloud-mesh.org/ns': 'ocm',
|
||
|
'http://sabredav.org/ns': 's', // mostly used in error responses
|
||
|
};
|
||
|
|
||
|
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',
|
||
|
if (baseHeaders != null) ...baseHeaders!,
|
||
|
if (headers != null) ...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 ApiException(
|
||
|
response.statusCode,
|
||
2 years ago
|
{}, // TODO
|
||
|
await response.bodyBytes,
|
||
2 years ago
|
);
|
||
|
}
|
||
|
|
||
|
return response;
|
||
|
}
|
||
|
|
||
|
/// Registers a custom namespace for properties.
|
||
|
///
|
||
|
/// Requires a unique [namespaceUri] and [prefix].
|
||
|
void registerNamespace(final String namespaceUri, final String prefix) =>
|
||
|
namespaces.putIfAbsent(namespaceUri, () => prefix);
|
||
|
|
||
|
String _constructPath([final String? path]) => [
|
||
|
baseUrl,
|
||
|
basePath,
|
||
|
if (path != null) ...[
|
||
|
path,
|
||
|
],
|
||
|
]
|
||
|
.map((part) {
|
||
|
while (part.startsWith('/')) {
|
||
|
part = part.substring(1);
|
||
|
}
|
||
|
while (part.endsWith('/')) {
|
||
|
part = part.substring(0, part.length - 1);
|
||
|
}
|
||
|
return part;
|
||
|
})
|
||
|
.where((final part) => part.isNotEmpty)
|
||
|
.join('/');
|
||
|
|
||
|
String _buildPropsRequest(final Set<String> props) {
|
||
2 years ago
|
final builder = xml.XmlBuilder();
|
||
2 years ago
|
builder
|
||
|
..processing('xml', 'version="1.0"')
|
||
|
..element(
|
||
|
'd:propfind',
|
||
|
nest: () {
|
||
|
namespaces.forEach(builder.namespace);
|
||
|
builder.element(
|
||
|
'd:prop',
|
||
|
nest: () {
|
||
|
props.forEach(builder.element);
|
||
|
},
|
||
|
);
|
||
|
},
|
||
|
);
|
||
|
return builder.buildDocument().toString();
|
||
|
}
|
||
|
|
||
|
/// 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 [remotePath] under current dir
|
||
|
Future<HttpClientResponse> mkdir(
|
||
|
final String remotePath, {
|
||
|
final bool safe = true,
|
||
|
}) async {
|
||
|
final expectedCodes = [
|
||
|
201,
|
||
|
if (safe) ...[
|
||
|
301,
|
||
|
405,
|
||
|
],
|
||
|
];
|
||
|
return _send(
|
||
|
'MKCOL',
|
||
|
_constructPath(remotePath),
|
||
|
expectedCodes,
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/// just like mkdir -p
|
||
2 years ago
|
Future<HttpClientResponse?> mkdirs(
|
||
2 years ago
|
final String remotePath, {
|
||
|
final bool safe = true,
|
||
|
}) async {
|
||
|
final dirs = remotePath.trim().split('/')..removeWhere((final value) => value == '');
|
||
|
if (dirs.isEmpty) {
|
||
2 years ago
|
return null;
|
||
2 years ago
|
}
|
||
|
if (remotePath.trim().startsWith('/')) {
|
||
|
dirs[0] = '/${dirs[0]}';
|
||
|
}
|
||
|
final prevPath = StringBuffer();
|
||
2 years ago
|
late HttpClientResponse response;
|
||
2 years ago
|
for (final dir in dirs) {
|
||
2 years ago
|
response = await mkdir(
|
||
2 years ago
|
'$prevPath/$dir',
|
||
|
safe: safe,
|
||
|
);
|
||
|
prevPath.write('/$dir');
|
||
|
}
|
||
2 years ago
|
|
||
|
return response;
|
||
2 years ago
|
}
|
||
|
|
||
|
/// remove dir with given [path]
|
||
|
Future<HttpClientResponse> delete(final String path) => _send(
|
||
|
'DELETE',
|
||
|
_constructPath(path),
|
||
|
[204],
|
||
|
);
|
||
|
|
||
|
/// upload a new file with [localData] as content to [remotePath]
|
||
|
Future<HttpClientResponse> upload(final Uint8List localData, final String remotePath) => _send(
|
||
|
'PUT',
|
||
|
_constructPath(remotePath),
|
||
|
[200, 201, 204],
|
||
|
data: Stream.value(localData),
|
||
|
);
|
||
|
|
||
|
/// upload a new file with [localData] as content to [remotePath]
|
||
|
Future<HttpClientResponse> uploadStream(final Stream<Uint8List> localData, final String remotePath) async => _send(
|
||
|
'PUT',
|
||
|
_constructPath(remotePath),
|
||
|
[200, 201, 204],
|
||
|
data: localData,
|
||
|
);
|
||
|
|
||
|
/// download [remotePath] and store the response file contents to String
|
||
|
Future<Uint8List> download(final String remotePath) async => Uint8List.fromList(
|
||
|
(await (await _send(
|
||
|
'GET',
|
||
|
_constructPath(remotePath),
|
||
|
[200],
|
||
|
))
|
||
|
.join())
|
||
|
.codeUnits,
|
||
|
);
|
||
|
|
||
|
/// download [remotePath] and store the response file contents to ByteStream
|
||
|
Future<HttpClientResponse> downloadStream(final String remotePath) async => _send(
|
||
|
'GET',
|
||
|
_constructPath(remotePath),
|
||
|
[200],
|
||
|
);
|
||
|
|
||
|
/// list the directories and files under given [remotePath].
|
||
|
///
|
||
|
/// Optionally populates the given [props] on the returned files.
|
||
|
Future<List<WebDavFile>> ls(
|
||
|
final String remotePath, {
|
||
|
final Set<String>? props,
|
||
|
}) async {
|
||
|
final response = await _send(
|
||
|
'PROPFIND',
|
||
|
_constructPath(remotePath),
|
||
|
[207, 301],
|
||
|
data: Stream.value(Uint8List.fromList(utf8.encode(_buildPropsRequest(props ?? {})))),
|
||
|
);
|
||
|
if (response.statusCode == 301) {
|
||
|
return ls(response.headers['location']!.first);
|
||
|
}
|
||
|
return treeFromWebDavXml(
|
||
|
basePath,
|
||
|
namespaces,
|
||
2 years ago
|
await response.body,
|
||
2 years ago
|
)..removeAt(0);
|
||
|
}
|
||
|
|
||
|
/// Runs the filter-files report with the given [propFilters] on the
|
||
|
/// [remotePath].
|
||
|
///
|
||
|
/// Optionally populates the given [props] on the returned files.
|
||
|
Future<List<WebDavFile>> filter(
|
||
|
final String remotePath,
|
||
|
final Map<String, String> propFilters, {
|
||
|
final Set<String> props = const {},
|
||
|
}) async {
|
||
2 years ago
|
final builder = xml.XmlBuilder();
|
||
2 years ago
|
builder
|
||
|
..processing('xml', 'version="1.0"')
|
||
|
..element(
|
||
|
'oc:filter-files',
|
||
|
nest: () {
|
||
|
namespaces.forEach(builder.namespace);
|
||
|
builder
|
||
|
..element(
|
||
|
'oc:filter-rules',
|
||
|
nest: () {
|
||
|
propFilters.forEach((final key, final value) {
|
||
|
builder.element(
|
||
|
key,
|
||
|
nest: () {
|
||
|
builder.text(value);
|
||
|
},
|
||
|
);
|
||
|
});
|
||
|
},
|
||
|
)
|
||
|
..element(
|
||
|
'd:prop',
|
||
|
nest: () {
|
||
|
props.forEach(builder.element);
|
||
|
},
|
||
|
);
|
||
|
},
|
||
|
);
|
||
|
final response = await _send(
|
||
|
'REPORT',
|
||
|
_constructPath(remotePath),
|
||
|
[200, 207],
|
||
|
data: Stream.value(Uint8List.fromList(utf8.encode(builder.buildDocument().toString()))),
|
||
|
);
|
||
|
return treeFromWebDavXml(
|
||
|
basePath,
|
||
|
namespaces,
|
||
2 years ago
|
await response.body,
|
||
2 years ago
|
);
|
||
|
}
|
||
|
|
||
|
/// Retrieves properties for the given [remotePath].
|
||
|
///
|
||
|
/// Populates all available properties by default, but a reduced set can be
|
||
|
/// specified via [props].
|
||
|
Future<WebDavFile> getProps(
|
||
|
final String remotePath, {
|
||
|
final Set<String>? props,
|
||
|
}) async {
|
||
|
final response = await _send(
|
||
|
'PROPFIND',
|
||
|
_constructPath(remotePath),
|
||
|
[200, 207],
|
||
|
data: Stream.value(Uint8List.fromList(utf8.encode(_buildPropsRequest(props ?? {})))),
|
||
|
headers: {'Depth': '0'},
|
||
|
);
|
||
|
return fileFromWebDavXml(
|
||
|
basePath,
|
||
|
namespaces,
|
||
2 years ago
|
await response.body,
|
||
2 years ago
|
);
|
||
|
}
|
||
|
|
||
|
/// Update (string) properties of the given [remotePath].
|
||
|
///
|
||
|
/// Returns true if the update was successful.
|
||
|
Future<bool> updateProps(
|
||
|
final String remotePath,
|
||
|
final Map<String, String> props,
|
||
|
) async {
|
||
2 years ago
|
final builder = xml.XmlBuilder();
|
||
2 years ago
|
builder
|
||
|
..processing('xml', 'version="1.0"')
|
||
|
..element(
|
||
|
'd:propertyupdate',
|
||
|
nest: () {
|
||
|
namespaces.forEach(builder.namespace);
|
||
|
builder.element(
|
||
|
'd:set',
|
||
|
nest: () {
|
||
|
builder.element(
|
||
|
'd:prop',
|
||
|
nest: () {
|
||
|
props.forEach((final key, final value) {
|
||
|
builder.element(
|
||
|
key,
|
||
|
nest: () {
|
||
|
builder.text(value);
|
||
|
},
|
||
|
);
|
||
|
});
|
||
|
},
|
||
|
);
|
||
|
},
|
||
|
);
|
||
|
},
|
||
|
);
|
||
|
final response = await _send(
|
||
|
'PROPPATCH',
|
||
|
_constructPath(remotePath),
|
||
|
[200, 207],
|
||
|
data: Stream.value(Uint8List.fromList(utf8.encode(builder.buildDocument().toString()))),
|
||
|
);
|
||
2 years ago
|
return checkUpdateFromWebDavXml(await response.body);
|
||
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;
|
||
|
}
|