import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:xml/xml.dart'; class Response { Response(this.statusCode, this.headers, this.body); bool get isGood => _isHttpStatusGood(statusCode); @override String toString() { return "{" "status: $statusCode, " "headers: ..., " "body: ..., " "}"; } final int statusCode; final Map headers; /// Content of the response body, String if isResponseString == true during /// request, Uint8List otherwise final dynamic body; } class Api { Api(this._account); _Files files() => _Files(this); static String getAuthorizationHeaderValue(Account account) { final auth = base64.encode(utf8.encode("${account.username}:${account.password}")); return "Basic $auth"; } Future request( String method, String endpoint, { Map header, String body, Uint8List bodyBytes, bool isResponseString = true, }) async { final url = _makeUri(endpoint); final req = http.Request(method, url) ..headers.addAll({ "authorization": getAuthorizationHeaderValue(_account), }); if (header != null) { // turn all to lower case, since HTTP headers are case-insensitive, this // smooths our processing (if any) req.headers.addEntries( header.entries.map((e) => MapEntry(e.key.toLowerCase(), e.value))); } if (body != null) { if (!req.headers.containsKey("content-type")) { req.headers["content-type"] = "application/xml"; } req.body = body; } else if (bodyBytes != null) { if (!req.headers.containsKey("content-type")) { req.headers["content-type"] = "application/octet-stream"; } req.bodyBytes = bodyBytes; } final response = await http.Response.fromStream(await http.Client().send(req)); if (!_isHttpStatusGood(response.statusCode)) { if (response.statusCode == 404) { _log.severe( "[request] HTTP $method (${response.statusCode}): $endpoint", ); } else { _log.severe( "[request] HTTP $method (${response.statusCode}): $endpoint", response.body, ); } } return Response(response.statusCode, response.headers, isResponseString ? response.body : response.bodyBytes); } Uri _makeUri(String endpoint) { final splits = _account.address.split("/"); final authority = splits[0]; final path = splits.length > 1 ? splits.sublist(1).join("/") + "/$endpoint" : endpoint; if (_account.scheme == "http") { return Uri.http(authority, path); } else { return Uri.https(authority, path); } } final Account _account; static final _log = Logger("api.api.Api"); } bool _isHttpStatusGood(int status) => status ~/ 100 == 2; class _Files { _Files(this._api); Api _api; Future delete({ @required String path, }) async { try { return await _api.request("DELETE", path); } catch (e) { _log.severe("[delete] Failed while delete", e); rethrow; } } Future get({ @required String path, }) async { try { return await _api.request("GET", path, isResponseString: false); } catch (e) { _log.severe("[get] Failed while get", e); rethrow; } } Future put({ @required String path, String mime = "application/octet-stream", Uint8List content, }) async { try { return await _api.request( "PUT", path, header: { "content-type": mime, }, bodyBytes: content, ); } catch (e) { _log.severe("[put] Failed while put", e); rethrow; } } Future propfind({ @required String path, int depth, getlastmodified, getetag, getcontenttype, resourcetype, getcontentlength, id, fileid, favorite, commentsHref, commentsCount, commentsUnread, ownerId, ownerDisplayName, shareTypes, checksums, hasPreview, size, richWorkspace, Map customNamespaces, List customProperties, }) async { try { final bool hasDavNs = (getlastmodified != null || getetag != null || getcontenttype != null || resourcetype != null || getcontentlength != null); final bool hasOcNs = (id != null || fileid != null || favorite != null || commentsHref != null || commentsCount != null || commentsUnread != null || ownerId != null || ownerDisplayName != null || shareTypes != null || checksums != null || size != null); final bool hasNcNs = (hasPreview != null || richWorkspace != null); if (!hasDavNs && !hasOcNs && !hasNcNs) { // no body return await _api.request("PROPFIND", path); } final namespaces = { "DAV:": "d", if (hasOcNs) "http://owncloud.org/ns": "oc", if (hasNcNs) "http://nextcloud.org/ns": "nc", }..addAll(customNamespaces ?? {}); final builder = XmlBuilder(); builder ..processing("xml", "version=\"1.0\"") ..element("d:propfind", namespaces: namespaces, nest: () { builder.element("d:prop", nest: () { if (getlastmodified != null) { builder.element("d:getlastmodified"); } if (getetag != null) { builder.element("d:getetag"); } if (getcontenttype != null) { builder.element("d:getcontenttype"); } if (resourcetype != null) { builder.element("d:resourcetype"); } if (getcontentlength != null) { builder.element("d:getcontentlength"); } if (id != null) { builder.element("oc:id"); } if (fileid != null) { builder.element("oc:fileid"); } if (favorite != null) { builder.element("oc:favorite"); } if (commentsHref != null) { builder.element("oc:comments-href"); } if (commentsCount != null) { builder.element("oc:comments-count"); } if (commentsUnread != null) { builder.element("oc:comments-unread"); } if (ownerId != null) { builder.element("oc:owner-id"); } if (ownerDisplayName != null) { builder.element("oc:owner-display-name"); } if (shareTypes != null) { builder.element("oc:share-types"); } if (checksums != null) { builder.element("oc:checksums"); } if (size != null) { builder.element("oc:size"); } if (hasPreview != null) { builder.element("nc:has-preview"); } if (richWorkspace != null) { builder.element("nc:rich-workspace"); } for (final p in customProperties ?? []) { builder.element(p); } }); }); return await _api.request("PROPFIND", path, header: { if (depth != null) "Depth": depth.toString(), }, body: builder.buildDocument().toXmlString()); } catch (e) { _log.severe("[propfind] Failed while propfind", e); rethrow; } } /// Set or remove custom properties /// /// [namespaces] should be specified in the format {"URI": "prefix"}, eg, /// {"DAV:": "d"} Future proppatch({ @required String path, Map namespaces, Map set, List remove, }) async { try { final ns = { "DAV:": "d", }..addAll(namespaces ?? {}); final builder = XmlBuilder(); builder ..processing("xml", "version=\"1.0\"") ..element("d:propertyupdate", namespaces: ns, nest: () { if (set?.isNotEmpty == true) { builder.element("d:set", nest: () { builder.element("d:prop", nest: () { for (final e in set.entries) { builder.element("${e.key}", nest: () { builder.text("${e.value}"); }); } }); }); } if (remove?.isNotEmpty == true) { builder.element("d:remove", nest: () { builder.element("d:prop", nest: () { for (final e in remove) { builder.element("$e"); } }); }); } }); return await _api.request("PROPPATCH", path, body: builder.buildDocument().toXmlString()); } catch (e) { _log.severe("[proppatch] Failed while proppatch", e); rethrow; } } /// A folder can be created by sending a MKCOL request to the folder Future mkcol({ @required String path, }) async { try { return await _api.request("MKCOL", path); } catch (e) { _log.severe("[mkcol] Failed while get", e); rethrow; } } /// A file or folder can be copied by sending a COPY request to the file or /// folder and specifying the [destinationUrl] as full url Future copy({ @required String path, @required String destinationUrl, bool overwrite, }) async { try { return await _api.request("COPY", path, header: { "Destination": destinationUrl, if (overwrite != null) "Overwrite": overwrite ? "T" : "F", }); } catch (e) { _log.severe("[copy] Failed while delete", e); rethrow; } } /// A file or folder can be moved by sending a MOVE request to the file or /// folder and specifying the [destinationUrl] as full url Future move({ @required String path, @required String destinationUrl, bool overwrite, }) async { try { return await _api.request("MOVE", path, header: { "Destination": destinationUrl, if (overwrite != null) "Overwrite": overwrite ? "T" : "F", }); } catch (e) { _log.severe("[move] Failed while delete", e); rethrow; } } static final _log = Logger("api.api._Files"); }