// // WebDAVFileProvider.swift // FileProvider // // Created by Amir Abbas Mousavian. // Copyright © 2016 Mousavian. Distributed under MIT license. // import Foundation #if os(macOS) || os(iOS) || os(tvOS) import CoreGraphics #endif /** Allows accessing to WebDAV server files. This provider doesn't cache or save files internally, however you can set `useCache` and `cache` properties to use Foundation `NSURLCache` system. WebDAV system supported by many cloud services including [Box.com](https://www.box.com/home) and [Yandex disk](https://disk.yandex.com) and [ownCloud](https://owncloud.org). - Important: Because this class uses `URLSession`, it's necessary to disable App Transport Security in case of using this class with unencrypted HTTP connection. [Read this to know how](http://iosdevtips.co/post/121756573323/ios-9-xcode-7-http-connect-server-error). */ open class WebDAVFileProvider: HTTPFileProvider, FileProviderSharing { override open class var type: String { return "WebDAV" } /// An enum which defines HTTP Authentication method, usually you should it default `.digest`. /// If the server uses OAuth authentication, credential must be set with token as `password`, like Dropbox. public var credentialType: URLRequest.AuthenticationType = .digest /** Initializes WebDAV provider. - Parameters: - baseURL: Location of WebDAV server. - credential: An `URLCredential` object with `user` and `password`. - cache: A URLCache to cache downloaded files and contents. */ public init? (baseURL: URL, credential: URLCredential?, cache: URLCache? = nil) { if !["http", "https"].contains(baseURL.uw_scheme.lowercased()) { return nil } let refinedBaseURL = (baseURL.absoluteString.hasSuffix("/") ? baseURL : baseURL.appendingPathComponent("")) super.init(baseURL: refinedBaseURL.absoluteURL, credential: credential, cache: cache) } public required convenience init?(coder aDecoder: NSCoder) { guard let baseURL = aDecoder.decodeObject(of: NSURL.self, forKey: "baseURL") as URL? else { if #available(macOS 10.11, iOS 9.0, tvOS 9.0, *) { aDecoder.failWithError(CocoaError(.coderValueNotFound, userInfo: [NSLocalizedDescriptionKey: "Base URL is not set."])) } return nil } self.init(baseURL: baseURL, credential: aDecoder.decodeObject(of: URLCredential.self, forKey: "credential")) self.useCache = aDecoder.decodeBool(forKey: "useCache") self.validatingCache = aDecoder.decodeBool(forKey: "validatingCache") } override open func copy(with zone: NSZone? = nil) -> Any { let copy = WebDAVFileProvider(baseURL: self.baseURL!, credential: self.credential, cache: self.cache)! copy.delegate = self.delegate copy.fileOperationDelegate = self.fileOperationDelegate copy.useCache = self.useCache copy.validatingCache = self.validatingCache return copy } /** Returns an Array of `FileObject`s identifying the the directory entries via asynchronous completion handler. If the directory contains no entries or an error is occured, this method will return the empty array. - Parameters: - path: path to target directory. If empty, root will be iterated. - completionHandler: a closure with result of directory entries or error. - contents: An array of `FileObject` identifying the the directory entries. - error: Error returned by system. */ override open func contentsOfDirectory(path: String, completionHandler: @escaping (([FileObject], Error?) -> Void)) { let query = NSPredicate(format: "TRUEPREDICATE") _ = searchFiles(path: path, recursive: false, query: query, including: [], foundItemHandler: nil, completionHandler: completionHandler) } /** Returns an Array of `FileObject`s identifying the the directory entries via asynchronous completion handler. If the directory contains no entries or an error is occured, this method will return the empty array. - Parameter path: path to target directory. If empty, root will be iterated. - Parameter including: An array which determines which file properties should be considered to fetch. - Parameter completionHandler: a closure with result of directory entries or error. - Parameter contents: An array of `FileObject` identifying the the directory entries. - Parameter error: Error returned by system. */ open func contentsOfDirectory(path: String, including: [URLResourceKey], completionHandler: @escaping (_ contents: [FileObject], _ error: Error?) -> Void) { let query = NSPredicate(format: "TRUEPREDICATE") _ = searchFiles(path: path, recursive: false, query: query, including: including, foundItemHandler: nil, completionHandler: completionHandler) } override open func attributesOfItem(path: String, completionHandler: @escaping (_ attributes: FileObject?, _ error: Error?) -> Void) { self.attributesOfItem(path: path, including: [], completionHandler: completionHandler) } /** Returns a `FileObject` containing the attributes of the item (file, directory, symlink, etc.) at the path in question via asynchronous completion handler. If the directory contains no entries or an error is occured, this method will return the empty `FileObject`. - Parameters: - path: path to target directory. If empty, attributes of root will be returned. - completionHandler: a closure with result of directory entries or error. - attributes: A `FileObject` containing the attributes of the item. - error: Error returned by system. */ open func attributesOfItem(path: String, including: [URLResourceKey], completionHandler: @escaping (_ attributes: FileObject?, _ error: Error?) -> Void) { let url = self.url(of: path) var request = URLRequest(url: url) request.httpMethod = "PROPFIND" request.setValue("0", forHTTPHeaderField: "Depth") request.setValue(authentication: credential, with: credentialType) request.setValue(contentType: .xml, charset: .utf8) request.httpBody = WebDavFileObject.xmlProp(including) runDataTask(with: request, completionHandler: { (data, response, error) in var responseError: FileProviderHTTPError? if let code = (response as? HTTPURLResponse)?.statusCode, code >= 300, let rCode = FileProviderHTTPErrorCode(rawValue: code) { responseError = self.serverError(with: rCode, path: path, data: data) } if let data = data { let xresponse = DavResponse.parse(xmlResponse: data, baseURL: self.baseURL) if let attr = xresponse.first { completionHandler(WebDavFileObject(attr), responseError ?? error) return } } completionHandler(nil, responseError ?? error) }) } /// Returns volume/provider information asynchronously. /// - Parameter volumeInfo: Information of filesystem/Provider returned by system/server. override open func storageProperties(completionHandler: @escaping (_ volumeInfo: VolumeObject?) -> Void) { // Not all WebDAV clients implements RFC2518 which allows geting storage quota. // In this case you won't get error. totalSize is NSURLSessionTransferSizeUnknown // and used space is zero. guard let baseURL = baseURL else { return } var request = URLRequest(url: baseURL) request.httpMethod = "PROPFIND" request.setValue("0", forHTTPHeaderField: "Depth") request.setValue(authentication: credential, with: credentialType) request.setValue(contentType: .xml, charset: .utf8) request.httpBody = WebDavFileObject.xmlProp([.volumeTotalCapacityKey, .volumeAvailableCapacityKey, .creationDateKey]) runDataTask(with: request, completionHandler: { (data, response, error) in guard let data = data, let attr = DavResponse.parse(xmlResponse: data, baseURL: self.baseURL).first else { completionHandler(nil) return } let volume = VolumeObject(allValues: [:]) volume.creationDate = attr.prop["creationdate"].flatMap { Date(rfcString: $0) } volume.availableCapacity = attr.prop["quota-available-bytes"].flatMap({ Int64($0) }) ?? 0 if let usage = attr.prop["quota-used-bytes"].flatMap({ Int64($0) }) { volume.totalCapacity = volume.availableCapacity + usage } completionHandler(volume) }) } /** Search files inside directory using query asynchronously. Sample predicates: ``` NSPredicate(format: "(name CONTAINS[c] 'hello') && (fileSize >= 10000)") NSPredicate(format: "(modifiedDate >= %@)", Date()) NSPredicate(format: "(path BEGINSWITH %@)", "folder/child folder") ``` - Note: Don't pass Spotlight predicates to this method directly, use `FileProvider.convertSpotlightPredicateTo()` method to get usable predicate. - Important: A file name criteria should be provided for Dropbox. - Parameters: - path: location of directory to start search - recursive: Searching subdirectories of path - query: An `NSPredicate` object with keys like `FileObject` members, except `size` which becomes `filesize`. - foundItemHandler: Closure which is called when a file is found - completionHandler: Closure which will be called after finishing search. Returns an arry of `FileObject` or error if occured. - files: all files meat the `query` criteria. - error: `Error` returned by server if occured. - Returns: An `Progress` to get progress or cancel progress. Use `completedUnitCount` to iterate count of found items. */ @discardableResult open override func searchFiles(path: String, recursive: Bool, query: NSPredicate, foundItemHandler: ((FileObject) -> Void)?, completionHandler: @escaping ([FileObject], Error?) -> Void) -> Progress? { return searchFiles(path: path, recursive: recursive, query: query, including: [], foundItemHandler: foundItemHandler, completionHandler: completionHandler) } /** Search files inside directory using query asynchronously. Sample predicates: ``` NSPredicate(format: "(name CONTAINS[c] 'hello') && (fileSize >= 10000)") NSPredicate(format: "(modifiedDate >= %@)", Date()) NSPredicate(format: "(path BEGINSWITH %@)", "folder/child folder") ``` - Note: Don't pass Spotlight predicates to this method directly, use `FileProvider.convertSpotlightPredicateTo()` method to get usable predicate. - Important: A file name criteria should be provided for Dropbox. - Parameters: - path: location of directory to start search - recursive: Searching subdirectories of path - query: An `NSPredicate` object with keys like `FileObject` members, except `size` which becomes `filesize`. - including: An array which determines which file properties should be considered to fetch. - foundItemHandler: Closure which is called when a file is found - completionHandler: Closure which will be called after finishing search. Returns an arry of `FileObject` or error if occured. - files: all files meat the `query` criteria. - error: `Error` returned by server if occured. - Returns: An `Progress` to get progress or cancel progress. Use `completedUnitCount` to iterate count of found items. */ @discardableResult open func searchFiles(path: String, recursive: Bool, query: NSPredicate, including: [URLResourceKey], foundItemHandler: ((FileObject) -> Void)?, completionHandler: @escaping (_ files: [FileObject], _ error: Error?) -> Void) -> Progress? { let url = self.url(of: path) var request = URLRequest(url: url) request.httpMethod = "PROPFIND" // Depth infinity is disabled on some servers. Implement workaround?! request.setValue(recursive ? "infinity" : "1", forHTTPHeaderField: "Depth") request.setValue(authentication: credential, with: credentialType) request.setValue(contentType: .xml, charset: .utf8) request.httpBody = WebDavFileObject.xmlProp(including) let progress = Progress(totalUnitCount: -1) progress.setUserInfoObject(url, forKey: .fileURLKey) let queryIsTruePredicate = query.predicateFormat == "TRUEPREDICATE" let task = session.dataTask(with: request) { (data, response, error) in // FIXME: paginating results var responseError: FileProviderHTTPError? if let code = (response as? HTTPURLResponse)?.statusCode , code >= 300, let rCode = FileProviderHTTPErrorCode(rawValue: code) { responseError = self.serverError(with: rCode, path: path, data: data) } guard let data = data else { completionHandler([], responseError ?? error) return } let xresponse = DavResponse.parse(xmlResponse: data, baseURL: self.baseURL) var fileObjects = [WebDavFileObject]() for attr in xresponse where attr.href.path != url.path { let fileObject = WebDavFileObject(attr) if !queryIsTruePredicate && !query.evaluate(with: fileObject.mapPredicate()) { continue } fileObjects.append(fileObject) progress.completedUnitCount = Int64(fileObjects.count) foundItemHandler?(fileObject) } completionHandler(fileObjects, responseError ?? error) } progress.cancellationHandler = { [weak task] in task?.cancel() } progress.setUserInfoObject(Date(), forKey: .startingTimeKey) task.resume() return progress } override open func isReachable(completionHandler: @escaping (_ success: Bool, _ error: Error?) -> Void) { var request = URLRequest(url: baseURL!) request.httpMethod = "PROPFIND" request.setValue("0", forHTTPHeaderField: "Depth") request.setValue(authentication: credential, with: credentialType) request.setValue(contentType: .xml, charset: .utf8) request.httpBody = WebDavFileObject.xmlProp([.volumeTotalCapacityKey, .volumeAvailableCapacityKey]) runDataTask(with: request, completionHandler: { (data, response, error) in let status = (response as? HTTPURLResponse)?.statusCode ?? 400 if status >= 400, let code = FileProviderHTTPErrorCode(rawValue: status) { let errorDesc = data.flatMap({ String(data: $0, encoding: .utf8) }) let error = FileProviderWebDavError(code: code, path: "", serverDescription: errorDesc, url: self.baseURL!) completionHandler(false, error) return } completionHandler(status < 300, error) }) } open func publicLink(to path: String, completionHandler: @escaping ((URL?, FileObject?, Date?, Error?) -> Void)) { guard self.baseURL?.host?.contains("dav.yandex.") ?? false else { dispatch_queue.async { completionHandler(nil, nil, nil, URLError(.resourceUnavailable, url: self.url(of: path))) } return } let url = self.url(of: path) var request = URLRequest(url: url) request.httpMethod = "PROPPATCH" request.setValue(authentication: credential, with: credentialType) request.setValue(contentType: .xml, charset: .utf8) let body = "\n\ntrue\n\n" request.httpBody = body.data(using: .utf8) runDataTask(with: request, completionHandler: { (data, response, error) in var responseError: FileProviderHTTPError? if let code = (response as? HTTPURLResponse)?.statusCode, code >= 300, let rCode = FileProviderHTTPErrorCode(rawValue: code) { responseError = self.serverError(with: rCode, path: path, data: data) } if let data = data { let xresponse = DavResponse.parse(xmlResponse: data, baseURL: self.baseURL) if let urlStr = xresponse.first?.prop["public_url"], let url = URL(string: urlStr) { completionHandler(url, nil, nil, nil) return } } completionHandler(nil, nil, nil, responseError ?? error) }) } override func request(for operation: FileOperationType, overwrite: Bool = true, attributes: [URLResourceKey: Any] = [:]) -> URLRequest { let method: String let url: URL let sourceURL = self.url(of: operation.source) switch operation { case .fetch: method = "GET" url = sourceURL case .create: if sourceURL.absoluteString.hasSuffix("/") { method = "MKCOL" url = sourceURL } else { fallthrough } case .modify: method = "PUT" url = sourceURL break case .copy(let source, let dest): if source.hasPrefix("file://") { method = "PUT" url = self.url(of: dest) } else if dest.hasPrefix("file://") { method = "GET" url = sourceURL } else { method = "COPY" url = sourceURL } case .move: method = "MOVE" url = sourceURL case .remove: method = "DELETE" url = sourceURL default: fatalError("Unimplemented operation \(operation.description) in \(#file)") } var request = URLRequest(url: url) request.httpMethod = method request.setValue(authentication: credential, with: credentialType) request.setValue(overwrite ? "T" : "F", forHTTPHeaderField: "Overwrite") if let dest = operation.destination, !dest.hasPrefix("file://") { request.setValue(self.url(of:dest).absoluteString, forHTTPHeaderField: "Destination") } return request } override func serverError(with code: FileProviderHTTPErrorCode, path: String?, data: Data?) -> FileProviderHTTPError { return FileProviderWebDavError(code: code, path: path ?? "", serverDescription: data.flatMap({ String(data: $0, encoding: .utf8) }), url: self.url(of: path ?? "")) } override func multiStatusError(operation: FileOperationType, data: Data) -> FileProviderHTTPError? { let xresponses = DavResponse.parse(xmlResponse: data, baseURL: self.baseURL) for xresponse in xresponses where (xresponse.status ?? 0) >= 300 { let code = xresponse.status.flatMap { FileProviderHTTPErrorCode(rawValue: $0) } ?? .internalServerError return self.serverError(with: code, path: operation.source, data: data) } return nil } /* fileprivate func registerNotifcation(path: String, eventHandler: (() -> Void)) { /* There is no unified api for monitoring WebDAV server content change/update * Microsoft Exchange uses SUBSCRIBE method, Apple uses push notification system. * while both is unavailable in a mobile platform. * A messy approach is listing a directory with an interval period and compare * with previous results */ NotImplemented() } fileprivate func unregisterNotifcation(path: String) { NotImplemented() }*/ // TODO: implements methods for lock mechanism } extension WebDAVFileProvider: ExtendedFileProvider { #if os(macOS) || os(iOS) || os(tvOS) open func thumbnailOfFileSupported(path: String) -> Bool { guard self.baseURL?.host?.contains("dav.yandex.") ?? false else { return false } let supportedExt: [String] = ["jpg", "jpeg", "png", "gif"] return supportedExt.contains(path.pathExtension) } @discardableResult open func thumbnailOfFile(path: String, dimension: CGSize?, completionHandler: @escaping ((ImageClass?, Error?) -> Void)) -> Progress? { guard self.baseURL?.host?.contains("dav.yandex.") ?? false else { dispatch_queue.async { completionHandler(nil, URLError(.resourceUnavailable, url: self.url(of: path))) } return nil } let dimension = dimension ?? CGSize(width: 64, height: 64) let url = URL(string: self.url(of: path).absoluteString + "?preview&size=\(dimension.width)x\(dimension.height)")! var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue(authentication: credential, with: credentialType) let task = session.dataTask(with: request, completionHandler: { (data, response, error) in var responseError: FileProviderHTTPError? if let code = (response as? HTTPURLResponse)?.statusCode , code >= 300, let rCode = FileProviderHTTPErrorCode(rawValue: code) { responseError = self.serverError(with: rCode, path: url.relativePath, data: data) completionHandler(nil, responseError ?? error) return } completionHandler(data.flatMap({ ImageClass(data: $0) }), nil) }) task.resume() return nil } #endif open func propertiesOfFileSupported(path: String) -> Bool { return false } @discardableResult open func propertiesOfFile(path: String, completionHandler: @escaping (([String : Any], [String], Error?) -> Void)) -> Progress? { dispatch_queue.async { completionHandler([:], [], URLError(.resourceUnavailable, url: self.url(of: path))) } return nil } } // MARK: WEBDAV XML response implementation struct DavResponse { let href: URL let hrefString: String let status: Int? let prop: [String: String] static let urlAllowed = CharacterSet(charactersIn: " ").inverted init? (_ node: AEXMLElement, baseURL: URL?) { func standardizePath(_ str: String) -> String { let trimmedStr = str.hasPrefix("/") ? String(str[str.index(after: str.startIndex)...]) : str return trimmedStr.addingPercentEncoding(withAllowedCharacters: .filePathAllowed) ?? str } // find node names with namespace var hreftag = "href" var statustag = "status" var propstattag = "propstat" for node in node.children { if node.name.lowercased().hasSuffix("href") { hreftag = node.name } if node.name.lowercased().hasSuffix("status") { statustag = node.name } if node.name.lowercased().hasSuffix("propstat") { propstattag = node.name } } guard let hrefString = node[hreftag].value else { return nil } // Percent-encoding space, some servers return invalid urls which space is not encoded to %20 let hrefStrPercented = hrefString.addingPercentEncoding(withAllowedCharacters: DavResponse.urlAllowed) ?? hrefString // trying to figure out relative path out of href let hrefAbsolute = URL(string: hrefStrPercented, relativeTo: baseURL)?.absoluteURL let relativePath: String if hrefAbsolute?.host?.replacingOccurrences(of: "www.", with: "", options: .anchored) == baseURL?.host?.replacingOccurrences(of: "www.", with: "", options: .anchored) { relativePath = hrefAbsolute?.path.replacingOccurrences(of: baseURL?.absoluteURL.path ?? "", with: "", options: .anchored, range: nil) ?? hrefString } else { relativePath = hrefAbsolute?.absoluteString.replacingOccurrences(of: baseURL?.absoluteString ?? "", with: "", options: .anchored, range: nil) ?? hrefString } let hrefURL = URL(string: standardizePath(relativePath), relativeTo: baseURL) ?? baseURL guard let href = hrefURL?.standardized else { return nil } // reading status and properties var status: Int? let statusDesc = (node[statustag].string).components(separatedBy: " ") if statusDesc.count > 2 { status = Int(statusDesc[1]) } var propDic = [String: String]() let propStatNode = node[propstattag] for node in propStatNode.children where node.name.lowercased().hasSuffix("status"){ statustag = node.name break } let statusDesc2 = (propStatNode[statustag].string).components(separatedBy: " ") if statusDesc2.count > 2 { status = Int(statusDesc2[1]) } var proptag = "prop" for tnode in propStatNode.children where tnode.name.lowercased().hasSuffix("prop") { proptag = tnode.name break } for propItemNode in propStatNode[proptag].children { let key = propItemNode.name.components(separatedBy: ":").last!.lowercased() guard propDic.index(forKey: key) == nil else { continue } propDic[key] = propItemNode.value if key == "resourcetype" && propItemNode.xml.contains("collection") { propDic["getcontenttype"] = ContentMIMEType.directory.rawValue } } self.href = href self.hrefString = hrefString self.status = status self.prop = propDic } static func parse(xmlResponse: Data, baseURL: URL?) -> [DavResponse] { guard let xml = try? AEXMLDocument(xml: xmlResponse) else { return [] } var result = [DavResponse]() var rootnode = xml.root var responsetag = "response" for node in rootnode.all ?? [] where node.name.lowercased().hasSuffix("multistatus") { rootnode = node } for node in rootnode.children where node.name.lowercased().hasSuffix("response") { responsetag = node.name break } for responseNode in rootnode[responsetag].all ?? [] { if let davResponse = DavResponse(responseNode, baseURL: baseURL) { result.append(davResponse) } } return result } } /// Containts path, url and attributes of a WebDAV file or resource. public final class WebDavFileObject: FileObject { internal init(_ davResponse: DavResponse) { let href = davResponse.href let name = davResponse.prop["displayname"] ?? davResponse.href.lastPathComponent let relativePath = href.relativePath let path = relativePath.hasPrefix("/") ? relativePath : ("/" + relativePath) super.init(url: href, name: name, path: path) self.size = Int64(davResponse.prop["getcontentlength"] ?? "-1") ?? NSURLSessionTransferSizeUnknown self.creationDate = davResponse.prop["creationdate"].flatMap { Date(rfcString: $0) } self.modifiedDate = davResponse.prop["getlastmodified"].flatMap { Date(rfcString: $0) } self.contentType = davResponse.prop["getcontenttype"].flatMap(ContentMIMEType.init(rawValue:)) ?? .stream self.isHidden = (Int(davResponse.prop["ishidden"] ?? "0") ?? 0) > 0 self.isReadOnly = (Int(davResponse.prop["isreadonly"] ?? "0") ?? 0) > 0 self.type = (self.contentType == .directory) ? .directory : .regular self.entryTag = davResponse.prop["getetag"] } /// MIME type of the file. public internal(set) var contentType: ContentMIMEType { get { return (allValues[.mimeTypeKey] as? String).flatMap(ContentMIMEType.init(rawValue:)) ?? .stream } set { allValues[.mimeTypeKey] = newValue.rawValue } } /// HTTP E-Tag, can be used to mark changed files. public internal(set) var entryTag: String? { get { return allValues[.entryTagKey] as? String } set { allValues[.entryTagKey] = newValue } } internal class func resourceKeyToDAVProp(_ key: URLResourceKey) -> String? { switch key { case URLResourceKey.fileSizeKey: return "getcontentlength" case URLResourceKey.creationDateKey: return "creationdate" case URLResourceKey.contentModificationDateKey: return "getlastmodified" case URLResourceKey.fileResourceTypeKey, URLResourceKey.mimeTypeKey: return "getcontenttype" case URLResourceKey.isHiddenKey: return "ishidden" case URLResourceKey.entryTagKey: return "getetag" case URLResourceKey.volumeTotalCapacityKey: // WebDAV doesn't have total capacity, but it's can be calculated via used capacity return "quota-used-bytes" case URLResourceKey.volumeAvailableCapacityKey: return "quota-available-bytes" default: return nil } } internal class func propString(_ keys: [URLResourceKey]) -> String { var propKeys = "" for item in keys { if let prop = WebDavFileObject.resourceKeyToDAVProp(item) { propKeys += "" } } if propKeys.isEmpty { propKeys = "" } else { propKeys += "" } return propKeys } internal class func xmlProp(_ keys: [URLResourceKey]) -> Data { return "\n\n\(WebDavFileObject.propString(keys))\n".data(using: .utf8)! } } /// Error returned by WebDAV server when trying to access or do operations on a file or folder. public struct FileProviderWebDavError: FileProviderHTTPError { public let code: FileProviderHTTPErrorCode public let path: String public let serverDescription: String? /// URL of resource caused error. public let url: URL }