sync(chunk 2a): vendor dav4jvm 2.2.1 as :dav
JitPack-only publishing conflicts with FAIL_ON_PROJECT_REPOS, artifacts are unsigned and not immutable, and upstream shipped two breaking majors 19 days apart. 2.2.1 is the last OkHttp release: 3.0.0 moved to Ktor and 4.x needs Java 21, and SYNC.md's whole auth section is written in OkHttp terms, so 4.x would have invalidated it. A plain JVM module, not an Android library — the tree has no Android imports and keeping it that way keeps the floret-kit extraction a file move. Four changes from upstream, all in dav/PROVENANCE.md: - commons-lang3 dropped (one import, one call). The replacement requires the whole string to be consumed, as DateUtils did: pattern 1 ends in a literal 'GMT', so SimpleDateFormat would match "...GMT+02:00" as a prefix and throw the offset away. - HTTP dates were parsed and formatted in the device's local zone — the GMT in the format string is a quoted literal and timeZone was never set, so every getlastmodified was out by the local UTC offset. Upstream tests dates nowhere. The formatter is also no longer shared and mutable. - dav4jvm#209: permanent redirects now reach the caller via DavResource.permanentLocation, cleared per request so it never describes an earlier one. - xpp3 is compileOnly; Android supplies org.xmlpull.v1. SYNC.md's other claimed defect, "does not follow 303", is not true of 2.2.1 — pinned by a test rather than removed from the notes. CI names :dav:test explicitly; testDebugUnitTest is Android-only and would have run none of the vendored suite. lintDebug added to the per-chunk checks after it caught a literal byte-order mark left by chunk 1.
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import okhttp3.*
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Handler to manage authentication against a given service (may be limited to one domain).
|
||||
* There's no domain-based cache, because the same user name and password will be used for
|
||||
* all requests.
|
||||
*
|
||||
* Authentication methods/credentials found to be working will be cached for further requests
|
||||
* (this is why the interceptor is needed).
|
||||
*
|
||||
* Usage: Set as authenticator *and* as network interceptor.
|
||||
*/
|
||||
class BasicDigestAuthHandler(
|
||||
/** Authenticate only against hosts ending with this domain (may be null, which means no restriction) */
|
||||
val domain: String?,
|
||||
|
||||
val username: String,
|
||||
val password: String,
|
||||
|
||||
val insecurePreemptive: Boolean = false
|
||||
): Authenticator, Interceptor {
|
||||
|
||||
companion object {
|
||||
private const val HEADER_AUTHORIZATION = "Authorization"
|
||||
|
||||
// cached digest parameters
|
||||
var clientNonce = h(UUID.randomUUID().toString())
|
||||
var nonceCount = AtomicInteger(1)
|
||||
|
||||
fun quotedString(s: String) = "\"" + s.replace("\"", "\\\"") + "\""
|
||||
fun h(data: String) = data.toByteArray().toByteString().md5().hex()
|
||||
|
||||
fun h(body: RequestBody): String {
|
||||
val buffer = Buffer()
|
||||
body.writeTo(buffer)
|
||||
return buffer.readByteArray().toByteString().md5().hex()
|
||||
}
|
||||
|
||||
fun kd(secret: String, data: String) = h("$secret:$data")
|
||||
}
|
||||
|
||||
// cached authentication schemes
|
||||
private var basicAuth: Challenge? = null
|
||||
private var digestAuth: Challenge? = null
|
||||
|
||||
|
||||
fun authenticateRequest(request: Request, response: Response?): Request? {
|
||||
domain?.let {
|
||||
val host = request.url.host
|
||||
if (!domain.equals(UrlUtils.hostToDomain(host), true)) {
|
||||
Dav4jvm.log.warning("Not authenticating against $host because it doesn't belong to $domain")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (response == null) {
|
||||
// we're not processing a 401 response
|
||||
|
||||
if (basicAuth == null && digestAuth == null && (request.isHttps || insecurePreemptive)) {
|
||||
Dav4jvm.log.fine("Trying Basic auth preemptively")
|
||||
basicAuth = Challenge("Basic", "")
|
||||
}
|
||||
|
||||
} else {
|
||||
// we're processing a 401 response
|
||||
|
||||
var newBasicAuth: Challenge? = null
|
||||
var newDigestAuth: Challenge? = null
|
||||
for (challenge in response.challenges())
|
||||
when {
|
||||
"Basic".equals(challenge.scheme, true) -> {
|
||||
basicAuth?.let {
|
||||
Dav4jvm.log.warning("Basic credentials didn't work last time -> aborting")
|
||||
basicAuth = null
|
||||
return null
|
||||
}
|
||||
newBasicAuth = challenge
|
||||
}
|
||||
"Digest".equals(challenge.scheme, true) -> {
|
||||
if (digestAuth != null && !"true".equals(challenge.authParams["stale"], true)) {
|
||||
Dav4jvm.log.warning("Digest credentials didn't work last time and server nonce has not expired -> aborting")
|
||||
digestAuth = null
|
||||
return null
|
||||
}
|
||||
newDigestAuth = challenge
|
||||
}
|
||||
}
|
||||
|
||||
basicAuth = newBasicAuth
|
||||
digestAuth = newDigestAuth
|
||||
}
|
||||
|
||||
// we MUST prefer Digest auth [https://tools.ietf.org/html/rfc2617#section-4.6]
|
||||
when {
|
||||
digestAuth != null -> {
|
||||
Dav4jvm.log.fine("Adding Digest authorization request for ${request.url}")
|
||||
return digestRequest(request, digestAuth)
|
||||
}
|
||||
|
||||
basicAuth != null -> {
|
||||
Dav4jvm.log.fine("Adding Basic authorization header for ${request.url}")
|
||||
|
||||
/* In RFC 2617 (obsolete), there was no encoding for credentials defined, although
|
||||
one can interpret it as "use ISO-8859-1 encoding". This has been clarified by RFC 7617,
|
||||
which creates a new charset parameter for WWW-Authenticate, which always must be UTF-8.
|
||||
So, UTF-8 encoding for credentials is compatible with all RFC 7617 servers and many,
|
||||
but not all pre-RFC 7617 servers. */
|
||||
return request.newBuilder()
|
||||
.header(HEADER_AUTHORIZATION, Credentials.basic(username, password, Charsets.UTF_8))
|
||||
.build()
|
||||
}
|
||||
|
||||
response != null ->
|
||||
Dav4jvm.log.warning("No supported authentication scheme")
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun digestRequest(request: Request, digest: Challenge?): Request? {
|
||||
if (digest == null)
|
||||
return null
|
||||
|
||||
val realm = digest.authParams["realm"]
|
||||
val opaque = digest.authParams["opaque"]
|
||||
val nonce = digest.authParams["nonce"]
|
||||
|
||||
val algorithm = Algorithm.determine(digest.authParams["algorithm"])
|
||||
val qop = Protection.selectFrom(digest.authParams["qop"])
|
||||
|
||||
// build response parameters
|
||||
var response: String? = null
|
||||
|
||||
val params = LinkedList<String>()
|
||||
params.add("username=${quotedString(username)}")
|
||||
if (realm != null)
|
||||
params.add("realm=${quotedString(realm)}")
|
||||
else {
|
||||
Dav4jvm.log.warning("No realm provided, aborting Digest auth")
|
||||
return null
|
||||
}
|
||||
if (nonce != null)
|
||||
params.add("nonce=${quotedString(nonce)}")
|
||||
else {
|
||||
Dav4jvm.log.warning("No nonce provided, aborting Digest auth")
|
||||
return null
|
||||
}
|
||||
if (opaque != null)
|
||||
params.add("opaque=${quotedString(opaque)}")
|
||||
|
||||
if (algorithm != null)
|
||||
params.add("algorithm=${quotedString(algorithm.algorithm)}")
|
||||
|
||||
val method = request.method
|
||||
val digestURI = request.url.encodedPath
|
||||
params.add("uri=${quotedString(digestURI)}")
|
||||
|
||||
if (qop != null) {
|
||||
params.add("qop=${qop.qop}")
|
||||
params.add("cnonce=${quotedString(clientNonce)}")
|
||||
|
||||
val nc = nonceCount.getAndIncrement()
|
||||
val ncValue = String.format(Locale.ROOT, "%08x", nc)
|
||||
params.add("nc=$ncValue")
|
||||
|
||||
val a1: String? = when (algorithm) {
|
||||
Algorithm.MD5 ->
|
||||
"$username:$realm:$password"
|
||||
Algorithm.MD5_SESSION ->
|
||||
h("$username:$realm:$password") + ":$nonce:$clientNonce"
|
||||
else ->
|
||||
null
|
||||
}
|
||||
Dav4jvm.log.finer("A1=$a1")
|
||||
|
||||
val a2: String? = when (qop) {
|
||||
Protection.Auth ->
|
||||
"$method:$digestURI"
|
||||
Protection.AuthInt -> {
|
||||
try {
|
||||
val body = request.body
|
||||
"$method:$digestURI:" + (if (body != null) h(body) else h(""))
|
||||
} catch(e: IOException) {
|
||||
Dav4jvm.log.warning("Couldn't get entity-body for hash calculation")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
Dav4jvm.log.finer("A2=$a2")
|
||||
|
||||
if (a1 != null && a2 != null)
|
||||
response = kd(h(a1), "$nonce:$ncValue:$clientNonce:${qop.qop}:${h(a2)}")
|
||||
|
||||
} else {
|
||||
Dav4jvm.log.finer("Using legacy Digest auth")
|
||||
|
||||
// legacy (backwards compatibility with RFC 2069)
|
||||
if (algorithm == Algorithm.MD5) {
|
||||
val a1 = "$username:$realm:$password"
|
||||
val a2 = "$method:$digestURI"
|
||||
response = kd(h(a1), nonce + ":" + h(a2))
|
||||
}
|
||||
}
|
||||
|
||||
return if (response != null) {
|
||||
params.add("response=" + quotedString(response))
|
||||
request.newBuilder()
|
||||
.header(HEADER_AUTHORIZATION, "Digest " + params.joinToString(", "))
|
||||
.build()
|
||||
} else
|
||||
null
|
||||
}
|
||||
|
||||
|
||||
private enum class Algorithm(
|
||||
val algorithm: String
|
||||
) {
|
||||
MD5("MD5"),
|
||||
MD5_SESSION("MD5-sess");
|
||||
|
||||
companion object {
|
||||
fun determine(paramValue: String?): Algorithm? {
|
||||
return when {
|
||||
paramValue == null || MD5.algorithm.equals(paramValue, true) ->
|
||||
MD5
|
||||
MD5_SESSION.algorithm.equals(paramValue, true) ->
|
||||
MD5_SESSION
|
||||
else -> {
|
||||
Dav4jvm.log.warning("Ignoring unknown hash algorithm: $paramValue")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Protection(
|
||||
val qop: String
|
||||
) { // quality of protection:
|
||||
Auth("auth"), // authentication only
|
||||
AuthInt("auth-int"); // authentication with integrity protection
|
||||
|
||||
companion object {
|
||||
fun selectFrom(paramValue: String?): Protection? {
|
||||
paramValue?.let {
|
||||
var qopAuth = false
|
||||
var qopAuthInt = false
|
||||
for (qop in paramValue.split(","))
|
||||
when (qop) {
|
||||
"auth" -> qopAuth = true
|
||||
"auth-int" -> qopAuthInt = true
|
||||
}
|
||||
|
||||
// prefer auth-int as it provides more protection
|
||||
if (qopAuthInt)
|
||||
return AuthInt
|
||||
else if (qopAuth)
|
||||
return Auth
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun authenticate(route: Route?, response: Response) =
|
||||
authenticateRequest(response.request, response)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
var request = chain.request()
|
||||
if (request.header(HEADER_AUTHORIZATION) == null) {
|
||||
// try to apply cached authentication
|
||||
val authRequest = authenticateRequest(request, null)
|
||||
if (authRequest != null)
|
||||
request = authRequest
|
||||
}
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
/**
|
||||
* Callback for the OPTIONS request.
|
||||
*/
|
||||
fun interface CapabilitiesCallback {
|
||||
fun onCapabilities(davCapabilities: Set<String>, response: okhttp3.Response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for 207 Multi-Status responses.
|
||||
*/
|
||||
fun interface MultiResponseCallback {
|
||||
/**
|
||||
* Called for every `<response>` element in the `<multistatus>` body. For instance,
|
||||
* in response to a `PROPFIND` request, this callback will be called once for every found
|
||||
* member resource.
|
||||
*
|
||||
* @param response the parsed response (including URL)
|
||||
* @param relation relation of the response to the called resource
|
||||
*/
|
||||
fun onResponse(response: Response, relation: Response.HrefRelation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for HTTP responses.
|
||||
*/
|
||||
fun interface ResponseCallback {
|
||||
/**
|
||||
* Called for a HTTP response. Typically this is only called for successful/redirect
|
||||
* responses because HTTP errors throw an exception before this callback is called.
|
||||
*/
|
||||
fun onResponse(response: okhttp3.Response)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import java.util.logging.Logger
|
||||
|
||||
object Dav4jvm {
|
||||
|
||||
var log = Logger.getLogger("dav4jvm")!!
|
||||
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.AddressData
|
||||
import at.bitfire.dav4jvm.property.GetContentType
|
||||
import at.bitfire.dav4jvm.property.GetETag
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.io.StringWriter
|
||||
import java.util.logging.Logger
|
||||
|
||||
class DavAddressBook @JvmOverloads constructor(
|
||||
httpClient: OkHttpClient,
|
||||
location: HttpUrl,
|
||||
log: Logger = Dav4jvm.log
|
||||
): DavCollection(httpClient, location, log) {
|
||||
|
||||
companion object {
|
||||
val MIME_JCARD = "application/vcard+json".toMediaType()
|
||||
val MIME_VCARD3_UTF8 = "text/vcard;charset=utf-8".toMediaType()
|
||||
val MIME_VCARD4 = "text/vcard;version=4.0".toMediaType()
|
||||
|
||||
val ADDRESSBOOK_QUERY = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-query")
|
||||
val ADDRESSBOOK_MULTIGET = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-multiget")
|
||||
val FILTER = Property.Name(XmlUtils.NS_CARDDAV, "filter")
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an addressbook-query REPORT request to the resource.
|
||||
*
|
||||
* @param callback called for every WebDAV response XML element in the result
|
||||
*
|
||||
* @return list of properties which have been received in the Multi-Status response, but
|
||||
* are not part of response XML elements
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error
|
||||
*/
|
||||
fun addressbookQuery(callback: MultiResponseCallback): List<Property> {
|
||||
/* <!ELEMENT addressbook-query ((DAV:allprop |
|
||||
DAV:propname |
|
||||
DAV:prop)?, filter, limit?)>
|
||||
<!ELEMENT filter (prop-filter*)>
|
||||
*/
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", null)
|
||||
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
|
||||
serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV)
|
||||
serializer.insertTag(ADDRESSBOOK_QUERY) {
|
||||
insertTag(PROP) {
|
||||
insertTag(GetETag.NAME)
|
||||
}
|
||||
insertTag(FILTER)
|
||||
}
|
||||
serializer.endDocument()
|
||||
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(location)
|
||||
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
|
||||
.header("Depth", "1")
|
||||
.build()).execute()
|
||||
}.use { response ->
|
||||
return processMultiStatus(response, callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an addressbook-multiget REPORT request to the resource.
|
||||
*
|
||||
* @param urls list of vCard URLs to be requested
|
||||
* @param contentType MIME type of requested format; may be "text/vcard" for vCard or
|
||||
* "application/vcard+json" for jCard. *null*: don't request specific representation type
|
||||
* @param version vCard version subtype of the requested format. Should only be specified together with a [contentType] of "text/vcard".
|
||||
* Currently only useful value: "4.0" for vCard 4. *null*: don't request specific version
|
||||
* @param callback called for every WebDAV response XML element in the result
|
||||
*
|
||||
* @return list of properties which have been received in the Multi-Status response, but
|
||||
* are not part of response XML elements
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error
|
||||
*/
|
||||
fun multiget(urls: List<HttpUrl>, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List<Property> {
|
||||
/* <!ELEMENT addressbook-multiget ((DAV:allprop |
|
||||
DAV:propname |
|
||||
DAV:prop)?,
|
||||
DAV:href+)>
|
||||
*/
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", null)
|
||||
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
|
||||
serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV)
|
||||
serializer.insertTag(ADDRESSBOOK_MULTIGET) {
|
||||
insertTag(PROP) {
|
||||
insertTag(GetContentType.NAME)
|
||||
insertTag(GetETag.NAME)
|
||||
insertTag(AddressData.NAME) {
|
||||
if (contentType != null)
|
||||
attribute(null, AddressData.CONTENT_TYPE, contentType)
|
||||
if (version != null)
|
||||
attribute(null, AddressData.VERSION, version)
|
||||
}
|
||||
}
|
||||
for (url in urls)
|
||||
insertTag(HREF) {
|
||||
text(url.encodedPath)
|
||||
}
|
||||
}
|
||||
serializer.endDocument()
|
||||
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(location)
|
||||
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
|
||||
.header("Depth", "0") // "The request MUST include a Depth: 0 header [...]"
|
||||
.build()).execute()
|
||||
}.use {
|
||||
return processMultiStatus(it, callback)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.CalendarData
|
||||
import at.bitfire.dav4jvm.property.GetContentType
|
||||
import at.bitfire.dav4jvm.property.GetETag
|
||||
import at.bitfire.dav4jvm.property.ScheduleTag
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.io.StringWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.logging.Logger
|
||||
|
||||
class DavCalendar @JvmOverloads constructor(
|
||||
httpClient: OkHttpClient,
|
||||
location: HttpUrl,
|
||||
log: Logger = Dav4jvm.log
|
||||
): DavCollection(httpClient, location, log) {
|
||||
|
||||
companion object {
|
||||
val MIME_ICALENDAR = "text/calendar".toMediaType()
|
||||
val MIME_ICALENDAR_UTF8 = "text/calendar;charset=utf-8".toMediaType()
|
||||
|
||||
val CALENDAR_QUERY = Property.Name(XmlUtils.NS_CALDAV, "calendar-query")
|
||||
val CALENDAR_MULTIGET = Property.Name(XmlUtils.NS_CALDAV, "calendar-multiget")
|
||||
|
||||
val FILTER = Property.Name(XmlUtils.NS_CALDAV, "filter")
|
||||
val COMP_FILTER = Property.Name(XmlUtils.NS_CALDAV, "comp-filter")
|
||||
const val COMP_FILTER_NAME = "name"
|
||||
val TIME_RANGE = Property.Name(XmlUtils.NS_CALDAV, "time-range")
|
||||
const val TIME_RANGE_START = "start"
|
||||
const val TIME_RANGE_END = "end"
|
||||
|
||||
private val timeFormatUTC = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT)
|
||||
init {
|
||||
timeFormatUTC.timeZone = TimeZone.getTimeZone("Etc/UTC")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sends a calendar-query REPORT to the resource.
|
||||
*
|
||||
* @param component requested component name (like VEVENT or VTODO)
|
||||
* @param start time-range filter: start date (optional)
|
||||
* @param end time-range filter: end date (optional)
|
||||
* @param callback called for every WebDAV response XML element in the result
|
||||
*
|
||||
* @return list of properties which have been received in the Multi-Status response, but
|
||||
* are not part of response XML elements
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error
|
||||
*/
|
||||
fun calendarQuery(component: String, start: Date?, end: Date?, callback: MultiResponseCallback): List<Property> {
|
||||
/* <!ELEMENT calendar-query ((DAV:allprop |
|
||||
DAV:propname |
|
||||
DAV:prop)?, filter, timezone?)>
|
||||
<!ELEMENT filter (comp-filter)>
|
||||
<!ELEMENT comp-filter (is-not-defined | (time-range?,
|
||||
prop-filter*, comp-filter*))>
|
||||
<!ATTLIST comp-filter name CDATA #REQUIRED>
|
||||
name value: a calendar object or calendar component
|
||||
type (e.g., VEVENT)
|
||||
|
||||
*/
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", null)
|
||||
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
|
||||
serializer.setPrefix("CAL", XmlUtils.NS_CALDAV)
|
||||
serializer.insertTag(CALENDAR_QUERY) {
|
||||
insertTag(PROP) {
|
||||
insertTag(GetETag.NAME)
|
||||
}
|
||||
insertTag(FILTER) {
|
||||
insertTag(COMP_FILTER) {
|
||||
attribute(null, COMP_FILTER_NAME, "VCALENDAR")
|
||||
insertTag(COMP_FILTER) {
|
||||
attribute(null, COMP_FILTER_NAME, component)
|
||||
if (start != null || end != null) {
|
||||
insertTag(TIME_RANGE) {
|
||||
if (start != null)
|
||||
attribute(null, TIME_RANGE_START, timeFormatUTC.format(start))
|
||||
if (end != null)
|
||||
attribute(null, TIME_RANGE_END, timeFormatUTC.format(end))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
serializer.endDocument()
|
||||
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(location)
|
||||
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
|
||||
.header("Depth", "1")
|
||||
.build()).execute()
|
||||
}.use {
|
||||
return processMultiStatus(it, callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a calendar-multiget REPORT to the resource. Received responses are sent
|
||||
* to the callback, whether they are successful (2xx) or not.
|
||||
*
|
||||
* @param urls list of iCalendar URLs to be requested
|
||||
* @param contentType MIME type of requested format; may be "text/calendar" for iCalendar or
|
||||
* "application/calendar+json" for jCard. *null*: don't request specific representation type
|
||||
* @param version Version subtype of the requested format, like "2.0" for iCalendar 2. *null*: don't request specific version
|
||||
* @param callback called for every WebDAV response XML element in the result
|
||||
*
|
||||
* @return list of properties which have been received in the Multi-Status response, but
|
||||
* are not part of response XML elements
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error
|
||||
*/
|
||||
fun multiget(urls: List<HttpUrl>, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List<Property> {
|
||||
/* <!ELEMENT calendar-multiget ((DAV:allprop |
|
||||
DAV:propname |
|
||||
DAV:prop)?, DAV:href+)>
|
||||
*/
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", null)
|
||||
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
|
||||
serializer.setPrefix("CAL", XmlUtils.NS_CALDAV)
|
||||
serializer.insertTag(CALENDAR_MULTIGET) {
|
||||
insertTag(PROP) {
|
||||
insertTag(GetContentType.NAME) // to determine the character set
|
||||
insertTag(GetETag.NAME)
|
||||
insertTag(ScheduleTag.NAME)
|
||||
insertTag(CalendarData.NAME) {
|
||||
if (contentType != null)
|
||||
attribute(null, CalendarData.CONTENT_TYPE, contentType)
|
||||
if (version != null)
|
||||
attribute(null, CalendarData.VERSION, version)
|
||||
}
|
||||
}
|
||||
for (url in urls)
|
||||
insertTag(HREF) {
|
||||
serializer.text(url.encodedPath)
|
||||
}
|
||||
}
|
||||
serializer.endDocument()
|
||||
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(location)
|
||||
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
|
||||
.build()).execute()
|
||||
}.use {
|
||||
return processMultiStatus(it, callback)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.SyncToken
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.io.StringWriter
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Represents a WebDAV collection.
|
||||
*/
|
||||
open class DavCollection @JvmOverloads constructor(
|
||||
httpClient: OkHttpClient,
|
||||
location: HttpUrl,
|
||||
log: Logger = Dav4jvm.log
|
||||
): DavResource(httpClient, location, log) {
|
||||
|
||||
companion object {
|
||||
val SYNC_COLLECTION = Property.Name(XmlUtils.NS_WEBDAV, "sync-collection")
|
||||
val SYNC_LEVEL = Property.Name(XmlUtils.NS_WEBDAV, "sync-level")
|
||||
val LIMIT = Property.Name(XmlUtils.NS_WEBDAV, "limit")
|
||||
val NRESULTS = Property.Name(XmlUtils.NS_WEBDAV, "nresults")
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a POST request. Primarily intended to be used with an Add-Member URL (RFC 5995).
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
fun post(body: RequestBody, ifNoneMatch: Boolean = false, callback: ResponseCallback) {
|
||||
followRedirects {
|
||||
val builder = Request.Builder()
|
||||
.post(body)
|
||||
.url(location)
|
||||
|
||||
if (ifNoneMatch)
|
||||
// don't overwrite anything existing
|
||||
builder.header("If-None-Match", "*")
|
||||
|
||||
httpClient.newCall(builder.build()).execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a REPORT sync-collection request.
|
||||
*
|
||||
* @param syncToken sync-token to be sent with the request
|
||||
* @param infiniteDepth sync-level to be sent with the request: false = "1", true = "infinite"
|
||||
* @param limit maximum number of results (may cause truncation)
|
||||
* @param properties WebDAV properties to be requested
|
||||
* @param callback called for every WebDAV response XML element in the result
|
||||
*
|
||||
* @return list of properties which have been received in the Multi-Status response, but
|
||||
* are not part of response XML elements (like `sync-token` which is returned as [SyncToken])
|
||||
*
|
||||
* @throws java.io.IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error
|
||||
*/
|
||||
fun reportChanges(syncToken: String?, infiniteDepth: Boolean, limit: Int?, vararg properties: Property.Name, callback: MultiResponseCallback): List<Property> {
|
||||
/* <!ELEMENT sync-collection (sync-token, sync-level, limit?, prop)>
|
||||
|
||||
<!ELEMENT sync-token CDATA> <!-- Text MUST be a URI -->
|
||||
<!ELEMENT sync-level CDATA> <!-- Text MUST be either "1" or "infinite" -->
|
||||
|
||||
<!ELEMENT limit (nresults) >
|
||||
<!ELEMENT nresults (#PCDATA)> <!-- only digits -->
|
||||
|
||||
<!-- DAV:prop defined in RFC 4918, Section 14.18 -->
|
||||
*/
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", null)
|
||||
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
|
||||
serializer.insertTag(SYNC_COLLECTION) {
|
||||
insertTag(SyncToken.NAME) {
|
||||
if (syncToken != null)
|
||||
text(syncToken)
|
||||
}
|
||||
insertTag(SYNC_LEVEL) {
|
||||
text(if (infiniteDepth) "infinite" else "1")
|
||||
}
|
||||
if (limit != null)
|
||||
insertTag(LIMIT) {
|
||||
insertTag(NRESULTS) {
|
||||
text(limit.toString())
|
||||
}
|
||||
}
|
||||
insertTag(PROP) {
|
||||
for (prop in properties)
|
||||
insertTag(prop)
|
||||
}
|
||||
}
|
||||
serializer.endDocument()
|
||||
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(location)
|
||||
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
|
||||
.header("Depth", "0")
|
||||
.build()).execute()
|
||||
}.use {
|
||||
return processMultiStatus(it, callback)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.XmlUtils.propertyName
|
||||
import at.bitfire.dav4jvm.exception.*
|
||||
import at.bitfire.dav4jvm.property.SyncToken
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
import java.io.Reader
|
||||
import java.io.StringWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import at.bitfire.dav4jvm.Response as DavResponse
|
||||
|
||||
/**
|
||||
* Represents a WebDAV resource at the given location and allows WebDAV
|
||||
* requests to be performed on this resource.
|
||||
*
|
||||
* Requests are executed synchronously (blocking). If no error occurs, the given
|
||||
* callback will be called. Otherwise, an exception is thrown. *These callbacks
|
||||
* don't need to close the response.*
|
||||
*
|
||||
* To cancel a request, interrupt the thread. This will cause the requests to
|
||||
* throw `InterruptedException` or `InterruptedIOException`.
|
||||
*
|
||||
* @param httpClient [OkHttpClient] to access this object (must not follow redirects)
|
||||
* @param location location of the WebDAV resource
|
||||
* @param log will be used for logging
|
||||
*/
|
||||
open class DavResource @JvmOverloads constructor(
|
||||
val httpClient: OkHttpClient,
|
||||
location: HttpUrl,
|
||||
val log: Logger = Dav4jvm.log
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val MAX_REDIRECTS = 5
|
||||
|
||||
const val HTTP_MULTISTATUS = 207
|
||||
|
||||
/** 301 and 308 — the two redirects a caller should persist. */
|
||||
const val HTTP_MOVED_PERM = 301
|
||||
const val HTTP_PERM_REDIRECT = 308
|
||||
val MIME_XML = "application/xml; charset=utf-8".toMediaType()
|
||||
|
||||
val PROPFIND = Property.Name(XmlUtils.NS_WEBDAV, "propfind")
|
||||
val PROPERTYUPDATE = Property.Name(XmlUtils.NS_WEBDAV, "propertyupdate")
|
||||
val SET = Property.Name(XmlUtils.NS_WEBDAV, "set")
|
||||
val REMOVE = Property.Name(XmlUtils.NS_WEBDAV, "remove")
|
||||
val PROP = Property.Name(XmlUtils.NS_WEBDAV, "prop")
|
||||
val HREF = Property.Name(XmlUtils.NS_WEBDAV, "href")
|
||||
|
||||
val XML_SIGNATURE = "<?xml".toByteArray()
|
||||
|
||||
|
||||
/**
|
||||
* Creates a request body for the PROPPATCH request.
|
||||
*/
|
||||
internal fun createProppatchXml(
|
||||
setProperties: Map<Property.Name, String>,
|
||||
removeProperties: List<Property.Name>
|
||||
): String {
|
||||
// build XML request body
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.setPrefix("d", XmlUtils.NS_WEBDAV)
|
||||
serializer.startDocument("UTF-8", null)
|
||||
serializer.insertTag(PROPERTYUPDATE) {
|
||||
// DAV:set
|
||||
if (setProperties.isNotEmpty()) {
|
||||
serializer.insertTag(SET) {
|
||||
for (prop in setProperties) {
|
||||
serializer.insertTag(PROP) {
|
||||
serializer.insertTag(prop.key) {
|
||||
text(prop.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DAV:remove
|
||||
if (removeProperties.isNotEmpty()) {
|
||||
serializer.insertTag(REMOVE) {
|
||||
for (prop in removeProperties) {
|
||||
insertTag(PROP) {
|
||||
insertTag(prop)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serializer.endDocument()
|
||||
return writer.toString()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* URL of this resource (changes when being redirected by server)
|
||||
*/
|
||||
/**
|
||||
* The location reached through an unbroken chain of **permanent** redirects
|
||||
* (301, 308), or null if none was followed.
|
||||
*
|
||||
* Upstream mutates [location] in place for every redirect and tells the
|
||||
* caller nothing, so a caller cannot distinguish "this resource has moved,
|
||||
* store the new URL" from "follow this once". DAVx5 consequently never
|
||||
* rewrites a stored collection URL after a 301 and re-follows it on every
|
||||
* single sync — bitfireAT/dav4jvm#209, and `docs/SYNC.md` names persisting
|
||||
* the new URL ourselves as the fix. Local addition; see dav/PROVENANCE.md
|
||||
* change 3.
|
||||
*/
|
||||
var permanentLocation: HttpUrl? = null
|
||||
private set
|
||||
|
||||
var location: HttpUrl
|
||||
private set // allow internal modification only (for redirects)
|
||||
|
||||
init {
|
||||
// Don't follow redirects (only useful for GET/POST).
|
||||
// This means we have to handle 30x responses ourselves.
|
||||
require(!httpClient.followRedirects) { "httpClient must not follow redirects automatically" }
|
||||
|
||||
this.location = location
|
||||
}
|
||||
|
||||
override fun toString() = location.toString()
|
||||
|
||||
|
||||
/**
|
||||
* Gets the file name of this resource. See [HttpUtils.fileName] for details.
|
||||
*/
|
||||
fun fileName() = HttpUtils.fileName(location)
|
||||
|
||||
|
||||
/**
|
||||
* Sends an OPTIONS request to this resource without HTTP compression (because some servers have
|
||||
* broken compression for OPTIONS). Doesn't follow redirects.
|
||||
*
|
||||
* @param callback called with server response unless an exception is thrown
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
fun options(callback: CapabilitiesCallback) {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.method("OPTIONS", null)
|
||||
.header("Content-Length", "0")
|
||||
.url(location)
|
||||
.header("Accept-Encoding", "identity") // disable compression
|
||||
.build()).execute().use { response ->
|
||||
checkStatus(response)
|
||||
callback.onCapabilities(
|
||||
HttpUtils.listHeader(response, "DAV").map { it.trim() }.toSet(),
|
||||
response
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a MOVE request to this resource. Follows up to [MAX_REDIRECTS] redirects.
|
||||
* Updates [location] on success.
|
||||
*
|
||||
* @param destination where the resource shall be moved to
|
||||
* @param forceOverride whether resources are overwritten when they already exist in destination
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error or HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class, DavException::class)
|
||||
fun move(destination: HttpUrl, forceOverride: Boolean, callback: ResponseCallback) {
|
||||
val requestBuilder = Request.Builder()
|
||||
.method("MOVE", null)
|
||||
.header("Content-Length", "0")
|
||||
.header("Destination", destination.toString())
|
||||
|
||||
if (forceOverride) requestBuilder.header("Overwrite", "F")
|
||||
|
||||
followRedirects {
|
||||
requestBuilder.url(location)
|
||||
httpClient.newCall(requestBuilder
|
||||
.build())
|
||||
.execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
if (response.code == HTTP_MULTISTATUS)
|
||||
/* Multiple resources were to be affected by the MOVE, but errors on some
|
||||
of them prevented the operation from taking place.
|
||||
[_] (RFC 4918 9.9.4. Status Codes for MOVE Method) */
|
||||
throw HttpException(response)
|
||||
|
||||
// update location
|
||||
location.resolve(response.header("Location") ?: destination.toString())?.let {
|
||||
location = it
|
||||
}
|
||||
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a COPY request for this resource. Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* @param destination where the resource shall be copied to
|
||||
* @param forceOverride whether resources are overwritten when they already exist in destination
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error or HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class, DavException::class)
|
||||
fun copy(destination:HttpUrl, forceOverride: Boolean, callback: ResponseCallback) {
|
||||
val requestBuilder = Request.Builder()
|
||||
.method("COPY", null)
|
||||
.header("Content-Length", "0")
|
||||
.header("Destination", destination.toString())
|
||||
|
||||
if (forceOverride) requestBuilder.header("Overwrite", "F")
|
||||
|
||||
followRedirects {
|
||||
requestBuilder.url(location)
|
||||
httpClient.newCall(requestBuilder
|
||||
.build())
|
||||
.execute()
|
||||
}.use{ response ->
|
||||
checkStatus(response)
|
||||
|
||||
if (response.code == HTTP_MULTISTATUS)
|
||||
/* Multiple resources were to be affected by the COPY, but errors on some
|
||||
of them prevented the operation from taking place.
|
||||
[_] (RFC 4918 9.8.5. Status Codes for COPY Method) */
|
||||
throw HttpException(response)
|
||||
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a MKCOL request to this resource. Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
fun mkCol(xmlBody: String?, callback: ResponseCallback) {
|
||||
val rqBody = xmlBody?.toRequestBody(MIME_XML)
|
||||
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.method("MKCOL", rqBody)
|
||||
.url(location)
|
||||
.build()).execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a HEAD request to the resource.
|
||||
*
|
||||
* Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* @param callback called with server response unless an exception is thrown
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
fun head(callback: ResponseCallback) {
|
||||
followRedirects {
|
||||
httpClient.newCall(
|
||||
Request.Builder()
|
||||
.head()
|
||||
.url(location)
|
||||
.build()
|
||||
).execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a GET request to the resource. Sends `Accept-Encoding: identity` to disable
|
||||
* compression, because compression might change the ETag.
|
||||
*
|
||||
* Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* @param accept value of `Accept` header (always sent for clarity; use */* if you don't care)
|
||||
* @param callback called with server response unless an exception is thrown
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Deprecated("Use get(accept, headers, callback) with explicit Accept-Encoding instead")
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
fun get(accept: String, callback: ResponseCallback) =
|
||||
get(accept, Headers.headersOf("Accept-Encoding", "identity"), callback)
|
||||
|
||||
/**
|
||||
* Sends a GET request to the resource. Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* Note: Add `Accept-Encoding: identity` to [headers] if you want to disable compression
|
||||
* (compression might change the returned ETag).
|
||||
*
|
||||
* @param accept value of `Accept` header (always sent for clarity; use */* if you don't care)
|
||||
* @param headers additional headers to send with the request
|
||||
* @param callback called with server response unless an exception is thrown
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
fun get(accept: String, headers: Headers?, callback: ResponseCallback) {
|
||||
followRedirects {
|
||||
val request = Request.Builder()
|
||||
.get()
|
||||
.url(location)
|
||||
|
||||
if (headers != null)
|
||||
request.headers(headers)
|
||||
|
||||
// always Accept header
|
||||
request.header("Accept", accept)
|
||||
|
||||
httpClient.newCall(request.build()).execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a GET request to the resource for a specific byte range. Make sure to check the
|
||||
* response code: servers may return the whole resource with 200 or partials with 206.
|
||||
*
|
||||
* Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* @param accept value of `Accept` header (always sent for clarity; use */* if you don't care)
|
||||
* @param offset zero-based index of first byte to request
|
||||
* @param size number of bytes to request
|
||||
* @param headers additional headers to send with the request
|
||||
* @param callback called with server response unless an exception is thrown
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on high-level errors
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
fun getRange(accept: String, offset: Long, size: Int, headers: Headers? = null, callback: ResponseCallback) {
|
||||
followRedirects {
|
||||
val request = Request.Builder()
|
||||
.get()
|
||||
.url(location)
|
||||
|
||||
if (headers != null)
|
||||
request.headers(headers)
|
||||
|
||||
val lastIndex = offset + size - 1
|
||||
request
|
||||
.header("Accept", accept)
|
||||
.header("Range", "bytes=$offset-$lastIndex")
|
||||
|
||||
httpClient.newCall(request.build()).execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a PUT request to the resource. Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* When the server returns an ETag, it is stored in response properties.
|
||||
*
|
||||
* @param body new resource body to upload
|
||||
* @param ifETag value of `If-Match` header to set, or null to omit
|
||||
* @param ifScheduleTag value of `If-Schedule-Tag-Match` header to set, or null to omit
|
||||
* @param ifNoneMatch indicates whether `If-None-Match: *` ("don't overwrite anything existing") header shall be sent
|
||||
* @param callback called with server response unless an exception is thrown
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
fun put(body: RequestBody, ifETag: String? = null, ifScheduleTag: String? = null, ifNoneMatch: Boolean = false, callback: ResponseCallback) {
|
||||
followRedirects {
|
||||
val builder = Request.Builder()
|
||||
.put(body)
|
||||
.url(location)
|
||||
|
||||
if (ifETag != null)
|
||||
// only overwrite specific version
|
||||
builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag))
|
||||
if (ifScheduleTag != null)
|
||||
// only overwrite specific version
|
||||
builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag))
|
||||
if (ifNoneMatch)
|
||||
// don't overwrite anything existing
|
||||
builder.header("If-None-Match", "*")
|
||||
|
||||
httpClient.newCall(builder.build()).execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a DELETE request to the resource. Warning: Sending this request to a collection will
|
||||
* delete the collection with all its contents!
|
||||
*
|
||||
* Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* @param ifETag value of `If-Match` header to set, or null to omit
|
||||
* @param ifScheduleTag value of `If-Schedule-Tag-Match` header to set, or null to omit
|
||||
* @param callback called with server response unless an exception is thrown
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP errors, or when 207 Multi-Status is returned
|
||||
* (because then there was probably a problem with a member resource)
|
||||
* @throws DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
fun delete(ifETag: String? = null, ifScheduleTag: String? = null, callback: ResponseCallback) {
|
||||
followRedirects {
|
||||
val builder = Request.Builder()
|
||||
.delete()
|
||||
.url(location)
|
||||
if (ifETag != null)
|
||||
builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag))
|
||||
if (ifScheduleTag != null)
|
||||
builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag))
|
||||
|
||||
httpClient.newCall(builder.build()).execute()
|
||||
}.use { response ->
|
||||
checkStatus(response)
|
||||
|
||||
if (response.code == HTTP_MULTISTATUS)
|
||||
/* If an error occurs deleting a member resource (a resource other than
|
||||
the resource identified in the Request-URI), then the response can be
|
||||
a 207 (Multi-Status). […] (RFC 4918 9.6.1. DELETE for Collections) */
|
||||
throw HttpException(response)
|
||||
|
||||
callback.onResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a PROPFIND request to the resource. Expects and processes a 207 Multi-Status response.
|
||||
*
|
||||
* Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* @param depth "Depth" header to send (-1 for `infinity`)
|
||||
* @param reqProp properties to request
|
||||
* @param callback called for every XML response element in the Multi-Status response
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class, DavException::class)
|
||||
fun propfind(depth: Int, vararg reqProp: Property.Name, callback: MultiResponseCallback) {
|
||||
// build XML request body
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
|
||||
serializer.setPrefix("CAL", XmlUtils.NS_CALDAV)
|
||||
serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV)
|
||||
serializer.startDocument("UTF-8", null)
|
||||
serializer.insertTag(PROPFIND) {
|
||||
insertTag(PROP) {
|
||||
for (prop in reqProp)
|
||||
insertTag(prop)
|
||||
}
|
||||
}
|
||||
serializer.endDocument()
|
||||
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(location)
|
||||
.method("PROPFIND", writer.toString().toRequestBody(MIME_XML))
|
||||
.header("Depth", if (depth >= 0) depth.toString() else "infinity")
|
||||
.build()).execute()
|
||||
}.use {
|
||||
processMultiStatus(it, callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a PROPPATCH request to the server in order to set and remove properties.
|
||||
*
|
||||
* @param setProperties map of properties that shall be set (values currently have to be strings)
|
||||
* @param removeProperties list of names of properties that shall be removed
|
||||
* @param callback called for every XML response element in the Multi-Status response
|
||||
*
|
||||
* Follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* Currently expects a 207 Multi-Status response although servers are allowed to
|
||||
* return other values, too.
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect
|
||||
*/
|
||||
fun proppatch(
|
||||
setProperties: Map<Property.Name, String>,
|
||||
removeProperties: List<Property.Name>,
|
||||
callback: MultiResponseCallback
|
||||
) {
|
||||
followRedirects {
|
||||
val rqBody = createProppatchXml(setProperties, removeProperties)
|
||||
|
||||
httpClient.newCall(
|
||||
Request.Builder()
|
||||
.url(location)
|
||||
.method("PROPPATCH", rqBody.toRequestBody(MIME_XML))
|
||||
.build()
|
||||
).execute()
|
||||
}.use {
|
||||
// TODO handle not only 207 Multi-Status
|
||||
// http://www.webdav.org/specs/rfc4918.html#PROPPATCH-status
|
||||
|
||||
processMultiStatus(it, callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a SEARCH request (RFC 5323) with the given body to the server.
|
||||
*
|
||||
* Follows up to [MAX_REDIRECTS] redirects. Expects a 207 Multi-Status response.
|
||||
*
|
||||
* @param search search request body (XML format, DAV:searchrequest or DAV:query-schema-discovery)
|
||||
* @param callback called for every XML response element in the Multi-Status response
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect
|
||||
*/
|
||||
fun search(search: String, callback: MultiResponseCallback) {
|
||||
followRedirects {
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(location)
|
||||
.method("SEARCH", search.toRequestBody(MIME_XML))
|
||||
.build()).execute()
|
||||
}.use {
|
||||
processMultiStatus(it, callback)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// status handling
|
||||
|
||||
/**
|
||||
* Checks the status from an HTTP response and throws an exception in case of an error.
|
||||
*
|
||||
* @throws HttpException in case of an HTTP error
|
||||
*/
|
||||
protected fun checkStatus(response: Response) =
|
||||
checkStatus(response.code, response.message, response)
|
||||
|
||||
/**
|
||||
* Checks the status from an HTTP response and throws an exception in case of an error.
|
||||
*
|
||||
* @throws HttpException (with XML error names, if available) in case of an HTTP error
|
||||
*/
|
||||
private fun checkStatus(code: Int, message: String?, response: Response?) {
|
||||
if (code / 100 == 2)
|
||||
// everything OK
|
||||
return
|
||||
|
||||
throw when (code) {
|
||||
HttpURLConnection.HTTP_UNAUTHORIZED ->
|
||||
if (response != null) UnauthorizedException(response) else UnauthorizedException(message)
|
||||
HttpURLConnection.HTTP_FORBIDDEN ->
|
||||
if (response != null) ForbiddenException(response) else ForbiddenException(message)
|
||||
HttpURLConnection.HTTP_NOT_FOUND ->
|
||||
if (response != null) NotFoundException(response) else NotFoundException(message)
|
||||
HttpURLConnection.HTTP_CONFLICT ->
|
||||
if (response != null) ConflictException(response) else ConflictException(message)
|
||||
HttpURLConnection.HTTP_PRECON_FAILED ->
|
||||
if (response != null) PreconditionFailedException(response) else PreconditionFailedException(message)
|
||||
HttpURLConnection.HTTP_UNAVAILABLE ->
|
||||
if (response != null) ServiceUnavailableException(response) else ServiceUnavailableException(message)
|
||||
else ->
|
||||
if (response != null) HttpException(response) else HttpException(code, message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and follows up to [MAX_REDIRECTS] redirects.
|
||||
*
|
||||
* @param sendRequest called to send the request (may be called multiple times)
|
||||
*
|
||||
* @return response of the last request (whether it is a redirect or not)
|
||||
*
|
||||
* @throws DavException on HTTPS -> HTTP redirect
|
||||
*/
|
||||
internal fun followRedirects(sendRequest: () -> Response): Response {
|
||||
lateinit var response: Response
|
||||
// Describes the request being made now, not one made earlier through the
|
||||
// same object. DavResource instances are reused, and a stale value would
|
||||
// have the caller persist a URL that a later move() already superseded.
|
||||
permanentLocation = null
|
||||
// A permanent target is only permanent while every hop so far has been
|
||||
// permanent: 301 -> 302 means the resource moved to the 301's target and
|
||||
// is being served from elsewhere temporarily.
|
||||
var chainStillPermanent = true
|
||||
for (attempt in 1..MAX_REDIRECTS) {
|
||||
response = sendRequest()
|
||||
if (response.isRedirect)
|
||||
// handle 3xx Redirection
|
||||
response.use {
|
||||
val target = it.header("Location")?.let { location.resolve(it) }
|
||||
if (target != null) {
|
||||
log.fine("Redirected, new location = $target")
|
||||
|
||||
if (location.isHttps && !target.isHttps)
|
||||
throw DavException("Received redirect from HTTPS to HTTP")
|
||||
|
||||
if (chainStillPermanent && (it.code == HTTP_MOVED_PERM || it.code == HTTP_PERM_REDIRECT))
|
||||
permanentLocation = target
|
||||
else
|
||||
chainStillPermanent = false
|
||||
|
||||
location = target
|
||||
} else
|
||||
throw DavException("Redirected without new Location")
|
||||
}
|
||||
else
|
||||
break
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a 207 Multi-Status response.
|
||||
*
|
||||
* @param response will be checked for Multi-Status response
|
||||
*
|
||||
* @throws DavException if the response is not a Multi-Status response
|
||||
*/
|
||||
fun assertMultiStatus(response: Response) {
|
||||
if (response.code != HTTP_MULTISTATUS)
|
||||
throw DavException("Expected 207 Multi-Status, got ${response.code} ${response.message}", httpResponse = response)
|
||||
|
||||
val body = response.body ?:
|
||||
throw DavException("Received 207 Multi-Status without body", httpResponse = response)
|
||||
|
||||
body.contentType()?.let { mimeType ->
|
||||
if (((mimeType.type != "application" && mimeType.type != "text")) || mimeType.subtype != "xml") {
|
||||
/* Content-Type is not application/xml or text/xml although that is expected here.
|
||||
Some broken servers return an XML response with some other MIME type. So we try to see
|
||||
whether the response is maybe XML although the Content-Type is something else. */
|
||||
try {
|
||||
val firstBytes = ByteArray(XML_SIGNATURE.size)
|
||||
body.source().peek().readFully(firstBytes)
|
||||
if (XML_SIGNATURE.contentEquals(firstBytes)) {
|
||||
Dav4jvm.log.warning("Received 207 Multi-Status that seems to be XML but has MIME type $mimeType")
|
||||
|
||||
// response is OK, return and do not throw Exception below
|
||||
return
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't scan for XML signature", e)
|
||||
}
|
||||
|
||||
throw DavException("Received non-XML 207 Multi-Status", httpResponse = response)
|
||||
}
|
||||
} ?: log.warning("Received 207 Multi-Status without Content-Type, assuming XML")
|
||||
}
|
||||
|
||||
|
||||
// Multi-Status handling
|
||||
|
||||
/**
|
||||
* Processes a Multi-Status response.
|
||||
*
|
||||
* @param response response which is expected to contain a Multi-Status response
|
||||
* @param callback called for every XML response element in the Multi-Status response
|
||||
*
|
||||
* @return list of properties which have been received in the Multi-Status response, but
|
||||
* are not part of response XML elements (like `sync-token` which is returned as [SyncToken])
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error (for instance, when the response is not a Multi-Status response)
|
||||
*/
|
||||
protected fun processMultiStatus(response: Response, callback: MultiResponseCallback): List<Property> {
|
||||
checkStatus(response)
|
||||
assertMultiStatus(response)
|
||||
response.body!!.use {
|
||||
return processMultiStatus(it.charStream(), callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a Multi-Status response.
|
||||
*
|
||||
* @param reader the Multi-Status response is read from this
|
||||
* @param callback called for every XML response element in the Multi-Status response
|
||||
*
|
||||
* @return list of properties which have been received in the Multi-Status response, but
|
||||
* are not part of response XML elements (like `sync-token` which is returned as [SyncToken])
|
||||
*
|
||||
* @throws IOException on I/O error
|
||||
* @throws HttpException on HTTP error
|
||||
* @throws DavException on WebDAV error (like an invalid XML response)
|
||||
*/
|
||||
protected fun processMultiStatus(reader: Reader, callback: MultiResponseCallback): List<Property> {
|
||||
val responseProperties = mutableListOf<Property>()
|
||||
val parser = XmlUtils.newPullParser()
|
||||
|
||||
fun parseMultiStatus(): List<Property> {
|
||||
// <!ELEMENT multistatus (response*, responsedescription?,
|
||||
// sync-token?) >
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
|
||||
when (parser.propertyName()) {
|
||||
DavResponse.RESPONSE ->
|
||||
at.bitfire.dav4jvm.Response.parse(parser, location, callback)
|
||||
SyncToken.NAME ->
|
||||
XmlUtils.readText(parser)?.let {
|
||||
responseProperties += SyncToken(it)
|
||||
}
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return responseProperties
|
||||
}
|
||||
|
||||
try {
|
||||
parser.setInput(reader)
|
||||
|
||||
var eventType = parser.eventType
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == 1)
|
||||
if (parser.propertyName() == DavResponse.MULTISTATUS)
|
||||
return parseMultiStatus()
|
||||
// ignore further <multistatus> elements
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
throw DavException("Multi-Status response didn't contain multistatus XML element")
|
||||
|
||||
} catch (e: EOFException) {
|
||||
throw DavException("Incomplete multistatus XML element", e)
|
||||
} catch (e: XmlPullParserException) {
|
||||
throw DavException("Couldn't parse multistatus XML element", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* Represents an XML precondition/postcondition error. Every error has a name, which is the XML element
|
||||
* name. Subclassed errors may have more specific information available.
|
||||
*
|
||||
* At the moment, there is no logic for subclassing errors.
|
||||
*/
|
||||
class Error(
|
||||
val name: Property.Name
|
||||
): Serializable {
|
||||
|
||||
companion object {
|
||||
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "error")
|
||||
|
||||
fun parseError(parser: XmlPullParser): List<Error> {
|
||||
val names = mutableSetOf<Property.Name>()
|
||||
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
|
||||
names += Property.Name(parser.namespace, parser.name)
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return names.map { Error(it) }
|
||||
}
|
||||
|
||||
|
||||
// some pre-defined errors
|
||||
|
||||
val NEED_PRIVILEGES = Error(Property.Name(XmlUtils.NS_WEBDAV, "need-privileges"))
|
||||
val VALID_SYNC_TOKEN = Error(Property.Name(XmlUtils.NS_WEBDAV, "valid-sync-token"))
|
||||
|
||||
}
|
||||
|
||||
override fun equals(other: Any?) =
|
||||
(other is Error) && other.name == name
|
||||
|
||||
override fun hashCode() = name.hashCode()
|
||||
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Response
|
||||
import java.text.ParsePosition
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
|
||||
object HttpUtils {
|
||||
|
||||
const val httpDateFormatStr = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"
|
||||
|
||||
/**
|
||||
* RFC 7231 §7.1.1.1: an HTTP-date is always GMT.
|
||||
*
|
||||
* ⚠️ Upstream never set this, and the `GMT` in [httpDateFormatStr] is a
|
||||
* quoted *literal* — so both formatting and parsing silently used the
|
||||
* device's default zone, putting every `getlastmodified` out by the local
|
||||
* UTC offset. Upstream's suite does not test date handling at all. Local
|
||||
* fix; see dav/PROVENANCE.md change 2.
|
||||
*/
|
||||
private val GMT: TimeZone = TimeZone.getTimeZone("GMT")
|
||||
|
||||
/**
|
||||
* Built per call rather than shared. `SimpleDateFormat` is mutable and not
|
||||
* thread-safe, and upstream exposed one instance publicly: two sync workers
|
||||
* formatting a header concurrently corrupt each other through its `Calendar`,
|
||||
* and any caller could `setTimeZone` on it and undo the fix above for
|
||||
* everyone. `:dav` is earmarked for floret-kit, where concurrent workers are
|
||||
* the normal case. Local change; see dav/PROVENANCE.md change 5.
|
||||
*/
|
||||
private fun httpDateFormat() = SimpleDateFormat(httpDateFormatStr, Locale.ROOT).apply {
|
||||
timeZone = GMT
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the resource name (the last segment of the path) from an URL.
|
||||
* Empty if the resource is the base directory.
|
||||
*
|
||||
* * `dir` for `https://example.com/dir/`
|
||||
* * `file` for `https://example.com/file`
|
||||
* * `` for `https://example.com` or `https://example.com/`
|
||||
*
|
||||
* @return resource name
|
||||
*/
|
||||
fun fileName(url: HttpUrl): String {
|
||||
val pathSegments = url.pathSegments.dropLastWhile { it == "" }
|
||||
return pathSegments.lastOrNull() ?: ""
|
||||
}
|
||||
|
||||
fun listHeader(response: Response, name: String): Array<String> {
|
||||
val value = response.headers(name).joinToString(",")
|
||||
return value.split(',').filter { it.isNotEmpty() }.toTypedArray()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Formats a date for use in HTTP headers.
|
||||
*
|
||||
* @param date date to be formatted
|
||||
* @return date in HTTP-date format
|
||||
*/
|
||||
fun formatDate(date: Date): String = httpDateFormat().format(date)
|
||||
|
||||
/**
|
||||
* Parses a HTTP-date.
|
||||
*
|
||||
* @param dateStr date with format specified by RFC 7231 section 7.1.1.1
|
||||
* or in one of the obsolete formats (copied from okhttp internal date-parsing class)
|
||||
*
|
||||
* @return date, or null if date could not be parsed
|
||||
*/
|
||||
/**
|
||||
* The formats [parseDate] accepts, in order. RFC 7231 §7.1.1.1 first, then the
|
||||
* obsolete ones, then the malformed-but-real ones — copied from OkHttp's
|
||||
* internal date parser, which is where upstream took them from.
|
||||
*/
|
||||
private val dateFormats = arrayOf(
|
||||
httpDateFormatStr,
|
||||
"EEE, dd MMM yyyy HH:mm:ss zzz", // RFC 822, updated by RFC 1123 with any TZ
|
||||
"EEEE, dd-MMM-yy HH:mm:ss zzz", // RFC 850, obsoleted by RFC 1036 with any TZ
|
||||
"EEE MMM d HH:mm:ss yyyy", // ANSI C's asctime() format
|
||||
// Alternative formats.
|
||||
"EEE, dd-MMM-yyyy HH:mm:ss z",
|
||||
"EEE, dd-MMM-yyyy HH-mm-ss z",
|
||||
"EEE, dd MMM yy HH:mm:ss z",
|
||||
"EEE dd-MMM-yyyy HH:mm:ss z",
|
||||
"EEE dd MMM yyyy HH:mm:ss z",
|
||||
"EEE dd-MMM-yyyy HH-mm-ss z",
|
||||
"EEE dd-MMM-yy HH:mm:ss z",
|
||||
"EEE dd MMM yy HH:mm:ss z",
|
||||
"EEE,dd-MMM-yy HH:mm:ss z",
|
||||
"EEE,dd-MMM-yyyy HH:mm:ss z",
|
||||
"EEE, dd-MM-yyyy HH:mm:ss z",
|
||||
/* RI bug 6641315 claims a cookie of this format was once served by www.yahoo.com */
|
||||
"EEE MMM d yyyy HH:mm:ss z",
|
||||
)
|
||||
|
||||
/**
|
||||
* Parses an HTTP-date.
|
||||
*
|
||||
* @param dateStr date in the format of RFC 7231 §7.1.1.1, or one of the
|
||||
* obsolete formats
|
||||
*
|
||||
* @return the date, or null if none of the formats matched
|
||||
*/
|
||||
fun parseDate(dateStr: String): Date? {
|
||||
// Upstream used commons-lang3 DateUtils.parseDate for exactly this loop,
|
||||
// and that import was the library's only use of the dependency. See
|
||||
// dav/PROVENANCE.md change 1.
|
||||
for (format in dateFormats) {
|
||||
val parser = SimpleDateFormat(format, Locale.US).apply {
|
||||
// GMT is the default for the formats that carry no zone — the
|
||||
// quoted-literal 'GMT' one and ANSI C's asctime(). The patterns
|
||||
// with a real `z` still take the zone from the input.
|
||||
timeZone = GMT
|
||||
// The obsolete formats carry two-digit years, and lenient parsing
|
||||
// is what lets "dd MMM yy" resolve at all.
|
||||
isLenient = true
|
||||
}
|
||||
val position = ParsePosition(0)
|
||||
val parsed = parser.parse(dateStr, position)
|
||||
// ⚠️ The whole string must be consumed. `DateUtils.parseDate` enforced
|
||||
// this and `SimpleDateFormat.parse` does not: pattern 1 ends in the
|
||||
// *literal* 'GMT', so "…07:28:00 GMT+02:00" matches it as a prefix and
|
||||
// the offset is silently discarded — reintroducing the exact two-hour
|
||||
// error change 2 exists to fix.
|
||||
if (parsed != null && position.index == dateStr.length) return parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.Response.Companion.STATUS
|
||||
import at.bitfire.dav4jvm.XmlUtils.propertyName
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.internal.http.StatusLine
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.net.ProtocolException
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Represents a WebDAV propstat XML element.
|
||||
*
|
||||
* <!ELEMENT propstat (prop, status, error?, responsedescription?) >
|
||||
*/
|
||||
data class PropStat(
|
||||
val properties: List<Property>,
|
||||
val status: StatusLine,
|
||||
val error: List<Error>? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "propstat")
|
||||
|
||||
private val ASSUMING_OK = StatusLine(Protocol.HTTP_1_1, 200, "Assuming OK")
|
||||
private val INVALID_STATUS = StatusLine(Protocol.HTTP_1_1, 500, "Invalid status line")
|
||||
|
||||
fun parse(parser: XmlPullParser): PropStat {
|
||||
val depth = parser.depth
|
||||
|
||||
var status: StatusLine? = null
|
||||
val prop = LinkedList<Property>()
|
||||
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
|
||||
when (parser.propertyName()) {
|
||||
DavResource.PROP ->
|
||||
prop.addAll(Property.parse(parser))
|
||||
STATUS ->
|
||||
status = try {
|
||||
StatusLine.parse(parser.nextText())
|
||||
} catch (e: ProtocolException) {
|
||||
// invalid status line, treat as 500 Internal Server Error
|
||||
INVALID_STATUS
|
||||
}
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return PropStat(prop, status ?: ASSUMING_OK)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
fun isSuccess() = status.code/100 == 2
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm.log
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.Serializable
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Represents a WebDAV property.
|
||||
*
|
||||
* Every [Property] must define a static field (use `@JvmStatic`) called `NAME` of type [Property.Name],
|
||||
* which will be accessed by reflection.
|
||||
*/
|
||||
interface Property {
|
||||
|
||||
data class Name(
|
||||
val namespace: String,
|
||||
val name: String
|
||||
): Serializable {
|
||||
|
||||
override fun toString() = "$namespace:$name"
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun parse(parser: XmlPullParser): List<Property> {
|
||||
// <!ELEMENT prop ANY >
|
||||
val depth = parser.depth
|
||||
val properties = LinkedList<Property>()
|
||||
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) {
|
||||
val depthBeforeParsing = parser.depth
|
||||
val name = Property.Name(parser.namespace, parser.name)
|
||||
val property = PropertyRegistry.create(name, parser)
|
||||
assert(parser.depth == depthBeforeParsing)
|
||||
|
||||
if (property != null) {
|
||||
properties.add(property)
|
||||
} else
|
||||
log.fine("Ignoring unknown property $name")
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return properties
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
|
||||
interface PropertyFactory {
|
||||
|
||||
/**
|
||||
* Name of the Property the factory creates,
|
||||
* e.g. Property.Name("DAV:", "displayname") if the factory creates DisplayName objects)
|
||||
*/
|
||||
fun getName(): Property.Name
|
||||
|
||||
/**
|
||||
* Parses XML of a property and returns its data class.
|
||||
* @throws XmlPullParserException in case of parsing errors
|
||||
*/
|
||||
fun create(parser: XmlPullParser): Property?
|
||||
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.property.*
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.util.logging.Level
|
||||
|
||||
object PropertyRegistry {
|
||||
|
||||
private val factories = mutableMapOf<Property.Name, PropertyFactory>()
|
||||
|
||||
init {
|
||||
Dav4jvm.log.info("Registering DAV property factories")
|
||||
registerDefaultFactories()
|
||||
}
|
||||
|
||||
private fun registerDefaultFactories() {
|
||||
register(listOf(
|
||||
AddMember.Factory,
|
||||
AddressbookDescription.Factory,
|
||||
AddressbookHomeSet.Factory,
|
||||
AddressData.Factory,
|
||||
CalendarColor.Factory,
|
||||
CalendarData.Factory,
|
||||
CalendarDescription.Factory,
|
||||
CalendarHomeSet.Factory,
|
||||
CalendarProxyReadFor.Factory,
|
||||
CalendarProxyWriteFor.Factory,
|
||||
CalendarTimezone.Factory,
|
||||
CalendarUserAddressSet.Factory,
|
||||
CreationDate.Factory,
|
||||
CurrentUserPrincipal.Factory,
|
||||
CurrentUserPrivilegeSet.Factory,
|
||||
DisplayName.Factory,
|
||||
GetContentLength.Factory,
|
||||
GetContentType.Factory,
|
||||
GetCTag.Factory,
|
||||
GetETag.Factory,
|
||||
GetLastModified.Factory,
|
||||
GroupMembership.Factory,
|
||||
MaxICalendarSize.Factory,
|
||||
MaxVCardSize.Factory,
|
||||
Owner.Factory,
|
||||
QuotaAvailableBytes.Factory,
|
||||
QuotaUsedBytes.Factory,
|
||||
ResourceType.Factory,
|
||||
ScheduleTag.Factory,
|
||||
Source.Factory,
|
||||
SupportedAddressData.Factory,
|
||||
SupportedCalendarComponentSet.Factory,
|
||||
SupportedCalendarData.Factory,
|
||||
SupportedReportSet.Factory,
|
||||
SyncToken.Factory
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Registers a property factory, so that objects for all WebDAV properties which are handled
|
||||
* by this factory can be created.
|
||||
*
|
||||
* @param factory property factory to be registered
|
||||
*/
|
||||
fun register(factory: PropertyFactory) {
|
||||
Dav4jvm.log.fine("Registering ${factory::class.java.name} for ${factory.getName()}")
|
||||
factories[factory.getName()] = factory
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers some property factories, so that objects for all WebDAV properties which are handled
|
||||
* by these factories can be created.
|
||||
|
||||
* @param factories property factories to be registered
|
||||
*/
|
||||
fun register(factories: Iterable<PropertyFactory>) {
|
||||
factories.forEach {
|
||||
register(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun create(name: Property.Name, parser: XmlPullParser) =
|
||||
try {
|
||||
factories[name]?.create(parser)
|
||||
} catch (e: XmlPullParserException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $name", e)
|
||||
null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
object QuotedStringUtils {
|
||||
|
||||
fun asQuotedString(raw: String) =
|
||||
"\"" + raw.replace("\\" ,"\\\\").replace("\"", "\\\"") + "\""
|
||||
|
||||
fun decodeQuotedString(quoted: String): String {
|
||||
/* quoted-string = ( <"> *(qdtext | quoted-pair ) <"> )
|
||||
qdtext = <any TEXT except <">>
|
||||
quoted-pair = "\" CHAR
|
||||
*/
|
||||
|
||||
val len = quoted.length
|
||||
if (len >= 2 && quoted[0] == '"' && quoted[len-1] == '"') {
|
||||
val result = StringBuffer(len)
|
||||
var pos = 1
|
||||
while (pos < len-1) {
|
||||
var c = quoted[pos]
|
||||
if (c == '\\' && pos != len-2)
|
||||
c = quoted[++pos]
|
||||
result.append(c)
|
||||
pos++
|
||||
}
|
||||
return result.toString()
|
||||
} else
|
||||
return quoted
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm.log
|
||||
import at.bitfire.dav4jvm.XmlUtils.propertyName
|
||||
import at.bitfire.dav4jvm.property.ResourceType
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.internal.http.StatusLine
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.net.ProtocolException
|
||||
|
||||
/**
|
||||
* Represents a WebDAV response XML Element.
|
||||
*
|
||||
* <!ELEMENT response (href, ((href*, status)|(propstat+)),
|
||||
* error?, responsedescription? , location?) >
|
||||
*/
|
||||
data class Response(
|
||||
/**
|
||||
* URL of the requested resource. For instance, if `this` is a result
|
||||
* of a PROPFIND request, the `requestedUrl` would be the URL where the
|
||||
* PROPFIND request has been sent to (usually the collection URL).
|
||||
*/
|
||||
val requestedUrl: HttpUrl,
|
||||
|
||||
/**
|
||||
* URL of this response (`href` element)
|
||||
*/
|
||||
val href: HttpUrl,
|
||||
|
||||
/**
|
||||
* status of this response (`status` XML element)
|
||||
*/
|
||||
val status: StatusLine?,
|
||||
|
||||
/**
|
||||
* property/status elements (`propstat` XML elements)
|
||||
*/
|
||||
val propstat: List<PropStat>,
|
||||
|
||||
/**
|
||||
* list of precondition/postcondition elements (`error` XML elements)
|
||||
*/
|
||||
val error: List<Error>? = null,
|
||||
|
||||
/**
|
||||
* new location of this response (`location` XML element), used for redirects
|
||||
*/
|
||||
val newLocation: HttpUrl? = null
|
||||
) {
|
||||
|
||||
enum class HrefRelation {
|
||||
SELF, MEMBER, OTHER
|
||||
}
|
||||
|
||||
/**
|
||||
* All properties from propstat elements with empty status or status code 2xx.
|
||||
*/
|
||||
val properties: List<Property> by lazy {
|
||||
if (isSuccess())
|
||||
propstat.filter { it.isSuccess() }.map { it.properties }.flatten()
|
||||
else
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to get a certain property with empty status or status code 2xx
|
||||
* from the current response.
|
||||
*/
|
||||
operator fun<T: Property> get(clazz: Class<T>) =
|
||||
properties.filterIsInstance(clazz).firstOrNull()
|
||||
|
||||
/**
|
||||
* Returns whether the request was successful.
|
||||
*
|
||||
* @return true: no status XML element or status code 2xx; false: otherwise
|
||||
*/
|
||||
fun isSuccess() = status == null || status.code/100 == 2
|
||||
|
||||
/**
|
||||
* Returns the name (last path segment) of the resource.
|
||||
*/
|
||||
fun hrefName() = HttpUtils.fileName(href)
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
val RESPONSE = Property.Name(XmlUtils.NS_WEBDAV, "response")
|
||||
val MULTISTATUS = Property.Name(XmlUtils.NS_WEBDAV, "multistatus")
|
||||
val STATUS = Property.Name(XmlUtils.NS_WEBDAV, "status")
|
||||
val LOCATION = Property.Name(XmlUtils.NS_WEBDAV, "location")
|
||||
|
||||
/**
|
||||
* Parses an XML response element.
|
||||
*/
|
||||
fun parse(parser: XmlPullParser, location: HttpUrl, callback: MultiResponseCallback) {
|
||||
val depth = parser.depth
|
||||
|
||||
var href: HttpUrl? = null
|
||||
var status: StatusLine? = null
|
||||
val propStat = mutableListOf<PropStat>()
|
||||
var error: List<Error>? = null
|
||||
var newLocation: HttpUrl? = null
|
||||
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth+1)
|
||||
when (parser.propertyName()) {
|
||||
DavResource.HREF -> {
|
||||
var sHref = parser.nextText()
|
||||
if (!sHref.startsWith("/")) {
|
||||
/* According to RFC 4918 8.3 URL Handling, only absolute paths are allowed as relative
|
||||
URLs. However, some servers reply with relative paths. */
|
||||
val firstColon = sHref.indexOf(':')
|
||||
if (firstColon != -1) {
|
||||
/* There are some servers which return not only relative paths, but relative paths like "a:b.vcf",
|
||||
which would be interpreted as scheme: "a", scheme-specific part: "b.vcf" normally.
|
||||
For maximum compatibility, we prefix all relative paths which contain ":" (but not "://"),
|
||||
with "./" to allow resolving by HttpUrl. */
|
||||
var hierarchical = false
|
||||
try {
|
||||
if (sHref.substring(firstColon, firstColon + 3) == "://")
|
||||
hierarchical = true
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
// no "://"
|
||||
}
|
||||
if (!hierarchical)
|
||||
sHref = "./$sHref"
|
||||
}
|
||||
}
|
||||
href = location.resolve(sHref)
|
||||
}
|
||||
STATUS ->
|
||||
status = try {
|
||||
StatusLine.parse(parser.nextText())
|
||||
} catch(e: ProtocolException) {
|
||||
log.warning("Invalid status line, treating as HTTP error 500")
|
||||
StatusLine(Protocol.HTTP_1_1, 500, "Invalid status line")
|
||||
}
|
||||
PropStat.NAME ->
|
||||
PropStat.parse(parser).let { propStat += it }
|
||||
Error.NAME ->
|
||||
error = Error.parseError(parser)
|
||||
LOCATION ->
|
||||
newLocation = parser.nextText().toHttpUrlOrNull()
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
if (href == null) {
|
||||
log.warning("Ignoring XML response element without valid href")
|
||||
return
|
||||
}
|
||||
|
||||
// if we know this resource is a collection, make sure href has a trailing slash
|
||||
// (for clarity and resolving relative paths)
|
||||
propStat.filter { it.isSuccess() }
|
||||
.map { it.properties }
|
||||
.filterIsInstance(ResourceType::class.java)
|
||||
.firstOrNull()
|
||||
?.let { type ->
|
||||
if (type.types.contains(ResourceType.COLLECTION))
|
||||
href = UrlUtils.withTrailingSlash(href!!)
|
||||
}
|
||||
|
||||
//log.log(Level.FINE, "Received properties for $href", if (status != null) status else propStat)
|
||||
|
||||
// Which resource does this <response> represent?
|
||||
val relation = when {
|
||||
UrlUtils.equals(UrlUtils.omitTrailingSlash(href!!), UrlUtils.omitTrailingSlash(location)) ->
|
||||
HrefRelation.SELF
|
||||
else -> {
|
||||
if (location.scheme == href!!.scheme && location.host == href!!.host && location.port == href!!.port) {
|
||||
val locationSegments = location.pathSegments
|
||||
val hrefSegments = href!!.pathSegments
|
||||
|
||||
// don't compare trailing slash segment ("")
|
||||
var nBasePathSegments = locationSegments.size
|
||||
if (locationSegments[nBasePathSegments-1] == "")
|
||||
nBasePathSegments--
|
||||
|
||||
/* example: locationSegments = [ "davCollection", "" ]
|
||||
nBasePathSegments = 1
|
||||
hrefSegments = [ "davCollection", "aMember" ]
|
||||
*/
|
||||
var relation = HrefRelation.OTHER
|
||||
if (hrefSegments.size > nBasePathSegments) {
|
||||
val sameBasePath = (0 until nBasePathSegments).none { locationSegments[it] != hrefSegments[it] }
|
||||
if (sameBasePath)
|
||||
relation = HrefRelation.MEMBER
|
||||
}
|
||||
|
||||
relation
|
||||
} else
|
||||
HrefRelation.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
callback.onResponse(
|
||||
Response(
|
||||
location,
|
||||
href!!,
|
||||
status,
|
||||
propStat,
|
||||
error,
|
||||
newLocation
|
||||
),
|
||||
relation)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
|
||||
object UrlUtils {
|
||||
|
||||
/**
|
||||
* Compares two URLs in WebDAV context. If two URLs are considered *equal*, both
|
||||
* represent the same WebDAV resource (e.g. `http://host:80/folder1` and `http://HOST/folder1#somefragment`).
|
||||
*
|
||||
* It decodes %xx entities in the path, so `/my@dav` and `/my%40dav` are considered the same.
|
||||
* This is important to process multi-status responses: some servers serve a multi-status
|
||||
* response with href `/my@dav` when you request `/my%40dav` and vice versa.
|
||||
*
|
||||
* This method does not deal with trailing slashes, so if you want to compare collection URLs,
|
||||
* make sure they both (don't) have a trailing slash before calling this method, for instance
|
||||
* with [omitTrailingSlash] or [withTrailingSlash].
|
||||
*
|
||||
* @param url1 the first URL to be compared
|
||||
* @param url2 the second URL to be compared
|
||||
*
|
||||
* @return whether [url1] and [url2] (usually) represent the same WebDAV resource
|
||||
*/
|
||||
fun equals(url1: HttpUrl, url2: HttpUrl): Boolean {
|
||||
// if okhttp thinks the two URLs are equal, they're in any case
|
||||
// (and it's a simple String comparison)
|
||||
if (url1 == url2)
|
||||
return true
|
||||
|
||||
// drop #fragment parts and convert to URI
|
||||
val uri1 = url1.newBuilder().fragment(null).build().toUri()
|
||||
val uri2 = url2.newBuilder().fragment(null).build().toUri()
|
||||
|
||||
return try {
|
||||
val decoded1 = URI(uri1.scheme, uri1.schemeSpecificPart, uri1.fragment)
|
||||
val decoded2 = URI(uri2.scheme, uri2.schemeSpecificPart, uri2.fragment)
|
||||
decoded1 == decoded2
|
||||
} catch (e: URISyntaxException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the first-level domain name (without subdomains) from a host name.
|
||||
* Also removes trailing dots.
|
||||
*
|
||||
* @param host name (e.g. `www.example.com.`)
|
||||
*
|
||||
* @return domain name (e.g. `example.com`)
|
||||
*/
|
||||
fun hostToDomain(host: String?): String? {
|
||||
if (host == null)
|
||||
return null
|
||||
|
||||
// remove optional dot at end
|
||||
val withoutTrailingDot = host.removeSuffix(".")
|
||||
|
||||
// split into labels
|
||||
val labels = withoutTrailingDot.split('.')
|
||||
return if (labels.size >= 2) {
|
||||
labels[labels.size - 2] + "." + labels[labels.size - 1]
|
||||
} else
|
||||
withoutTrailingDot
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that a given URL doesn't have a trailing slash after member names.
|
||||
* If the path is the root path (`/`), the slash is preserved.
|
||||
*
|
||||
* @param url URL to process (e.g. 'http://host/test1/')
|
||||
*
|
||||
* @return URL without trailing slash (except when the path is the root path), e.g. `http://host/test1`
|
||||
*/
|
||||
fun omitTrailingSlash(url: HttpUrl): HttpUrl {
|
||||
val idxLast = url.pathSize - 1
|
||||
val hasTrailingSlash = url.pathSegments[idxLast] == ""
|
||||
|
||||
return if (hasTrailingSlash)
|
||||
url.newBuilder().removePathSegment(idxLast).build()
|
||||
else
|
||||
url
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that a given URL has a trailing slash after member names.
|
||||
*
|
||||
* @param url URL to process (e.g. 'http://host/test1')
|
||||
*
|
||||
* @return URL with trailing slash, e.g. `http://host/test1/`
|
||||
*/
|
||||
fun withTrailingSlash(url: HttpUrl): HttpUrl {
|
||||
val idxLast = url.pathSize - 1
|
||||
val hasTrailingSlash = url.pathSegments[idxLast] == ""
|
||||
|
||||
return if (hasTrailingSlash)
|
||||
url
|
||||
else
|
||||
url.newBuilder().addPathSegment("").build()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import org.xmlpull.v1.XmlSerializer
|
||||
import java.io.IOException
|
||||
|
||||
object XmlUtils {
|
||||
|
||||
const val NS_WEBDAV = "DAV:"
|
||||
const val NS_CALDAV = "urn:ietf:params:xml:ns:caldav"
|
||||
const val NS_CARDDAV = "urn:ietf:params:xml:ns:carddav"
|
||||
const val NS_APPLE_ICAL = "http://apple.com/ns/ical/"
|
||||
const val NS_CALENDARSERVER = "http://calendarserver.org/ns/"
|
||||
|
||||
private val factory: XmlPullParserFactory
|
||||
init {
|
||||
try {
|
||||
factory = XmlPullParserFactory.newInstance()
|
||||
factory.isNamespaceAware = true
|
||||
} catch (e: XmlPullParserException) {
|
||||
throw RuntimeException("Couldn't create XmlPullParserFactory", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun newPullParser() = factory.newPullParser()!!
|
||||
fun newSerializer() = factory.newSerializer()!!
|
||||
|
||||
|
||||
@Throws(IOException::class, XmlPullParserException::class)
|
||||
fun processTag(parser: XmlPullParser, name: Property.Name, processor: () -> Unit) {
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.propertyName() == name)
|
||||
processor()
|
||||
eventType = parser.next()
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class, XmlPullParserException::class)
|
||||
fun readText(parser: XmlPullParser): String? {
|
||||
var text: String? = null
|
||||
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.TEXT && parser.depth == depth)
|
||||
text = parser.text
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
@Throws(IOException::class, XmlPullParserException::class)
|
||||
fun readTextProperty(parser: XmlPullParser, name: Property.Name): String? {
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
var result: String? = null
|
||||
while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.propertyName() == name)
|
||||
result = parser.nextText()
|
||||
eventType = parser.next()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Throws(IOException::class, XmlPullParserException::class)
|
||||
fun readTextPropertyList(parser: XmlPullParser, name: Property.Name, list: MutableCollection<String>) {
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.propertyName() == name)
|
||||
list.add(parser.nextText())
|
||||
eventType = parser.next()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun XmlSerializer.insertTag(name: Property.Name, contentGenerator: XmlSerializer.() -> Unit = {}) {
|
||||
startTag(name.namespace, name.name)
|
||||
contentGenerator(this)
|
||||
endTag(name.namespace, name.name)
|
||||
}
|
||||
|
||||
fun XmlPullParser.propertyName(): Property.Name {
|
||||
val propNs = namespace
|
||||
val propName = name
|
||||
if (propNs == null || propName == null)
|
||||
throw IllegalStateException("Current event must be START_TAG or END_TAG")
|
||||
return Property.Name(propNs, propName)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.Response
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class ConflictException: HttpException {
|
||||
|
||||
constructor(response: Response): super(response)
|
||||
constructor(message: String?): super(HttpURLConnection.HTTP_CONFLICT, message)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Error
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.propertyName
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.Serializable
|
||||
import java.lang.Long.min
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Signals that an error occurred during a WebDAV-related operation.
|
||||
*
|
||||
* This could be a logical error like when a required ETag has not been
|
||||
* received, but also an explicit HTTP error.
|
||||
*/
|
||||
open class DavException @JvmOverloads constructor(
|
||||
message: String,
|
||||
ex: Throwable? = null,
|
||||
|
||||
/**
|
||||
* An associated HTTP [Response]. Will be closed after evaluation.
|
||||
*/
|
||||
httpResponse: Response? = null
|
||||
): Exception(message, ex), Serializable {
|
||||
|
||||
companion object {
|
||||
|
||||
const val MAX_EXCERPT_SIZE = 10*1024 // don't dump more than 20 kB
|
||||
|
||||
fun isPlainText(type: MediaType) =
|
||||
type.type == "text" ||
|
||||
(type.type == "application" && type.subtype in arrayOf("html", "xml"))
|
||||
|
||||
}
|
||||
|
||||
var request: String? = null
|
||||
|
||||
/**
|
||||
* Body excerpt of [request] (up to [MAX_EXCERPT_SIZE] characters). Only available
|
||||
* if the HTTP request body was textual content and could be read again.
|
||||
*/
|
||||
var requestBody: String? = null
|
||||
private set
|
||||
|
||||
val response: String?
|
||||
|
||||
/**
|
||||
* Body excerpt of [response] (up to [MAX_EXCERPT_SIZE] characters). Only available
|
||||
* if the HTTP response body was textual content.
|
||||
*/
|
||||
var responseBody: String? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Precondition/postcondition XML elements which have been found in the XML response.
|
||||
*/
|
||||
var errors: List<Error> = listOf()
|
||||
private set
|
||||
|
||||
|
||||
init {
|
||||
if (httpResponse != null) {
|
||||
response = httpResponse.toString()
|
||||
|
||||
try {
|
||||
request = httpResponse.request.toString()
|
||||
|
||||
httpResponse.request.body?.let { body ->
|
||||
body.contentType()?.let { type ->
|
||||
if (isPlainText(type)) {
|
||||
val buffer = Buffer()
|
||||
body.writeTo(buffer)
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
buffer.writeTo(baos, min(buffer.size, MAX_EXCERPT_SIZE.toLong()))
|
||||
|
||||
requestBody = baos.toString(type.charset(Charsets.UTF_8)!!.name())
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't read HTTP request", e)
|
||||
requestBody = "Couldn't read HTTP request: ${e.message}"
|
||||
}
|
||||
|
||||
try {
|
||||
// save response body excerpt
|
||||
if (httpResponse.body?.source() != null) {
|
||||
// response body has a source
|
||||
|
||||
httpResponse.peekBody(MAX_EXCERPT_SIZE.toLong()).let { body ->
|
||||
body.contentType()?.let { mimeType ->
|
||||
if (isPlainText(mimeType))
|
||||
responseBody = body.string()
|
||||
}
|
||||
}
|
||||
|
||||
httpResponse.body?.use { body ->
|
||||
body.contentType()?.let {
|
||||
if (it.type in arrayOf("application", "text") && it.subtype == "xml") {
|
||||
// look for precondition/postcondition XML elements
|
||||
try {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(body.charStream())
|
||||
|
||||
var eventType = parser.eventType
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == 1)
|
||||
if (parser.propertyName() == Error.NAME)
|
||||
errors = Error.parseError(parser)
|
||||
eventType = parser.next()
|
||||
}
|
||||
} catch (e: XmlPullParserException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse XML response", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't read HTTP response", e)
|
||||
responseBody = "Couldn't read HTTP response: ${e.message}"
|
||||
} finally {
|
||||
httpResponse.body?.close()
|
||||
}
|
||||
} else
|
||||
response = null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.Response
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class ForbiddenException: HttpException {
|
||||
|
||||
constructor(response: Response): super(response)
|
||||
constructor(message: String?): super(HttpURLConnection.HTTP_FORBIDDEN, message)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.Response
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class GoneException: HttpException {
|
||||
|
||||
constructor(response: Response): super(response)
|
||||
constructor(message: String?): super(HttpURLConnection.HTTP_GONE, message)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Signals that a HTTP error was sent by the server.
|
||||
*/
|
||||
open class HttpException: DavException {
|
||||
|
||||
var code: Int
|
||||
|
||||
constructor(response: Response): super(
|
||||
"HTTP ${response.code} ${response.message}",
|
||||
httpResponse = response
|
||||
) {
|
||||
code = response.code
|
||||
}
|
||||
|
||||
constructor(code: Int, message: String?): super("HTTP $code $message") {
|
||||
this.code = code
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.Response
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class NotFoundException: HttpException {
|
||||
|
||||
constructor(response: Response): super(response)
|
||||
constructor(message: String?): super(HttpURLConnection.HTTP_NOT_FOUND, message)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.Response
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class PreconditionFailedException: HttpException {
|
||||
|
||||
constructor(response: Response): super(response)
|
||||
constructor(message: String?): super(HttpURLConnection.HTTP_PRECON_FAILED, message)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.HttpUtils
|
||||
import okhttp3.Response
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.*
|
||||
|
||||
class ServiceUnavailableException: HttpException {
|
||||
|
||||
var retryAfter: Date? = null
|
||||
|
||||
constructor(message: String?): super(HttpURLConnection.HTTP_UNAVAILABLE, message)
|
||||
|
||||
constructor(response: Response): super(response) {
|
||||
// Retry-After = "Retry-After" ":" ( HTTP-date | delta-seconds )
|
||||
// HTTP-date = rfc1123-date | rfc850-date | asctime-date
|
||||
|
||||
response.header("Retry-After")?.let { after ->
|
||||
retryAfter = HttpUtils.parseDate(after) ?:
|
||||
// not a HTTP-date, must be delta-seconds
|
||||
try {
|
||||
val seconds = Integer.parseInt(after)
|
||||
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.SECOND, seconds)
|
||||
cal.time
|
||||
|
||||
} catch (ignored: NumberFormatException) {
|
||||
Dav4jvm.log.warning("Received Retry-After which was not a HTTP-date nor delta-seconds: $after")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.Response
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class UnauthorizedException: HttpException {
|
||||
|
||||
constructor(response: Response): super(response)
|
||||
constructor(message: String?): super(HttpURLConnection.HTTP_UNAUTHORIZED, message)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
/**
|
||||
* Defined in RFC 5995 3.2.1 DAV:add-member Property (Protected).
|
||||
*/
|
||||
data class AddMember(
|
||||
val href: String?
|
||||
): Property {
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "add-member")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
AddMember(XmlUtils.readTextProperty(parser, DavResource.HREF))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class AddressData(
|
||||
val card: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "address-data")
|
||||
|
||||
// attributes
|
||||
const val CONTENT_TYPE = "content-type"
|
||||
const val VERSION = "version"
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT address-data (#PCDATA)>
|
||||
AddressData(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class AddressbookDescription(
|
||||
var description: String? = null
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-description")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT addressbook-description (#PCDATA)>
|
||||
AddressbookDescription(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class AddressbookHomeSet: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-home-set")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, AddressbookHomeSet())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.logging.Level
|
||||
import java.util.regex.Pattern
|
||||
|
||||
data class CalendarColor(
|
||||
val color: Int
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_APPLE_ICAL, "calendar-color")
|
||||
|
||||
private val PATTERN = Pattern.compile("#?(\\p{XDigit}{6})(\\p{XDigit}{2})?")!!
|
||||
|
||||
/**
|
||||
* Converts a WebDAV color from one of these formats:
|
||||
* #RRGGBB (alpha = 0xFF)
|
||||
* RRGGBB (alpha = 0xFF)
|
||||
* #RRGGBBAA
|
||||
* RRGGBBAA
|
||||
* to an [Int] with alpha.
|
||||
*/
|
||||
@Throws(IllegalArgumentException::class)
|
||||
fun parseARGBColor(davColor: String): Int {
|
||||
val m = PATTERN.matcher(davColor)
|
||||
if (m.find()) {
|
||||
val color_rgb = Integer.parseInt(m.group(1), 16)
|
||||
val color_alpha = m.group(2)?.let { Integer.parseInt(m.group(2), 16) and 0xFF } ?: 0xFF
|
||||
return (color_alpha shl 24) or color_rgb
|
||||
} else
|
||||
throw IllegalArgumentException("Couldn't parse color value: $davColor")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): CalendarColor? {
|
||||
XmlUtils.readText(parser)?.let {
|
||||
try {
|
||||
return CalendarColor(parseARGBColor(it))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse color, ignoring", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class CalendarData(
|
||||
val iCalendar: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-data")
|
||||
|
||||
// attributes
|
||||
const val CONTENT_TYPE = "content-type"
|
||||
const val VERSION = "version"
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT calendar-data (#PCDATA)>
|
||||
CalendarData(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class CalendarDescription(
|
||||
val description: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-description")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT calendar-description (#PCDATA)>
|
||||
CalendarDescription(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class CalendarHomeSet: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-home-set")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, CalendarHomeSet())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class CalendarProxyReadFor: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "calendar-proxy-read-for")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, CalendarProxyReadFor())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class CalendarProxyWriteFor: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "calendar-proxy-write-for")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, CalendarProxyWriteFor())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class CalendarTimezone(
|
||||
val vTimeZone: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-timezone")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT calendar-timezone (#PCDATA)>
|
||||
CalendarTimezone(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class CalendarUserAddressSet: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-user-address-set")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, CalendarUserAddressSet())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class CreationDate(
|
||||
var creationDate: String
|
||||
): Property {
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "creationdate")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): CreationDate? {
|
||||
XmlUtils.readText(parser)?.let { it ->
|
||||
return CreationDate(it)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
// see RFC 5397: WebDAV Current Principal Extension
|
||||
|
||||
data class CurrentUserPrincipal(
|
||||
val href: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "current-user-principal")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): CurrentUserPrincipal {
|
||||
// <!ELEMENT current-user-principal (unauthenticated | href)>
|
||||
var href: String? = null
|
||||
XmlUtils.processTag(parser, DavResource.HREF) {
|
||||
href = XmlUtils.readText(parser)
|
||||
}
|
||||
return CurrentUserPrincipal(href)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.propertyName
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class CurrentUserPrivilegeSet(
|
||||
// not all privileges from RFC 3744 are implemented by now
|
||||
// feel free to add more if you need them for your project
|
||||
var mayRead: Boolean = false,
|
||||
var mayWriteProperties: Boolean = false,
|
||||
var mayWriteContent: Boolean = false,
|
||||
var mayBind: Boolean = false,
|
||||
var mayUnbind: Boolean = false
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "current-user-privilege-set")
|
||||
|
||||
val PRIVILEGE = Property.Name(XmlUtils.NS_WEBDAV, "privilege")
|
||||
val READ = Property.Name(XmlUtils.NS_WEBDAV, "read")
|
||||
val WRITE = Property.Name(XmlUtils.NS_WEBDAV, "write")
|
||||
val WRITE_PROPERTIES = Property.Name(XmlUtils.NS_WEBDAV, "write-properties")
|
||||
val WRITE_CONTENT = Property.Name(XmlUtils.NS_WEBDAV, "write-content")
|
||||
val BIND = Property.Name(XmlUtils.NS_WEBDAV, "bind")
|
||||
val UNBIND = Property.Name(XmlUtils.NS_WEBDAV, "unbind")
|
||||
val ALL = Property.Name(XmlUtils.NS_WEBDAV, "all")
|
||||
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): CurrentUserPrivilegeSet? {
|
||||
// <!ELEMENT current-user-privilege-set (privilege*)>
|
||||
// <!ELEMENT privilege ANY>
|
||||
val privs = CurrentUserPrivilegeSet()
|
||||
|
||||
XmlUtils.processTag(parser, PRIVILEGE) {
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
|
||||
when (parser.propertyName()) {
|
||||
READ ->
|
||||
privs.mayRead = true
|
||||
WRITE -> {
|
||||
privs.mayBind = true
|
||||
privs.mayUnbind = true
|
||||
privs.mayWriteProperties = true
|
||||
privs.mayWriteContent = true
|
||||
}
|
||||
WRITE_PROPERTIES ->
|
||||
privs.mayWriteProperties = true
|
||||
WRITE_CONTENT ->
|
||||
privs.mayWriteContent = true
|
||||
BIND ->
|
||||
privs.mayBind = true
|
||||
UNBIND ->
|
||||
privs.mayUnbind = true
|
||||
ALL -> {
|
||||
privs.mayRead = true
|
||||
privs.mayBind = true
|
||||
privs.mayUnbind = true
|
||||
privs.mayWriteProperties = true
|
||||
privs.mayWriteContent = true
|
||||
}
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
}
|
||||
|
||||
return privs
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class DisplayName(
|
||||
val displayName: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "displayname")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT displayname (#PCDATA) >
|
||||
DisplayName(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class GetCTag(
|
||||
val cTag: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "getctag")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
GetCTag(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.logging.Level
|
||||
|
||||
data class GetContentLength(
|
||||
val contentLength: Long
|
||||
) : Property {
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getcontentlength")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): GetContentLength? {
|
||||
XmlUtils.readText(parser)?.let { valueStr ->
|
||||
try {
|
||||
return GetContentLength(valueStr.toLong())
|
||||
} catch(e: NumberFormatException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class GetContentType(
|
||||
val type: MediaType?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getcontenttype")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT getcontenttype (#PCDATA) >
|
||||
GetContentType(XmlUtils.readText(parser)?.toMediaTypeOrNull())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.QuotedStringUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import okhttp3.Response
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
/**
|
||||
* The GetETag property.
|
||||
*
|
||||
* Can also be used to parse ETags from HTTP responses – just pass the raw ETag
|
||||
* header value to the constructor and then use [eTag] and [weak].
|
||||
*/
|
||||
class GetETag(
|
||||
rawETag: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getetag")
|
||||
|
||||
fun fromResponse(response: Response) =
|
||||
response.header("ETag")?.let { GetETag(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The parsed eTag value. May be null if the tag is weak.
|
||||
*/
|
||||
val eTag: String?
|
||||
|
||||
/**
|
||||
* If the tag is weak. May be null if the tag passed is null.
|
||||
*/
|
||||
val weak: Boolean?
|
||||
|
||||
init {
|
||||
/* entity-tag = [ weak ] opaque-tag
|
||||
weak = "W/"
|
||||
opaque-tag = quoted-string
|
||||
*/
|
||||
var tag: String? = rawETag
|
||||
if (tag != null) {
|
||||
// remove trailing "W/"
|
||||
if (tag.startsWith("W/") && tag.length >= 3) {
|
||||
// entity tag is weak
|
||||
tag = tag.substring(2)
|
||||
weak = true
|
||||
} else
|
||||
weak = false
|
||||
|
||||
tag = QuotedStringUtils.decodeQuotedString(tag)
|
||||
} else
|
||||
weak = null
|
||||
|
||||
eTag = tag
|
||||
}
|
||||
|
||||
override fun toString() = eTag ?: "(null)"
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
GetETag(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.*
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class GetLastModified(
|
||||
var lastModified: Long
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getlastmodified")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): GetLastModified? {
|
||||
// <!ELEMENT getlastmodified (#PCDATA) >
|
||||
XmlUtils.readText(parser)?.let { rawDate ->
|
||||
val date = HttpUtils.parseDate(rawDate)
|
||||
if (date != null)
|
||||
return GetLastModified(date.time)
|
||||
else
|
||||
Dav4jvm.log.warning("Couldn't parse Last-Modified date")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class GroupMembership: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "group-membership")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, GroupMembership())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.*
|
||||
|
||||
abstract class HrefListProperty: Property {
|
||||
|
||||
val hrefs = LinkedList<String>()
|
||||
|
||||
val href
|
||||
get() = hrefs.firstOrNull()
|
||||
|
||||
override fun toString() = "href=[" + hrefs.joinToString(", ") + "]"
|
||||
|
||||
|
||||
|
||||
abstract class Factory: PropertyFactory {
|
||||
|
||||
fun create(parser: XmlPullParser, list: HrefListProperty): HrefListProperty {
|
||||
XmlUtils.readTextPropertyList(parser, DavResource.HREF, list.hrefs)
|
||||
return list
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.logging.Level
|
||||
|
||||
data class MaxICalendarSize(
|
||||
val maxSize: Long
|
||||
) : Property {
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "max-resource-size")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): MaxICalendarSize? {
|
||||
XmlUtils.readText(parser)?.let { valueStr ->
|
||||
try {
|
||||
return MaxICalendarSize(valueStr.toLong())
|
||||
} catch(e: NumberFormatException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.logging.Level
|
||||
|
||||
data class MaxVCardSize(
|
||||
val maxSize: Long
|
||||
) : Property {
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "max-resource-size")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): MaxVCardSize? {
|
||||
XmlUtils.readText(parser)?.let { valueStr ->
|
||||
try {
|
||||
return MaxVCardSize(valueStr.toLong())
|
||||
} catch(e: NumberFormatException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class Owner: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "owner")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, Owner())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.logging.Level
|
||||
|
||||
data class QuotaAvailableBytes(
|
||||
val quotaAvailableBytes: Long
|
||||
) : Property {
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "quota-available-bytes")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): QuotaAvailableBytes? {
|
||||
XmlUtils.readText(parser)?.let { valueStr ->
|
||||
try {
|
||||
return QuotaAvailableBytes(valueStr.toLong())
|
||||
} catch(e: NumberFormatException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.logging.Level
|
||||
|
||||
data class QuotaUsedBytes(
|
||||
val quotaUsedBytes: Long
|
||||
) : Property {
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "quota-used-bytes")
|
||||
}
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): QuotaUsedBytes? {
|
||||
XmlUtils.readText(parser)?.let { valueStr ->
|
||||
try {
|
||||
return QuotaUsedBytes(valueStr.toLong())
|
||||
} catch(e: NumberFormatException) {
|
||||
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class ResourceType: Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "resourcetype")
|
||||
|
||||
val COLLECTION = Property.Name(XmlUtils.NS_WEBDAV, "collection") // WebDAV
|
||||
val PRINCIPAL = Property.Name(XmlUtils.NS_WEBDAV, "principal") // WebDAV ACL
|
||||
val ADDRESSBOOK = Property.Name(XmlUtils.NS_CARDDAV, "addressbook") // CardDAV
|
||||
val CALENDAR = Property.Name(XmlUtils.NS_CALDAV, "calendar") // CalDAV
|
||||
val SUBSCRIBED = Property.Name(XmlUtils.NS_CALENDARSERVER, "subscribed")
|
||||
}
|
||||
|
||||
val types = mutableSetOf<Property.Name>()
|
||||
|
||||
override fun toString() = "[${types.joinToString(", ")}]"
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): ResourceType? {
|
||||
val type = ResourceType()
|
||||
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) {
|
||||
// use static objects to allow types.contains()
|
||||
var typeName = Property.Name(parser.namespace, parser.name)
|
||||
when (typeName) {
|
||||
COLLECTION -> typeName = COLLECTION
|
||||
PRINCIPAL -> typeName = PRINCIPAL
|
||||
ADDRESSBOOK -> typeName = ADDRESSBOOK
|
||||
CALENDAR -> typeName = CALENDAR
|
||||
SUBSCRIBED -> typeName = SUBSCRIBED
|
||||
}
|
||||
type.types.add(typeName)
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
assert(parser.depth == depth)
|
||||
|
||||
return type
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.QuotedStringUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import okhttp3.Response
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class ScheduleTag(
|
||||
rawScheduleTag: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "schedule-tag")
|
||||
|
||||
fun fromResponse(response: Response) =
|
||||
response.header("Schedule-Tag")?.let { ScheduleTag(it) }
|
||||
}
|
||||
|
||||
/* Value: opaque-tag
|
||||
opaque-tag = quoted-string
|
||||
*/
|
||||
val scheduleTag: String? = rawScheduleTag?.let { QuotedStringUtils.decodeQuotedString(it) }
|
||||
|
||||
override fun toString() = scheduleTag ?: "(null)"
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
ScheduleTag(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class Source: HrefListProperty() {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "source")
|
||||
}
|
||||
|
||||
|
||||
object Factory: HrefListProperty.Factory() {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
create(parser, Source())
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.util.logging.Level
|
||||
|
||||
class SupportedAddressData: Property {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "supported-address-data")
|
||||
|
||||
val ADDRESS_DATA_TYPE = Property.Name(XmlUtils.NS_CARDDAV, "address-data-type")
|
||||
const val CONTENT_TYPE = "content-type"
|
||||
const val VERSION = "version"
|
||||
|
||||
}
|
||||
|
||||
val types = mutableSetOf<MediaType>()
|
||||
|
||||
fun hasVCard4() = types.any { "text/vcard; version=4.0".equals(it.toString(), true) }
|
||||
fun hasJCard() = types.any { "application".equals(it.type, true) && "vcard+json".equals(it.subtype, true) }
|
||||
|
||||
override fun toString() = "[${types.joinToString(", ")}]"
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): SupportedAddressData? {
|
||||
val supported = SupportedAddressData()
|
||||
|
||||
try {
|
||||
XmlUtils.processTag(parser, ADDRESS_DATA_TYPE) {
|
||||
parser.getAttributeValue(null, CONTENT_TYPE)?.let { contentType ->
|
||||
var type = contentType
|
||||
parser.getAttributeValue(null, VERSION)?.let { version -> type += "; version=$version" }
|
||||
type.toMediaTypeOrNull()?.let { supported.types.add(it) }
|
||||
}
|
||||
}
|
||||
} catch(e: XmlPullParserException) {
|
||||
Dav4jvm.log.log(Level.SEVERE, "Couldn't parse <resourcetype>", e)
|
||||
return null
|
||||
}
|
||||
|
||||
return supported
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.propertyName
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class SupportedCalendarComponentSet(
|
||||
var supportsEvents: Boolean,
|
||||
var supportsTasks: Boolean,
|
||||
var supportsJournal: Boolean
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
|
||||
|
||||
val ALLCOMP = Property.Name(XmlUtils.NS_CALDAV, "allcomp")
|
||||
val COMP = Property.Name(XmlUtils.NS_CALDAV, "comp")
|
||||
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): SupportedCalendarComponentSet? {
|
||||
/* <!ELEMENT supported-calendar-component-set (comp+)>
|
||||
<!ELEMENT comp ((allprop | prop*), (allcomp | comp*))>
|
||||
<!ATTLIST comp name CDATA #REQUIRED>
|
||||
*/
|
||||
val components = SupportedCalendarComponentSet(false, false, false)
|
||||
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) {
|
||||
when (parser.propertyName()) {
|
||||
ALLCOMP -> {
|
||||
components.supportsEvents = true
|
||||
components.supportsTasks = true
|
||||
components.supportsJournal = true
|
||||
}
|
||||
COMP ->
|
||||
when (parser.getAttributeValue(null, "name")?.uppercase()) {
|
||||
"VEVENT" -> components.supportsEvents = true
|
||||
"VTODO" -> components.supportsTasks = true
|
||||
"VJOURNAL" -> components.supportsJournal = true
|
||||
}
|
||||
}
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Dav4jvm
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.util.logging.Level
|
||||
|
||||
class SupportedCalendarData: Property {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_CALDAV, "supported-calendar-data")
|
||||
|
||||
val CALENDAR_DATA_TYPE = Property.Name(XmlUtils.NS_CALDAV, "calendar-data")
|
||||
const val CONTENT_TYPE = "content-type"
|
||||
const val VERSION = "version"
|
||||
|
||||
}
|
||||
|
||||
val types = mutableSetOf<MediaType>()
|
||||
|
||||
fun hasJCal() = types.any { "application".equals(it.type, true) && "calendar+json".equals(it.subtype, true) }
|
||||
|
||||
override fun toString() = "[${types.joinToString(", ")}]"
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): SupportedCalendarData? {
|
||||
val supported = SupportedCalendarData()
|
||||
|
||||
try {
|
||||
XmlUtils.processTag(parser, CALENDAR_DATA_TYPE) {
|
||||
parser.getAttributeValue(null, CONTENT_TYPE)?.let { contentType ->
|
||||
var type = contentType
|
||||
parser.getAttributeValue(null, VERSION)?.let { version -> type += "; version=$version" }
|
||||
type.toMediaTypeOrNull()?.let { supported.types.add(it) }
|
||||
}
|
||||
}
|
||||
} catch(e: XmlPullParserException) {
|
||||
Dav4jvm.log.log(Level.SEVERE, "Couldn't parse <resourcetype>", e)
|
||||
return null
|
||||
}
|
||||
|
||||
return supported
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
class SupportedReportSet: Property {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "supported-report-set")
|
||||
|
||||
val SUPPORTED_REPORT = Property.Name(XmlUtils.NS_WEBDAV, "supported-report")
|
||||
val REPORT = Property.Name(XmlUtils.NS_WEBDAV, "report")
|
||||
|
||||
const val SYNC_COLLECTION = "DAV:sync-collection" // collection synchronization (RFC 6578)
|
||||
|
||||
}
|
||||
|
||||
val reports = mutableSetOf<String>()
|
||||
|
||||
override fun toString() = "[${reports.joinToString(", ")}]"
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser): SupportedReportSet? {
|
||||
/* <!ELEMENT supported-report-set (supported-report*)>
|
||||
<!ELEMENT supported-report report>
|
||||
<!ELEMENT report ANY>
|
||||
*/
|
||||
|
||||
val supported = SupportedReportSet()
|
||||
XmlUtils.processTag(parser, SUPPORTED_REPORT) {
|
||||
XmlUtils.processTag(parser, REPORT) {
|
||||
parser.nextTag()
|
||||
if (parser.eventType == XmlPullParser.TEXT)
|
||||
supported.reports += parser.text
|
||||
else if (parser.eventType == XmlPullParser.START_TAG)
|
||||
supported.reports += "${parser.namespace}${parser.name}"
|
||||
}
|
||||
}
|
||||
return supported
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.PropertyFactory
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
data class SyncToken(
|
||||
val token: String?
|
||||
): Property {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "sync-token")
|
||||
}
|
||||
|
||||
|
||||
object Factory: PropertyFactory {
|
||||
|
||||
override fun getName() = NAME
|
||||
|
||||
override fun create(parser: XmlPullParser) =
|
||||
// <!ELEMENT sync-token #PCDATA>
|
||||
SyncToken(XmlUtils.readText(parser))
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import okhttp3.Challenge
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.Response.Builder
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class BasicDigestAuthHandlerTest {
|
||||
|
||||
@Test
|
||||
fun testBasic() {
|
||||
var authenticator = BasicDigestAuthHandler(null, "user", "password")
|
||||
val original = Request.Builder()
|
||||
.url("http://example.com")
|
||||
.build()
|
||||
var response = Builder()
|
||||
.request(original)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(401).message("Authentication required")
|
||||
.header("WWW-Authenticate", "Basic realm=\"WallyWorld\"")
|
||||
.build()
|
||||
var request = authenticator.authenticateRequest(original, response)
|
||||
assertEquals("Basic dXNlcjpwYXNzd29yZA==", request!!.header("Authorization"))
|
||||
|
||||
// special characters: always use UTF-8 (and don't crash on RFC 7617 charset header)
|
||||
authenticator = BasicDigestAuthHandler(null, "username", "paßword")
|
||||
response = response.newBuilder()
|
||||
.header("WWW-Authenticate", "Basic realm=\"WallyWorld\",charset=UTF-8")
|
||||
.build()
|
||||
request = authenticator.authenticateRequest(original, response)
|
||||
assertEquals("Basic dXNlcm5hbWU6cGHDn3dvcmQ=", request!!.header("Authorization"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDigestRFCExample() {
|
||||
// use cnonce from example
|
||||
val authenticator = BasicDigestAuthHandler(null, "Mufasa", "Circle Of Life")
|
||||
BasicDigestAuthHandler.clientNonce = "0a4f113b"
|
||||
BasicDigestAuthHandler.nonceCount.set(1)
|
||||
|
||||
// construct WWW-Authenticate
|
||||
val authScheme = Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "testrealm@host.com"),
|
||||
Pair("qop", "auth"),
|
||||
Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"),
|
||||
Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41")
|
||||
))
|
||||
|
||||
val original = Request.Builder()
|
||||
.get()
|
||||
.url("http://www.nowhere.org/dir/index.html")
|
||||
.build()
|
||||
val request = authenticator.digestRequest(original, authScheme)
|
||||
val auth = request!!.header("Authorization")
|
||||
assertTrue(auth!!.contains("username=\"Mufasa\""))
|
||||
assertTrue(auth.contains("realm=\"testrealm@host.com\""))
|
||||
assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""))
|
||||
assertTrue(auth.contains("uri=\"/dir/index.html\""))
|
||||
assertTrue(auth.contains("qop=auth"))
|
||||
assertTrue(auth.contains("nc=00000001"))
|
||||
assertTrue(auth.contains("cnonce=\"0a4f113b\""))
|
||||
assertTrue(auth.contains("response=\"6629fae49393a05397450978507c4ef1\""))
|
||||
assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDigestRealWorldExamples() {
|
||||
var authenticator = BasicDigestAuthHandler(null, "demo", "demo")
|
||||
BasicDigestAuthHandler.clientNonce = "MDI0ZDgxYTNmZDk4MTA1ODM0NDNjNmJjNDllYjQ1ZTI="
|
||||
BasicDigestAuthHandler.nonceCount.set(1)
|
||||
|
||||
// example 1
|
||||
var authScheme = Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "Group-Office"),
|
||||
Pair("qop", "auth"),
|
||||
Pair("nonce", "56212407212c8"),
|
||||
Pair("opaque", "df58bdff8cf60599c939187d0b5c54de")
|
||||
))
|
||||
|
||||
var original = Request.Builder()
|
||||
.method("PROPFIND", null)
|
||||
.url("https://demo.group-office.eu/caldav/")
|
||||
.build()
|
||||
var request = authenticator.digestRequest(original, authScheme)
|
||||
var auth = request!!.header("Authorization")
|
||||
assertTrue(auth!!.contains("username=\"demo\""))
|
||||
assertTrue(auth.contains("realm=\"Group-Office\""))
|
||||
assertTrue(auth.contains("nonce=\"56212407212c8\""))
|
||||
assertTrue(auth.contains("uri=\"/caldav/\""))
|
||||
assertTrue(auth.contains("cnonce=\"MDI0ZDgxYTNmZDk4MTA1ODM0NDNjNmJjNDllYjQ1ZTI=\""))
|
||||
assertTrue(auth.contains("nc=00000001"))
|
||||
assertTrue(auth.contains("qop=auth"))
|
||||
assertTrue(auth.contains("response=\"de3b3b194d85ddc62537208c9c3637dc\""))
|
||||
assertTrue(auth.contains("opaque=\"df58bdff8cf60599c939187d0b5c54de\""))
|
||||
|
||||
// example 2
|
||||
authenticator = BasicDigestAuthHandler(null, "test", "test")
|
||||
authScheme = Challenge("digest", mapOf<String?, String>( // lower case
|
||||
Pair("nonce", "87c4c2aceed9abf30dd68c71"),
|
||||
Pair("algorithm", "md5"),
|
||||
Pair("opaque", "571609eb7058505d35c7bf7288fbbec4-ODdjNGMyYWNlZWQ5YWJmMzBkZDY4YzcxLDAuMC4wLjAsMTQ0NTM3NzE0Nw=="),
|
||||
Pair("realm", "ieddy.ru")
|
||||
))
|
||||
original = Request.Builder()
|
||||
.method("OPTIONS", null)
|
||||
.url("https://ieddy.ru/")
|
||||
.build()
|
||||
request = authenticator.digestRequest(original, authScheme)
|
||||
auth = request!!.header("Authorization")
|
||||
assertTrue(auth!!.contains("algorithm=\"MD5\"")) // some servers require it
|
||||
assertTrue(auth.contains("username=\"test\""))
|
||||
assertTrue(auth.contains("realm=\"ieddy.ru\""))
|
||||
assertTrue(auth.contains("nonce=\"87c4c2aceed9abf30dd68c71\""))
|
||||
assertTrue(auth.contains("uri=\"/\""))
|
||||
assertFalse(auth.contains("cnonce="))
|
||||
assertFalse(auth.contains("nc=00000001"))
|
||||
assertFalse(auth.contains("qop="))
|
||||
assertTrue(auth.contains("response=\"d42a39f25f80b0d6907286a960ff9c7d\""))
|
||||
assertTrue(auth.contains("opaque=\"571609eb7058505d35c7bf7288fbbec4-ODdjNGMyYWNlZWQ5YWJmMzBkZDY4YzcxLDAuMC4wLjAsMTQ0NTM3NzE0Nw==\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDigestMD5Sess() {
|
||||
val authenticator = BasicDigestAuthHandler(null, "admin", "12345")
|
||||
BasicDigestAuthHandler.clientNonce = "hxk1lu63b6c7vhk"
|
||||
BasicDigestAuthHandler.nonceCount.set(1)
|
||||
|
||||
val authScheme = Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "MD5-sess Example"),
|
||||
Pair("qop", "auth"),
|
||||
Pair("algorithm", "MD5-sess"),
|
||||
Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"),
|
||||
Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41")
|
||||
))
|
||||
|
||||
/* A1 = h("admin:MD5-sess Example:12345"):dcd98b7102dd2f0e8b11d0f600bfb0c093:hxk1lu63b6c7vhk =
|
||||
4eaed818bc587129e73b39c8d3e8425a:dcd98b7102dd2f0e8b11d0f600bfb0c093:hxk1lu63b6c7vhk a994ee9d33e2f077d3a6e13e882f6686
|
||||
A2 = POST:/plain.txt 1b557703454e1aa1230c5523f54380ed
|
||||
|
||||
h("a994ee9d33e2f077d3a6e13e882f6686:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:hxk1lu63b6c7vhk:auth:1b557703454e1aa1230c5523f54380ed") =
|
||||
af2a72145775cfd08c36ad2676e89446
|
||||
*/
|
||||
|
||||
val original = Request.Builder()
|
||||
.method("POST", "PLAIN TEXT".toRequestBody("text/plain".toMediaType()))
|
||||
.url("http://example.com/plain.txt")
|
||||
.build()
|
||||
val request = authenticator.digestRequest(original, authScheme)
|
||||
val auth = request!!.header("Authorization")
|
||||
assertTrue(auth!!.contains("username=\"admin\""))
|
||||
assertTrue(auth.contains("realm=\"MD5-sess Example\""))
|
||||
assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""))
|
||||
assertTrue(auth.contains("uri=\"/plain.txt\""))
|
||||
assertTrue(auth.contains("cnonce=\"hxk1lu63b6c7vhk\""))
|
||||
assertTrue(auth.contains("nc=00000001"))
|
||||
assertTrue(auth.contains("qop=auth"))
|
||||
assertTrue(auth.contains("response=\"af2a72145775cfd08c36ad2676e89446\""))
|
||||
assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDigestMD5AuthInt() {
|
||||
val authenticator = BasicDigestAuthHandler(null, "admin", "12435")
|
||||
BasicDigestAuthHandler.clientNonce = "hxk1lu63b6c7vhk"
|
||||
BasicDigestAuthHandler.nonceCount.set(1)
|
||||
|
||||
val authScheme = Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "AuthInt Example"),
|
||||
Pair("qop", "auth-int"),
|
||||
Pair("nonce", "367sj3265s5"),
|
||||
Pair("opaque", "87aaxcval4gba36")
|
||||
))
|
||||
|
||||
/* A1 = admin:AuthInt Example:12345 380dc3fc1305127cd2aa81ab68ef3f34
|
||||
|
||||
h("PLAIN TEXT") = 20296edbd4c4275fb416b64e4be752f9
|
||||
A2 = POST:/plain.txt:20296edbd4c4275fb416b64e4be752f9 a71c4c86e18b3993ffc98c6e426fe4b0
|
||||
|
||||
h(380dc3fc1305127cd2aa81ab68ef3f34:367sj3265s5:00000001:hxk1lu63b6c7vhk:auth-int:a71c4c86e18b3993ffc98c6e426fe4b0) =
|
||||
81d07cb3b8d412b34144164124c970cb
|
||||
*/
|
||||
|
||||
val original = Request.Builder()
|
||||
.method("POST", "PLAIN TEXT".toRequestBody("text/plain".toMediaType()))
|
||||
.url("http://example.com/plain.txt")
|
||||
.build()
|
||||
val request = authenticator.digestRequest(original, authScheme)
|
||||
val auth = request!!.header("Authorization")
|
||||
assertTrue(auth!!.contains("username=\"admin\""))
|
||||
assertTrue(auth.contains("realm=\"AuthInt Example\""))
|
||||
assertTrue(auth.contains("nonce=\"367sj3265s5\""))
|
||||
assertTrue(auth.contains("uri=\"/plain.txt\""))
|
||||
assertTrue(auth.contains("cnonce=\"hxk1lu63b6c7vhk\""))
|
||||
assertTrue(auth.contains("nc=00000001"))
|
||||
assertTrue(auth.contains("qop=auth-int"))
|
||||
assertTrue(auth.contains("response=\"5ab6822b9d906cc711760a7783b28dca\""))
|
||||
assertTrue(auth.contains("opaque=\"87aaxcval4gba36\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDigestLegacy() {
|
||||
val authenticator = BasicDigestAuthHandler(null, "Mufasa", "CircleOfLife")
|
||||
|
||||
// construct WWW-Authenticate
|
||||
val authScheme = Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "testrealm@host.com"),
|
||||
Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"),
|
||||
Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41")
|
||||
))
|
||||
|
||||
val original = Request.Builder()
|
||||
.get()
|
||||
.url("http://www.nowhere.org/dir/index.html")
|
||||
.build()
|
||||
val request = authenticator.digestRequest(original, authScheme)
|
||||
val auth = request!!.header("Authorization")
|
||||
assertTrue(auth!!.contains("username=\"Mufasa\""))
|
||||
assertTrue(auth.contains("realm=\"testrealm@host.com\""))
|
||||
assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""))
|
||||
assertTrue(auth.contains("uri=\"/dir/index.html\""))
|
||||
assertFalse(auth.contains("qop="))
|
||||
assertFalse(auth.contains("nc="))
|
||||
assertFalse(auth.contains("cnonce="))
|
||||
assertTrue(auth.contains("response=\"1949323746fe6a43ef61f9606e7febea\""))
|
||||
assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIncompleteAuthenticationRequests() {
|
||||
val authenticator = BasicDigestAuthHandler(null, "demo", "demo")
|
||||
|
||||
val original = Request.Builder()
|
||||
.get()
|
||||
.url("http://www.nowhere.org/dir/index.html")
|
||||
.build()
|
||||
|
||||
assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>())))
|
||||
|
||||
assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "Group-Office")
|
||||
))))
|
||||
|
||||
assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "Group-Office"),
|
||||
Pair("qop", "auth")
|
||||
))))
|
||||
|
||||
assertNotNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>(
|
||||
Pair("realm", "Group-Office"),
|
||||
Pair("qop", "auth"),
|
||||
Pair("nonce", "56212407212c8")
|
||||
))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAuthenticateNull() {
|
||||
val authenticator = BasicDigestAuthHandler(null, "demo", "demo")
|
||||
// must not crash (route may be null)
|
||||
val request = Request.Builder()
|
||||
.get()
|
||||
.url("http://example.com")
|
||||
.build()
|
||||
val response = Response.Builder()
|
||||
.request(request)
|
||||
.protocol(Protocol.HTTP_2)
|
||||
.code(200).message("OK")
|
||||
.build()
|
||||
authenticator.authenticate(null, response)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.GetETag
|
||||
import at.bitfire.dav4jvm.property.SyncToken
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class DavCollectionTest {
|
||||
|
||||
private val sampleText = "SAMPLE RESPONSE"
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
private val mockServer = MockWebServer()
|
||||
private fun sampleUrl() = mockServer.url("/dav/")
|
||||
|
||||
@Before
|
||||
fun startServer() = mockServer.start()
|
||||
|
||||
@After
|
||||
fun stopServer() = mockServer.shutdown()
|
||||
|
||||
|
||||
/**
|
||||
* Test sample response for an initial sync-collection report from RFC 6578 3.8.
|
||||
*/
|
||||
@Test
|
||||
fun testInitialSyncCollectionReport() {
|
||||
val url = sampleUrl()
|
||||
val collection = DavCollection(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "text/xml; charset=\"utf-8\"")
|
||||
.setBody("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
|
||||
" <D:multistatus xmlns:D=\"DAV:\">\n" +
|
||||
" <D:response>\n" +
|
||||
" <D:href\n" +
|
||||
" >${sampleUrl()}test.doc</D:href>\n" +
|
||||
" <D:propstat>\n" +
|
||||
" <D:prop>\n" +
|
||||
" <D:getetag>\"00001-abcd1\"</D:getetag>\n" +
|
||||
" <R:bigbox xmlns:R=\"urn:ns.example.com:boxschema\">\n" +
|
||||
" <R:BoxType>Box type A</R:BoxType>\n" +
|
||||
" </R:bigbox>\n" +
|
||||
" </D:prop>\n" +
|
||||
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
|
||||
" </D:propstat>\n" +
|
||||
" </D:response>\n" +
|
||||
" <D:response>\n" +
|
||||
" <D:href\n" +
|
||||
" >${sampleUrl()}vcard.vcf</D:href>\n" +
|
||||
" <D:propstat>\n" +
|
||||
" <D:prop>\n" +
|
||||
" <D:getetag>\"00002-abcd1\"</D:getetag>\n" +
|
||||
" </D:prop>\n" +
|
||||
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
|
||||
" </D:propstat>\n" +
|
||||
" <D:propstat>\n" +
|
||||
" <D:prop>\n" +
|
||||
" <R:bigbox xmlns:R=\"urn:ns.example.com:boxschema\"/>\n" +
|
||||
" </D:prop>\n" +
|
||||
" <D:status>HTTP/1.1 404 Not Found</D:status>\n" +
|
||||
" </D:propstat>\n" +
|
||||
" </D:response>\n" +
|
||||
" <D:response>\n" +
|
||||
" <D:href\n" +
|
||||
" >${sampleUrl()}calendar.ics</D:href>\n" +
|
||||
" <D:propstat>\n" +
|
||||
" <D:prop>\n" +
|
||||
" <D:getetag>\"00003-abcd1\"</D:getetag>\n" +
|
||||
" </D:prop>\n" +
|
||||
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
|
||||
" </D:propstat>\n" +
|
||||
" <D:propstat>\n" +
|
||||
" <D:prop>\n" +
|
||||
" <R:bigbox xmlns:R=\"urn:ns.example.com:boxschema\"/>\n" +
|
||||
" </D:prop>\n" +
|
||||
" <D:status>HTTP/1.1 404 Not Found</D:status>\n" +
|
||||
" </D:propstat>\n" +
|
||||
" </D:response>\n" +
|
||||
" <D:sync-token>http://example.com/ns/sync/1234</D:sync-token>\n" +
|
||||
" </D:multistatus>")
|
||||
)
|
||||
var nrCalled = 0
|
||||
val result = collection.reportChanges(null, false, null, GetETag.NAME) { response, relation ->
|
||||
when (response.href) {
|
||||
url.resolve("/dav/test.doc") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
val eTag = response[GetETag::class.java]
|
||||
assertEquals("00001-abcd1", eTag?.eTag)
|
||||
assertTrue(eTag?.weak == false)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/vcard.vcf") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
val eTag = response[GetETag::class.java]
|
||||
assertEquals("00002-abcd1", eTag?.eTag)
|
||||
assertTrue(eTag?.weak == false)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/calendar.ics") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
val eTag = response[GetETag::class.java]
|
||||
assertEquals("00003-abcd1", eTag?.eTag)
|
||||
assertTrue(eTag?.weak == false)
|
||||
nrCalled++
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(3, nrCalled)
|
||||
assertEquals("http://example.com/ns/sync/1234", result.filterIsInstance(SyncToken::class.java).first().token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sample response for an initial sync-collection report with truncation from RFC 6578 3.10.
|
||||
*/
|
||||
@Test
|
||||
fun testInitialSyncCollectionReportWithTruncation() {
|
||||
val url = sampleUrl()
|
||||
val collection = DavCollection(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "text/xml; charset=\"utf-8\"")
|
||||
.setBody("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
|
||||
" <D:multistatus xmlns:D=\"DAV:\">\n" +
|
||||
" <D:response>\n" +
|
||||
" <D:href>${sampleUrl()}test.doc</D:href>\n" +
|
||||
" <D:propstat>\n" +
|
||||
" <D:prop>\n" +
|
||||
" <D:getetag>\"00001-abcd1\"</D:getetag>\n" +
|
||||
" </D:prop>\n" +
|
||||
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
|
||||
" </D:propstat>\n" +
|
||||
" </D:response>\n" +
|
||||
" <D:response>\n" +
|
||||
" <D:href>${sampleUrl()}vcard.vcf</D:href>\n" +
|
||||
" <D:propstat>\n" +
|
||||
" <D:prop>\n" +
|
||||
" <D:getetag>\"00002-abcd1\"</D:getetag>\n" +
|
||||
" </D:prop>\n" +
|
||||
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
|
||||
" </D:propstat>\n" +
|
||||
" </D:response>\n" +
|
||||
" <D:response>\n" +
|
||||
" <D:href>${sampleUrl()}removed.txt</D:href>\n" +
|
||||
" <D:status>HTTP/1.1 404 Not Found</D:status>\n" +
|
||||
" </D:response>" +
|
||||
" <D:response>\n" +
|
||||
" <D:href>${sampleUrl()}</D:href>\n" +
|
||||
" <D:status>HTTP/1.1 507 Insufficient Storage</D:status>\n" +
|
||||
" <D:error><D:number-of-matches-within-limits/></D:error>\n" +
|
||||
" </D:response>" +
|
||||
" <D:sync-token>http://example.com/ns/sync/1233</D:sync-token>\n" +
|
||||
" </D:multistatus>")
|
||||
)
|
||||
var nrCalled = 0
|
||||
val result = collection.reportChanges(null, false, null, GetETag.NAME) { response, relation ->
|
||||
when (response.href) {
|
||||
url.resolve("/dav/test.doc") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
val eTag = response[GetETag::class.java]
|
||||
assertEquals("00001-abcd1", eTag?.eTag)
|
||||
assertTrue(eTag?.weak == false)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/vcard.vcf") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
val eTag = response[GetETag::class.java]
|
||||
assertEquals("00002-abcd1", eTag?.eTag)
|
||||
assertTrue(eTag?.weak == false)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/removed.txt") -> {
|
||||
assertFalse(response.isSuccess())
|
||||
assertEquals(404, response.status?.code)
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/") -> {
|
||||
assertFalse(response.isSuccess())
|
||||
assertEquals(507, response.status?.code)
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
nrCalled++
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals("http://example.com/ns/sync/1233", result.filterIsInstance(SyncToken::class.java).first().token)
|
||||
assertEquals(4, nrCalled)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sample response for a sync-collection report with unsupported limit from RFC 6578 3.12.
|
||||
*/
|
||||
@Test
|
||||
fun testSyncCollectionReportWithUnsupportedLimit() {
|
||||
val url = sampleUrl()
|
||||
val collection = DavCollection(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(507)
|
||||
.setHeader("Content-Type", "text/xml; charset=\"utf-8\"")
|
||||
.setBody("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
|
||||
" <D:error xmlns:D=\"DAV:\">\n" +
|
||||
" <D:number-of-matches-within-limits/>\n" +
|
||||
" </D:error>")
|
||||
)
|
||||
|
||||
try {
|
||||
collection.reportChanges("http://example.com/ns/sync/1232", false, 100, GetETag.NAME) { _, _ -> }
|
||||
fail("Expected HttpException")
|
||||
} catch (e: HttpException) {
|
||||
assertEquals(507, e.code)
|
||||
assertTrue(e.errors.any { it.name == Property.Name(XmlUtils.NS_WEBDAV, "number-of-matches-within-limits") })
|
||||
assertEquals(1, e.errors.size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPost() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavCollection(httpClient, url)
|
||||
|
||||
// 201 Created
|
||||
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_CREATED))
|
||||
var called = false
|
||||
dav.post(sampleText.toRequestBody("text/plain".toMediaType())) { response ->
|
||||
assertEquals("POST", mockServer.takeRequest().method)
|
||||
assertEquals(response.request.url, dav.location)
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.exception.PreconditionFailedException
|
||||
import at.bitfire.dav4jvm.property.DisplayName
|
||||
import at.bitfire.dav4jvm.property.GetContentType
|
||||
import at.bitfire.dav4jvm.property.GetETag
|
||||
import at.bitfire.dav4jvm.property.ResourceType
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class DavResourceTest {
|
||||
|
||||
private val sampleText = "SAMPLE RESPONSE"
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
private val mockServer = MockWebServer()
|
||||
|
||||
|
||||
@Before
|
||||
fun startServer() {
|
||||
mockServer.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun stopServer() {
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
private fun sampleUrl() = mockServer.url("/dav/")
|
||||
|
||||
|
||||
@Test
|
||||
fun testCopy() {
|
||||
val url = sampleUrl()
|
||||
val destination = url.resolve("test")!!
|
||||
|
||||
/* POSITIVE TEST CASES */
|
||||
|
||||
// no preconditions, 201 Created, resulted in the creation of a new resource
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_CREATED))
|
||||
var called = false
|
||||
DavResource(httpClient, url).let { dav ->
|
||||
dav.copy(destination, false) {
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
var rq = mockServer.takeRequest()
|
||||
assertEquals("COPY", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertEquals(destination.toString(), rq.getHeader("Destination"))
|
||||
assertNull(rq.getHeader("Overwrite"))
|
||||
|
||||
// no preconditions, 204 No content, resource successfully copied to a preexisting
|
||||
// destination resource
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
|
||||
called = false
|
||||
DavResource(httpClient, url).let { dav ->
|
||||
dav.copy(destination, true) {
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
rq = mockServer.takeRequest()
|
||||
assertEquals("COPY", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertEquals(destination.toString(), rq.getHeader("Destination"))
|
||||
assertEquals("F", rq.getHeader("Overwrite"))
|
||||
|
||||
/* NEGATIVE TEST CASES */
|
||||
|
||||
// 207 multi-status (e.g. errors on some of resources affected by
|
||||
// the COPY prevented the operation from taking place)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207))
|
||||
try {
|
||||
called = false
|
||||
DavResource(httpClient, url).let { dav ->
|
||||
dav.copy(destination, false) { called = true }
|
||||
fail("Expected HttpException")
|
||||
}
|
||||
} catch(e: HttpException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDelete() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
/* POSITIVE TEST CASES */
|
||||
|
||||
// no preconditions, 204 No Content
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
|
||||
var called = false
|
||||
dav.delete {
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
var rq = mockServer.takeRequest()
|
||||
assertEquals("DELETE", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertNull(rq.getHeader("If-Match"))
|
||||
|
||||
// precondition: If-Match / If-Schedule-Tag-Match, 200 OK
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody("Resource has been deleted."))
|
||||
called = false
|
||||
dav.delete("DeleteOnlyThisETag", "DeleteOnlyThisScheduleTag") {
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
rq = mockServer.takeRequest()
|
||||
assertEquals("\"DeleteOnlyThisETag\"", rq.getHeader("If-Match"))
|
||||
assertEquals("\"DeleteOnlyThisScheduleTag\"", rq.getHeader("If-Schedule-Tag-Match"))
|
||||
|
||||
// 302 Moved Temporarily
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_MOVED_TEMP)
|
||||
.setHeader("Location", "/new-location")
|
||||
)
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
called = false
|
||||
dav.delete(null) {
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
/* NEGATIVE TEST CASES */
|
||||
|
||||
// 207 multi-status (e.g. single resource couldn't be deleted when DELETEing a collection)
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207))
|
||||
try {
|
||||
called = false
|
||||
dav.delete(null) { called = true }
|
||||
fail("Expected HttpException")
|
||||
} catch(e: HttpException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFollowRedirects_302() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
var i = 0
|
||||
dav.followRedirects {
|
||||
if (i++ == 0)
|
||||
okhttp3.Response.Builder()
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(302)
|
||||
.message("Found")
|
||||
.header("Location", "http://to.com/")
|
||||
.request(Request.Builder()
|
||||
.get()
|
||||
.url("http://from.com/")
|
||||
.build())
|
||||
.body("New location!".toResponseBody())
|
||||
.build()
|
||||
else
|
||||
okhttp3.Response.Builder()
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(204)
|
||||
.message("No Content")
|
||||
.request(Request.Builder()
|
||||
.get()
|
||||
.url("http://to.com/")
|
||||
.build())
|
||||
.build()
|
||||
}.let { response ->
|
||||
assertEquals(204, response.code)
|
||||
assertEquals("http://to.com/".toHttpUrl(), dav.location)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(expected = DavException::class)
|
||||
fun testFollowRedirects_HttpsToHttp() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.followRedirects {
|
||||
okhttp3.Response.Builder()
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(302)
|
||||
.message("Found")
|
||||
.header("Location", "http://to.com/")
|
||||
.request(Request.Builder()
|
||||
.get()
|
||||
.url("https://from.com/")
|
||||
.build())
|
||||
.body("New location!".toResponseBody())
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGet() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
/* POSITIVE TEST CASES */
|
||||
|
||||
// 200 OK
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setHeader("ETag", "W/\"My Weak ETag\"")
|
||||
.setHeader("Content-Type", "application/x-test-result")
|
||||
.setBody(sampleText))
|
||||
var called = false
|
||||
dav.get("*/*", null) { response ->
|
||||
called = true
|
||||
assertEquals(sampleText, response.body!!.string())
|
||||
|
||||
val eTag = GetETag.fromResponse(response)
|
||||
assertEquals("My Weak ETag", eTag!!.eTag)
|
||||
assertTrue(eTag.weak!!)
|
||||
assertEquals("application/x-test-result".toMediaType(), GetContentType(response.body!!.contentType()!!).type)
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
var rq = mockServer.takeRequest()
|
||||
assertEquals("GET", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertEquals("*/*", rq.getHeader("Accept"))
|
||||
|
||||
// 302 Moved Temporarily + 200 OK
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_MOVED_TEMP)
|
||||
.setHeader("Location", "/target")
|
||||
.setBody("This resource was moved."))
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setHeader("ETag", "\"StrongETag\"")
|
||||
.setBody(sampleText))
|
||||
called = false
|
||||
dav.get("*/*", null) { response ->
|
||||
called = true
|
||||
assertEquals(sampleText, response.body!!.string())
|
||||
val eTag = GetETag(response.header("ETag"))
|
||||
assertEquals("StrongETag", eTag.eTag)
|
||||
assertFalse(eTag.weak!!)
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
mockServer.takeRequest()
|
||||
rq = mockServer.takeRequest()
|
||||
assertEquals("GET", rq.method)
|
||||
assertEquals("/target", rq.path)
|
||||
|
||||
// 200 OK without ETag in response
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(sampleText))
|
||||
called = false
|
||||
dav.get("*/*", null) { response ->
|
||||
called = true
|
||||
assertNull(response.header("ETag"))
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetRange_Ok() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_PARTIAL))
|
||||
var called = false
|
||||
dav.getRange("*/*", 100, 342) { response ->
|
||||
assertEquals("bytes=100-441", response.request.header("Range"))
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMove() {
|
||||
val url = sampleUrl()
|
||||
val destination = url.resolve("test")!!
|
||||
|
||||
/* POSITIVE TEST CASES */
|
||||
|
||||
// no preconditions, 201 Created, new URL mapping at the destination
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_CREATED))
|
||||
var called = false
|
||||
DavResource(httpClient, url).let { dav ->
|
||||
dav.move(destination, false) {
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
assertEquals(destination, dav.location)
|
||||
}
|
||||
|
||||
var rq = mockServer.takeRequest()
|
||||
assertEquals("MOVE", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertEquals(destination.toString(), rq.getHeader("Destination"))
|
||||
assertNull(rq.getHeader("Overwrite"))
|
||||
|
||||
// no preconditions, 204 No content, URL already mapped, overwrite
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
|
||||
called = false
|
||||
DavResource(httpClient, url).let { dav ->
|
||||
dav.move(destination, true) {
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
assertEquals(destination, dav.location)
|
||||
}
|
||||
|
||||
rq = mockServer.takeRequest()
|
||||
assertEquals("MOVE", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertEquals(destination.toString(), rq.getHeader("Destination"))
|
||||
assertEquals("F", rq.getHeader("Overwrite"))
|
||||
|
||||
/* NEGATIVE TEST CASES */
|
||||
|
||||
// 207 multi-status (e.g. errors on some of resources affected by
|
||||
// the MOVE prevented the operation from taking place)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207))
|
||||
try {
|
||||
called = false
|
||||
DavResource(httpClient, url).let { dav ->
|
||||
dav.move(destination, false) { called = true }
|
||||
fail("Expected HttpException")
|
||||
}
|
||||
} catch(e: HttpException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOptions() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setHeader("DAV", " 1, 2 ,3,hyperactive-access"))
|
||||
var called = false
|
||||
dav.options { davCapabilities, _ ->
|
||||
called = true
|
||||
assertTrue(davCapabilities.contains("1"))
|
||||
assertTrue(davCapabilities.contains("2"))
|
||||
assertTrue(davCapabilities.contains("3"))
|
||||
assertTrue(davCapabilities.contains("hyperactive-access"))
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
called = false
|
||||
dav.options { davCapabilities, _ ->
|
||||
called = true
|
||||
assertTrue(davCapabilities.isEmpty())
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPropfindAndMultiStatus() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
/*** NEGATIVE TESTS ***/
|
||||
|
||||
// test for non-multi-status responses:
|
||||
// * 500 Internal Server Error
|
||||
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_INTERNAL_ERROR))
|
||||
var called = false
|
||||
try {
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
|
||||
fail("Expected HttpException")
|
||||
} catch(e: HttpException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
// * 200 OK (instead of 207 Multi-Status)
|
||||
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
try {
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
|
||||
fail("Expected DavException")
|
||||
} catch(e: DavException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
|
||||
// test for invalid multi-status responses:
|
||||
// * non-XML response
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "text/html")
|
||||
.setBody("<html></html>"))
|
||||
try {
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
|
||||
fail("Expected DavException")
|
||||
} catch(e: DavException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
|
||||
// * malformed XML response
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<malformed-xml>"))
|
||||
try {
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
|
||||
fail("Expected DavException")
|
||||
} catch(e: DavException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
|
||||
// * response without <multistatus> root element
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<test></test>"))
|
||||
try {
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
|
||||
fail("Expected DavException")
|
||||
} catch(e: DavException) {
|
||||
assertFalse(called)
|
||||
}
|
||||
|
||||
// * multi-status response with invalid <status> in <response>
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <status>Invalid Status Line</status>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { response, relation ->
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
assertEquals(500, response.status?.code)
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
// * multi-status response with <response>/<status> element indicating failure
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <status>HTTP/1.1 403 Forbidden</status>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { response, relation ->
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
assertEquals(403, response.status?.code)
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
// * multi-status response with invalid <status> in <propstat>
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <resourcetype><collection/></resourcetype>" +
|
||||
" </prop>" +
|
||||
" <status>Invalid Status Line</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { response, relation ->
|
||||
called = true
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
assertTrue(response.properties.filterIsInstance(ResourceType::class.java).isEmpty())
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
|
||||
/*** POSITIVE TESTS ***/
|
||||
|
||||
// multi-status response without <response> elements
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'></multistatus>"))
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ ->
|
||||
fail("Shouldn't be called")
|
||||
}
|
||||
|
||||
// multi-status response with <response>/<status> element indicating success
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME) { response, relation ->
|
||||
called = true
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
assertEquals(0, response.properties.size)
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
// multi-status response with <response>/<propstat> element
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <resourcetype></resourcetype>" +
|
||||
" <displayname>My DAV Collection</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME, DisplayName.NAME) { response, relation ->
|
||||
called = true
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName)
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
// multi-status response for collection with several members; incomplete (not all <resourcetype>s listed)
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>" + url.toString() + "</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <resourcetype><collection/></resourcetype>" +
|
||||
" <displayname>My DAV Collection</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
" <response>" +
|
||||
" <href>/dav/subcollection</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <resourcetype><collection/></resourcetype>" +
|
||||
" <displayname>A Subfolder</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
" <response>" +
|
||||
" <href>/dav/uid@host:file</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <displayname>Absolute path with @ and :</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
" <response>" +
|
||||
" <href>relative-uid@host.file</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <displayname>Relative path with @</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
" <response>" +
|
||||
" <href>relative:colon.vcf</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <displayname>Relative path with colon</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
" <response>" +
|
||||
" <href>/something-very/else</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <displayname>Not requested</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
var nrCalled = 0
|
||||
dav.propfind(1, ResourceType.NAME, DisplayName.NAME) { response, relation ->
|
||||
when (response.href) {
|
||||
url.resolve("/dav/") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION))
|
||||
assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/subcollection/") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION))
|
||||
assertEquals("A Subfolder", response[DisplayName::class.java]?.displayName)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/uid@host:file") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
assertEquals("Absolute path with @ and :", response[DisplayName::class.java]?.displayName)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/relative-uid@host.file") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
assertEquals("Relative path with @", response[DisplayName::class.java]?.displayName)
|
||||
nrCalled++
|
||||
}
|
||||
url.resolve("/dav/relative:colon.vcf") -> {
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.MEMBER, relation)
|
||||
assertEquals("Relative path with colon", response[DisplayName::class.java]?.displayName)
|
||||
nrCalled++
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(4, nrCalled)
|
||||
|
||||
|
||||
/*** SPECIAL CASES ***/
|
||||
|
||||
// same property is sent as 200 OK and 404 Not Found in same <response> (seen in iCloud)
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>" + url.toString() + "</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <resourcetype><collection/></resourcetype>" +
|
||||
" <displayname>My DAV Collection</displayname>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <resourcetype/>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 404 Not Found</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
called = false
|
||||
dav.propfind(0, ResourceType.NAME, DisplayName.NAME) { response, relation ->
|
||||
called = true
|
||||
assertTrue(response.isSuccess())
|
||||
assertEquals(Response.HrefRelation.SELF, relation)
|
||||
assertEquals(url.resolve("/dav/"), response.href)
|
||||
assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION))
|
||||
assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName)
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
// multi-status response with <propstat> that doesn't contain <status> (=> assume 200 OK)
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <displayname>Without Status</displayname>" +
|
||||
" </prop>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
called = false
|
||||
dav.propfind(0, DisplayName.NAME) { response, _ ->
|
||||
called = true
|
||||
assertEquals(200, response.propstat.first().status.code)
|
||||
assertEquals("Without Status", response[DisplayName::class.java]?.displayName)
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testProppatch() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
// multi-status response with <response>/<propstat> elements
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:s='sample'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <s:setThis>Some Value</s:setThis>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
" </propstat>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <s:removeThis/>" +
|
||||
" </prop>" +
|
||||
" <status>HTTP/1.1 404 Not Found</status>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
var called = false
|
||||
dav.proppatch(
|
||||
setProperties = mapOf(Pair(Property.Name("sample", "setThis"), "Some Value")),
|
||||
removeProperties = listOf(Property.Name("sample", "removeThis"))
|
||||
) { response, hrefRelation ->
|
||||
called = true
|
||||
assertEquals(Response.HrefRelation.SELF, hrefRelation)
|
||||
}
|
||||
assertTrue(called)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testProppatch_createProppatchXml() {
|
||||
val xml = DavResource.createProppatchXml(
|
||||
setProperties = mapOf(Pair(Property.Name("sample", "setThis"), "Some Value")),
|
||||
removeProperties = listOf(Property.Name("sample", "removeThis"))
|
||||
)
|
||||
assertEquals("<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
|
||||
"<d:propertyupdate xmlns:d=\"DAV:\">" +
|
||||
"<d:set><d:prop><n1:setThis xmlns:n1=\"sample\">Some Value</n1:setThis></d:prop></d:set>" +
|
||||
"<d:remove><d:prop><n2:removeThis xmlns:n2=\"sample\" /></d:prop></d:remove>" +
|
||||
"</d:propertyupdate>", xml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPut() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
/* POSITIVE TEST CASES */
|
||||
|
||||
// no preconditions, 201 Created
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_CREATED)
|
||||
.setHeader("ETag", "W/\"Weak PUT ETag\""))
|
||||
var called = false
|
||||
dav.put(sampleText.toRequestBody("text/plain".toMediaType())) { response ->
|
||||
called = true
|
||||
val eTag = GetETag.fromResponse(response)!!
|
||||
assertEquals("Weak PUT ETag", eTag.eTag)
|
||||
assertTrue(eTag.weak!!)
|
||||
assertEquals(response.request.url, dav.location)
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
var rq = mockServer.takeRequest()
|
||||
assertEquals("PUT", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertNull(rq.getHeader("If-Match"))
|
||||
assertNull(rq.getHeader("If-None-Match"))
|
||||
|
||||
// precondition: If-None-Match, 301 Moved Permanently + 204 No Content, no ETag in response
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_MOVED_PERM)
|
||||
.setHeader("Location", "/target"))
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
|
||||
called = false
|
||||
dav.put(sampleText.toRequestBody("text/plain".toMediaType()), ifNoneMatch = true) { response ->
|
||||
called = true
|
||||
assertEquals(url.resolve("/target"), response.request.url)
|
||||
val eTag = GetETag.fromResponse(response)
|
||||
assertNull("Weak PUT ETag", eTag?.eTag)
|
||||
assertNull(eTag?.weak)
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
mockServer.takeRequest()
|
||||
rq = mockServer.takeRequest()
|
||||
assertEquals("PUT", rq.method)
|
||||
assertEquals("*", rq.getHeader("If-None-Match"))
|
||||
|
||||
// precondition: If-Match, 412 Precondition Failed
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_PRECON_FAILED))
|
||||
called = false
|
||||
try {
|
||||
dav.put(sampleText.toRequestBody("text/plain".toMediaType()), "ExistingETag") {
|
||||
called = true
|
||||
}
|
||||
fail("Expected PreconditionFailedException")
|
||||
} catch(_: PreconditionFailedException) {}
|
||||
assertFalse(called)
|
||||
rq = mockServer.takeRequest()
|
||||
assertEquals("\"ExistingETag\"", rq.getHeader("If-Match"))
|
||||
assertNull(rq.getHeader("If-None-Match"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSearch() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns='DAV:'>" +
|
||||
" <response>" +
|
||||
" <href>/dav</href>" +
|
||||
" <propstat>" +
|
||||
" <prop>" +
|
||||
" <displayname>Found something</displayname>" +
|
||||
" </prop>" +
|
||||
" </propstat>" +
|
||||
" </response>" +
|
||||
"</multistatus>"))
|
||||
var called = false
|
||||
dav.search("<TEST/>") { response, hrefRelation, ->
|
||||
assertEquals(Response.HrefRelation.SELF, hrefRelation)
|
||||
assertEquals("Found something", response[DisplayName::class.java]?.displayName)
|
||||
called = true
|
||||
}
|
||||
assertTrue(called)
|
||||
|
||||
val rq = mockServer.takeRequest()
|
||||
assertEquals("SEARCH", rq.method)
|
||||
assertEquals(url.encodedPath, rq.path)
|
||||
assertEquals("<TEST/>", rq.body.readString(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
|
||||
/** test helpers **/
|
||||
|
||||
@Test(expected = DavException::class)
|
||||
fun testAssertMultiStatus_NoBody_NoXML() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.assertMultiStatus(okhttp3.Response.Builder()
|
||||
.request(Request.Builder().url(dav.location).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(207).message("Multi-Status")
|
||||
.build())
|
||||
}
|
||||
|
||||
@Test(expected = DavException::class)
|
||||
fun testAssertMultiStatus_NoBody_XML() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.assertMultiStatus(okhttp3.Response.Builder()
|
||||
.request(Request.Builder().url(dav.location).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(207).message("Multi-Status")
|
||||
.addHeader("Content-Type", "text/xml")
|
||||
.build())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAssertMultiStatus_NonXML_ButContentIsXML() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.assertMultiStatus(okhttp3.Response.Builder()
|
||||
.request(Request.Builder().url(dav.location).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(207).message("Multi-Status")
|
||||
.addHeader("Content-Type", "application/octet-stream")
|
||||
.body("<?xml version=\"1.0\"><test/>".toResponseBody())
|
||||
.build())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAssertMultiStatus_NonXML_ReallyNotXML() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.assertMultiStatus(okhttp3.Response.Builder()
|
||||
.request(Request.Builder().url(dav.location).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(207).message("Multi-Status")
|
||||
.addHeader("Content-Type", "text/plain")
|
||||
.body("Some error occurred".toResponseBody())
|
||||
.build())
|
||||
}
|
||||
|
||||
@Test(expected = DavException::class)
|
||||
fun testAssertMultiStatus_Not207() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.assertMultiStatus(okhttp3.Response.Builder()
|
||||
.request(Request.Builder().url(dav.location).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(403).message("Multi-Status")
|
||||
.addHeader("Content-Type", "application/xml")
|
||||
.body("".toResponseBody())
|
||||
.build())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAssertMultiStatus_Ok_ApplicationXml() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.assertMultiStatus(okhttp3.Response.Builder()
|
||||
.request(Request.Builder().url(dav.location).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(207).message("Multi-Status")
|
||||
.addHeader("Content-Type", "application/xml")
|
||||
.body("".toResponseBody())
|
||||
.build())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAssertMultiStatus_Ok_TextXml() {
|
||||
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
|
||||
dav.assertMultiStatus(okhttp3.Response.Builder()
|
||||
.request(Request.Builder().url(dav.location).build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(207).message("Multi-Status")
|
||||
.addHeader("Content-Type", "text/xml")
|
||||
.body("".toResponseBody())
|
||||
.build())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ErrorTest {
|
||||
|
||||
@Test
|
||||
fun testEquals() {
|
||||
val errors = listOf(Error(Property.Name("DAV:", "valid-sync-token")))
|
||||
assertTrue(errors.contains(Error.VALID_SYNC_TOKEN))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class HttpUtilsTest {
|
||||
|
||||
@Test
|
||||
fun testFilename() {
|
||||
assertEquals("", HttpUtils.fileName("https://example.com".toHttpUrl()))
|
||||
assertEquals("", HttpUtils.fileName("https://example.com/".toHttpUrl()))
|
||||
assertEquals("file1", HttpUtils.fileName("https://example.com/file1".toHttpUrl()))
|
||||
assertEquals("dir1", HttpUtils.fileName("https://example.com/dir1/".toHttpUrl()))
|
||||
assertEquals("file2", HttpUtils.fileName("https://example.com/dir1/file2".toHttpUrl()))
|
||||
assertEquals("dir2", HttpUtils.fileName("https://example.com/dir1/dir2/".toHttpUrl()))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* Covers what Agendula changed while vendoring, and the two upstream defects
|
||||
* `docs/SYNC.md` says we would inherit. Everything else in this directory is
|
||||
* upstream's own suite, unmodified. See `dav/PROVENANCE.md`.
|
||||
*/
|
||||
class LocalChangesTest {
|
||||
|
||||
private val httpClient = OkHttpClient.Builder().followRedirects(false).build()
|
||||
private val mockServer = MockWebServer()
|
||||
|
||||
@Before fun startServer() = mockServer.start()
|
||||
@After fun stopServer() = mockServer.shutdown()
|
||||
|
||||
private fun redirect(code: Int, to: String) = MockResponse()
|
||||
.setResponseCode(code)
|
||||
.setHeader("Location", to)
|
||||
|
||||
// -- change 3: dav4jvm#209, permanent redirects never reached the caller ---
|
||||
|
||||
@Test
|
||||
fun permanentRedirectIsReportedSoTheCallerCanPersistIt() {
|
||||
mockServer.enqueue(redirect(301, "/moved/"))
|
||||
mockServer.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val resource = DavResource(httpClient, mockServer.url("/dav/"))
|
||||
resource.head { }
|
||||
|
||||
assertEquals(mockServer.url("/moved/"), resource.permanentLocation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a308IsPermanentToo() {
|
||||
mockServer.enqueue(redirect(308, "/moved/"))
|
||||
mockServer.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val resource = DavResource(httpClient, mockServer.url("/dav/"))
|
||||
resource.head { }
|
||||
|
||||
assertEquals(mockServer.url("/moved/"), resource.permanentLocation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTemporaryRedirectIsNotPersisted() {
|
||||
mockServer.enqueue(redirect(302, "/elsewhere/"))
|
||||
mockServer.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val resource = DavResource(httpClient, mockServer.url("/dav/"))
|
||||
resource.head { }
|
||||
|
||||
assertNull(resource.permanentLocation)
|
||||
// The in-flight location still moves, as it must.
|
||||
assertEquals(mockServer.url("/elsewhere/"), resource.location)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun permanentLocationDescribesOnlyTheRequestJustMade() {
|
||||
// DavResource objects are reused. A value left over from an earlier
|
||||
// request would have the caller persist a URL that a later one replaced,
|
||||
// and every sync after that 404s.
|
||||
mockServer.enqueue(redirect(301, "/moved/"))
|
||||
mockServer.enqueue(MockResponse().setResponseCode(200))
|
||||
mockServer.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val resource = DavResource(httpClient, mockServer.url("/dav/"))
|
||||
resource.head { }
|
||||
assertEquals(mockServer.url("/moved/"), resource.permanentLocation)
|
||||
|
||||
resource.head { }
|
||||
assertNull(resource.permanentLocation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTemporaryHopEndsThePermanentChain() {
|
||||
// 301 -> 302 means "the resource moved to /moved/, and /moved/ is being
|
||||
// served from /temp/ for now". Persisting /temp/ would be wrong.
|
||||
mockServer.enqueue(redirect(301, "/moved/"))
|
||||
mockServer.enqueue(redirect(302, "/temp/"))
|
||||
mockServer.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val resource = DavResource(httpClient, mockServer.url("/dav/"))
|
||||
resource.head { }
|
||||
|
||||
assertEquals(mockServer.url("/moved/"), resource.permanentLocation)
|
||||
assertEquals(mockServer.url("/temp/"), resource.location)
|
||||
}
|
||||
|
||||
// -- the "303 is not handled" claim, checked rather than assumed -----------
|
||||
|
||||
@Test
|
||||
fun a303IsFollowed() {
|
||||
// `docs/SYNC.md` lists "handles 301/302/307/308 but not 303" as a defect
|
||||
// we would inherit. It does not hold for this version: OkHttp's
|
||||
// Response.isRedirect includes HTTP_SEE_OTHER, and followRedirects re-sends
|
||||
// the same method, which is what RFC 6764 section 5 asks for in discovery.
|
||||
mockServer.enqueue(redirect(303, "/other/"))
|
||||
mockServer.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val resource = DavResource(httpClient, mockServer.url("/dav/"))
|
||||
resource.head { }
|
||||
|
||||
assertEquals(mockServer.url("/other/"), resource.location)
|
||||
}
|
||||
|
||||
// -- change 1: commons-lang3 removed from date parsing --------------------
|
||||
|
||||
/**
|
||||
* Runs [block] with a deliberately non-UTC default zone.
|
||||
*
|
||||
* The defect change 2 fixes *is* "the formatter used the default zone", so a
|
||||
* test that does not move the default zone passes identically before and
|
||||
* after the fix — and CI containers default to UTC. Without this the whole
|
||||
* change is pinned by nothing.
|
||||
*/
|
||||
private fun <T> inNonUtcZone(block: () -> T): T {
|
||||
val previous = TimeZone.getDefault()
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Kolkata")) // UTC+05:30
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
TimeZone.setDefault(previous)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formatDateEmitsGmtRatherThanLocalTimeLabelledGmt() = inNonUtcZone {
|
||||
assertEquals(
|
||||
"Wed, 21 Oct 2015 07:28:00 GMT",
|
||||
HttpUtils.formatDate(java.util.Date(1445412480000L)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDateAcceptsTheRfc7231Format() = inNonUtcZone {
|
||||
assertEquals(1445412480000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT")!!.time)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDateHonoursAnExplicitOffsetInsteadOfMatchingGmtAsAPrefix() {
|
||||
// Pattern 1 ends in the *literal* 'GMT', and SimpleDateFormat.parse is
|
||||
// happy with a prefix — so without a full-consumption check the "+02:00"
|
||||
// is discarded and the result is two hours out, which is the same class of
|
||||
// error change 2 set out to remove.
|
||||
assertEquals(1445405280000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT+02:00")!!.time)
|
||||
assertEquals(1445430480000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT-05:00")!!.time)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDateRejectsTrailingGarbageRatherThanMatchingAPrefix() {
|
||||
assertNull(HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT and then some"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDateAcceptsTheObsoleteFormats() {
|
||||
// RFC 850, and ANSI C asctime() — both still emitted in the wild.
|
||||
assertTrue(HttpUtils.parseDate("Wednesday, 21-Oct-15 07:28:00 GMT") != null)
|
||||
assertTrue(HttpUtils.parseDate("Wed Oct 21 07:28:00 2015") != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDateReturnsNullRatherThanThrowing() {
|
||||
assertNull(HttpUtils.parseDate("not a date at all"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDateIsIndependentOfTheDefaultLocale() {
|
||||
// SimpleDateFormat defaults to the platform locale, which would reject
|
||||
// English month names on a Turkish or German device.
|
||||
val locale = Locale.getDefault()
|
||||
val zone = TimeZone.getDefault()
|
||||
try {
|
||||
Locale.setDefault(Locale.forLanguageTag("tr-TR"))
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Istanbul"))
|
||||
assertEquals(1445412480000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT")!!.time)
|
||||
} finally {
|
||||
Locale.setDefault(locale)
|
||||
TimeZone.setDefault(zone)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class QuotedStringUtilsTest {
|
||||
|
||||
@Test
|
||||
fun testAsQuotedString() {
|
||||
assertEquals("\"\"", QuotedStringUtils.asQuotedString(""))
|
||||
assertEquals("\"\\\"\"", QuotedStringUtils.asQuotedString("\""))
|
||||
assertEquals("\"\\\\\"", QuotedStringUtils.asQuotedString("\\"))
|
||||
}
|
||||
|
||||
fun testDecodeQuotedString() {
|
||||
assertEquals("\"", QuotedStringUtils.decodeQuotedString("\""))
|
||||
assertEquals("\\", QuotedStringUtils.decodeQuotedString("\"\\\""))
|
||||
assertEquals("\"test", QuotedStringUtils.decodeQuotedString("\"test"))
|
||||
assertEquals("test", QuotedStringUtils.decodeQuotedString("test"))
|
||||
assertEquals("", QuotedStringUtils.decodeQuotedString("\"\""))
|
||||
assertEquals("test", QuotedStringUtils.decodeQuotedString("\"test\""))
|
||||
assertEquals("test\\", QuotedStringUtils.decodeQuotedString("\"test\\\""))
|
||||
assertEquals("test", QuotedStringUtils.decodeQuotedString("\"t\\e\\st\""))
|
||||
assertEquals("12\"34", QuotedStringUtils.decodeQuotedString("\"12\\\"34\""))
|
||||
assertEquals("1234\"", QuotedStringUtils.decodeQuotedString("\"1234\\\"\""))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class UrlUtilsTest {
|
||||
|
||||
@Test
|
||||
fun testEquals() {
|
||||
assertTrue(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host/resource".toHttpUrlOrNull()!!))
|
||||
assertTrue(UrlUtils.equals("http://host:80/resource".toHttpUrlOrNull()!!, "http://host/resource".toHttpUrlOrNull()!!))
|
||||
assertTrue(UrlUtils.equals("https://HOST:443/resource".toHttpUrlOrNull()!!, "https://host/resource".toHttpUrlOrNull()!!))
|
||||
assertTrue(UrlUtils.equals("https://host:443/my@dav/".toHttpUrlOrNull()!!, "https://host/my%40dav/".toHttpUrlOrNull()!!))
|
||||
assertTrue(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host/resource#frag1".toHttpUrlOrNull()!!))
|
||||
|
||||
// should work, but currently doesn't (see MR #5)
|
||||
// assertTrue(UrlUtils.equals(HttpUrl.parse("https://host/%5bresource%5d/")!!, HttpUrl.parse("https://host/[resource]/")!!))
|
||||
|
||||
assertFalse(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host/resource/".toHttpUrlOrNull()!!))
|
||||
assertFalse(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host:81/resource".toHttpUrlOrNull()!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHostToDomain() {
|
||||
assertNull(UrlUtils.hostToDomain(null))
|
||||
assertEquals("", UrlUtils.hostToDomain("."))
|
||||
assertEquals("com", UrlUtils.hostToDomain("com"))
|
||||
assertEquals("com", UrlUtils.hostToDomain("com."))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain("example.com"))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain("example.com."))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain(".example.com"))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain(".example.com."))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain("host.example.com"))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain("host.example.com."))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain("sub.host.example.com"))
|
||||
assertEquals("example.com", UrlUtils.hostToDomain("sub.host.example.com."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOmitTrailingSlash() {
|
||||
assertEquals("http://host/resource".toHttpUrlOrNull()!!, UrlUtils.omitTrailingSlash("http://host/resource".toHttpUrlOrNull()!!))
|
||||
assertEquals("http://host/resource".toHttpUrlOrNull()!!, UrlUtils.omitTrailingSlash("http://host/resource/".toHttpUrlOrNull()!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWithTrailingSlash() {
|
||||
assertEquals("http://host/resource/".toHttpUrlOrNull()!!, UrlUtils.withTrailingSlash("http://host/resource".toHttpUrlOrNull()!!))
|
||||
assertEquals("http://host/resource/".toHttpUrlOrNull()!!, UrlUtils.withTrailingSlash("http://host/resource/".toHttpUrlOrNull()!!))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.StringReader
|
||||
|
||||
class XmlUtilsTest {
|
||||
|
||||
@Test
|
||||
fun testProcessTagRoot() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(StringReader("<test></test>"))
|
||||
// now on START_DOCUMENT [0]
|
||||
|
||||
var processed = false
|
||||
XmlUtils.processTag(parser, Property.Name("", "test")) {
|
||||
processed = true
|
||||
}
|
||||
assertTrue(processed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testProcessTagDepth1() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(StringReader("<root><test></test></root>"))
|
||||
parser.next() // now on START_TAG <root>
|
||||
|
||||
var processed = false
|
||||
XmlUtils.processTag(parser, Property.Name("", "test")) {
|
||||
processed = true
|
||||
}
|
||||
assertTrue(processed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReadText() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(StringReader("<root><test>Test 1</test><test><garbage/>Test 2</test></root>"))
|
||||
parser.next()
|
||||
parser.next() // now on START_TAG <test>
|
||||
|
||||
assertEquals("Test 1", XmlUtils.readText(parser))
|
||||
assertEquals(XmlPullParser.END_TAG, parser.eventType)
|
||||
parser.next()
|
||||
|
||||
assertEquals("Test 2", XmlUtils.readText(parser))
|
||||
assertEquals(XmlPullParser.END_TAG, parser.eventType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReadTextCDATA() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(StringReader("<test><![CDATA[Test 1</test><test><garbage/>Test 2]]></test>"))
|
||||
parser.next() // now on START_TAG <test>
|
||||
|
||||
assertEquals("Test 1</test><test><garbage/>Test 2", XmlUtils.readText(parser))
|
||||
assertEquals(XmlPullParser.END_TAG, parser.eventType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReadTextPropertyRoot() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(StringReader("<root><entry>Test 1</entry><entry>Test 2</entry></root>"))
|
||||
parser.next() // now on START_TAG <root>
|
||||
|
||||
val entries = mutableListOf<String>()
|
||||
XmlUtils.readTextPropertyList(parser, Property.Name("", "entry"), entries)
|
||||
assertEquals("Test 1", entries[0])
|
||||
assertEquals("Test 2", entries[1])
|
||||
|
||||
parser.next() // END_TAG </root>
|
||||
assertEquals(XmlPullParser.END_DOCUMENT, parser.eventType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReadTextPropertyListDepth1() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(StringReader("<test><entry>Test 1</entry><entry>Test 2</entry></test>"))
|
||||
parser.next() // now on START_TAG <test> [1]
|
||||
|
||||
val entries = mutableListOf<String>()
|
||||
XmlUtils.readTextPropertyList(parser, Property.Name("", "entry"), entries)
|
||||
assertEquals("Test 1", entries[0])
|
||||
assertEquals("Test 2", entries[1])
|
||||
assertEquals(XmlPullParser.END_TAG, parser.eventType)
|
||||
assertEquals("test", parser.name)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.property.ResourceType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.ObjectInputStream
|
||||
import java.io.ObjectOutputStream
|
||||
|
||||
class DavExceptionTest {
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
private val mockServer = MockWebServer()
|
||||
private fun sampleUrl() = mockServer.url("/dav/")
|
||||
|
||||
@Before
|
||||
fun startServer() = mockServer.start()
|
||||
|
||||
@After
|
||||
fun stopServer() = mockServer.shutdown()
|
||||
|
||||
|
||||
/**
|
||||
* Test truncation of a too large plain text request in [DavException].
|
||||
*/
|
||||
@Test
|
||||
fun testRequestLargeTextError() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
val builder = StringBuilder()
|
||||
builder.append(CharArray(DavException.MAX_EXCERPT_SIZE+100) { '*' })
|
||||
val body = builder.toString()
|
||||
|
||||
val e = DavException("Error with large request body", null, Response.Builder()
|
||||
.request(Request.Builder()
|
||||
.url("http://example.com")
|
||||
.post(body.toRequestBody("text/plain".toMediaType()))
|
||||
.build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(204)
|
||||
.message("No Content")
|
||||
.build())
|
||||
|
||||
assertTrue(e.errors.isEmpty())
|
||||
assertEquals(
|
||||
body.substring(0, DavException.MAX_EXCERPT_SIZE),
|
||||
e.requestBody
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a large HTML response which has a multi-octet UTF-8 character
|
||||
* exactly at the cut-off position.
|
||||
*/
|
||||
@Test
|
||||
fun testResponseLargeTextError() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
val builder = StringBuilder()
|
||||
builder.append(CharArray(DavException.MAX_EXCERPT_SIZE-1) { '*' })
|
||||
builder.append("\u03C0") // Pi
|
||||
val body = builder.toString()
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(404)
|
||||
.setHeader("Content-Type", "text/html")
|
||||
.setBody(body))
|
||||
try {
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> }
|
||||
fail("Expected HttpException")
|
||||
} catch (e: HttpException) {
|
||||
assertEquals(e.code, 404)
|
||||
assertTrue(e.errors.isEmpty())
|
||||
assertEquals(
|
||||
body.substring(0, DavException.MAX_EXCERPT_SIZE-1),
|
||||
e.responseBody!!.substring(0, DavException.MAX_EXCERPT_SIZE-1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testResponseNonTextError() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(403)
|
||||
.setHeader("Content-Type", "application/octet-stream")
|
||||
.setBody("12345"))
|
||||
try {
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> }
|
||||
fail("Expected HttpException")
|
||||
} catch (e: HttpException) {
|
||||
assertEquals(e.code, 403)
|
||||
assertTrue(e.errors.isEmpty())
|
||||
assertNull(e.responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSerialization() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(500)
|
||||
.setHeader("Content-Type", "text/plain")
|
||||
.setBody("12345"))
|
||||
try {
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> }
|
||||
fail("Expected DavException")
|
||||
} catch (e: DavException) {
|
||||
val baos = ByteArrayOutputStream()
|
||||
val oos = ObjectOutputStream(baos)
|
||||
oos.writeObject(e)
|
||||
|
||||
val ois = ObjectInputStream(ByteArrayInputStream(baos.toByteArray()))
|
||||
val e2 = ois.readObject() as HttpException
|
||||
assertEquals(500, e2.code)
|
||||
assertTrue(e2.responseBody!!.contains("12345"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test precondition XML element (sample from RFC 4918 16)
|
||||
*/
|
||||
@Test
|
||||
fun testXmlError() {
|
||||
val url = sampleUrl()
|
||||
val dav = DavResource(httpClient, url)
|
||||
|
||||
val body = "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
|
||||
"<D:error xmlns:D=\"DAV:\">\n" +
|
||||
" <D:lock-token-submitted>\n" +
|
||||
" <D:href>/workspace/webdav/</D:href>\n" +
|
||||
" </D:lock-token-submitted>\n" +
|
||||
"</D:error>\n"
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(423)
|
||||
.setHeader("Content-Type", "application/xml; charset=\"utf-8\"")
|
||||
.setBody(body))
|
||||
try {
|
||||
dav.propfind(0, ResourceType.NAME) { _, _ -> }
|
||||
fail("Expected HttpException")
|
||||
} catch (e: HttpException) {
|
||||
assertEquals(e.code, 423)
|
||||
assertTrue(e.errors.any { it.name == Property.Name(XmlUtils.NS_WEBDAV, "lock-token-submitted") })
|
||||
assertEquals(body, e.responseBody)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class HttpExceptionTest {
|
||||
|
||||
private val responseMessage = "Unknown error"
|
||||
|
||||
@Test
|
||||
fun testHttpFormatting() {
|
||||
val request = Request.Builder()
|
||||
.post("REQUEST\nBODY".toRequestBody("text/something".toMediaType()))
|
||||
.url("http://example.com")
|
||||
.build()
|
||||
|
||||
val response = Response.Builder()
|
||||
.request(request)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(500)
|
||||
.message(responseMessage)
|
||||
.body("SERVER\r\nRESPONSE".toResponseBody("text/something-other".toMediaType()))
|
||||
.build()
|
||||
val e = HttpException(response)
|
||||
assertTrue(e.message!!.contains("500"))
|
||||
assertTrue(e.message!!.contains(responseMessage))
|
||||
assertTrue(e.requestBody!!.contains("REQUEST\nBODY"))
|
||||
assertTrue(e.responseBody!!.contains("SERVER\r\nRESPONSE"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package at.bitfire.dav4jvm.exception
|
||||
|
||||
import at.bitfire.dav4jvm.HttpUtils
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
|
||||
class ServiceUnavailableExceptionTest {
|
||||
|
||||
val response503 = Response.Builder()
|
||||
.request(Request.Builder()
|
||||
.url("http://www.example.com")
|
||||
.get()
|
||||
.build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(503).message("Try later")
|
||||
.build()
|
||||
|
||||
@Test
|
||||
fun testRetryAfter_NoTime() {
|
||||
val e = ServiceUnavailableException(response503)
|
||||
assertNull(e.retryAfter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRetryAfter_Seconds() {
|
||||
val response = response503.newBuilder()
|
||||
.header("Retry-After", "120")
|
||||
.build()
|
||||
val e = ServiceUnavailableException(response)
|
||||
assertNotNull(e.retryAfter)
|
||||
assertTrue(withinTimeRange(e.retryAfter!!, 120))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRetryAfter_Date() {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MINUTE, 30)
|
||||
val response = response503.newBuilder()
|
||||
.header("Retry-After", HttpUtils.formatDate(cal.time))
|
||||
.build()
|
||||
val e = ServiceUnavailableException(response)
|
||||
assertNotNull(e.retryAfter)
|
||||
assertTrue(withinTimeRange(e.retryAfter!!, 30*60))
|
||||
}
|
||||
|
||||
|
||||
private fun withinTimeRange(d: Date, seconds: Int): Boolean {
|
||||
val msCheck = d.time
|
||||
val msShouldBe = Date().time + seconds*1000
|
||||
// assume max. 5 seconds difference for test running
|
||||
return abs(msCheck - msShouldBe) < 5000
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class CalendarDescriptionTest: PropertyTest() {
|
||||
|
||||
@Test
|
||||
fun testCalendarDescription() {
|
||||
val results = parseProperty("<calendar-description xmlns=\"urn:ietf:params:xml:ns:caldav\">My Calendar</calendar-description>")
|
||||
val result = results.first() as CalendarDescription
|
||||
assertEquals("My Calendar", result.description)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class GetETagTest: PropertyTest() {
|
||||
|
||||
@Test
|
||||
fun testGetETag_NoText() {
|
||||
val results = parseProperty("<getetag xmlns=\"DAV:\"><invalid/></getetag>")
|
||||
val getETag = results.first() as GetETag
|
||||
assertNull(getETag.eTag)
|
||||
assertNull(getETag.weak)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetETag_Strong() {
|
||||
val results = parseProperty("<getetag xmlns=\"DAV:\">\"Correct strong ETag\"</getetag>")
|
||||
val getETag = results.first() as GetETag
|
||||
assertEquals("Correct strong ETag", getETag.eTag)
|
||||
assertFalse(getETag.weak!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetETag_Strong_NoQuotes() {
|
||||
val results = parseProperty("<getetag xmlns=\"DAV:\">Strong ETag without quotes</getetag>")
|
||||
val getETag = results.first() as GetETag
|
||||
assertEquals("Strong ETag without quotes", getETag.eTag)
|
||||
assertFalse(getETag.weak!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetETag_Weak() {
|
||||
val results = parseProperty("<getetag xmlns=\"DAV:\">W/\"Correct weak ETag\"</getetag>")
|
||||
val getETag = results.first() as GetETag
|
||||
assertEquals("Correct weak ETag", getETag.eTag)
|
||||
assertTrue(getETag.weak!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetETag_Weak_NoQuotes() {
|
||||
val results = parseProperty("<getetag xmlns=\"DAV:\">W/Weak ETag without quotes</getetag>")
|
||||
val getETag = results.first() as GetETag
|
||||
assertEquals("Weak ETag without quotes", getETag.eTag)
|
||||
assertTrue(getETag.weak!!)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class OwnerTest: PropertyTest() {
|
||||
|
||||
@Test
|
||||
fun testOwner_PlainText() {
|
||||
val results = parseProperty("<owner xmlns=\"DAV:\">https://example.com</owner>")
|
||||
val owner = results.first() as Owner
|
||||
assertNull(owner.href)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOwner_PlainTextAndHref() {
|
||||
val results = parseProperty("<owner xmlns=\"DAV:\">Principal Name. <href>mailto:owner@example.com</href> (test)</owner>")
|
||||
val owner = results.first() as Owner
|
||||
assertEquals("mailto:owner@example.com", owner.href)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOwner_Href() {
|
||||
val results = parseProperty("<owner xmlns=\"DAV:\"><href>https://example.com</href></owner>")
|
||||
val owner = results.first() as Owner
|
||||
assertEquals("https://example.com", owner.href)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package at.bitfire.dav4jvm.property
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import java.io.StringReader
|
||||
|
||||
open class PropertyTest {
|
||||
|
||||
companion object {
|
||||
|
||||
fun parseProperty(s: String): List<Property> {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(StringReader("<test>$s</test>"))
|
||||
parser.nextTag() // move into <test>
|
||||
return Property.parse(parser)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user