506 lines
20 KiB
Kotlin
506 lines
20 KiB
Kotlin
/*
|
||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||
*/
|
||
package at.bitfire.davdroid.servicedetection
|
||
|
||
import android.app.ActivityManager
|
||
import android.content.Context
|
||
import androidx.core.content.getSystemService
|
||
import at.bitfire.dav4jvm.DavResource
|
||
import at.bitfire.dav4jvm.Property
|
||
import at.bitfire.dav4jvm.Response
|
||
import at.bitfire.dav4jvm.UrlUtils
|
||
import at.bitfire.dav4jvm.exception.DavException
|
||
import at.bitfire.dav4jvm.exception.HttpException
|
||
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
|
||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||
import at.bitfire.dav4jvm.property.caldav.CalendarUserAddressSet
|
||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||
import at.bitfire.dav4jvm.property.common.HrefListProperty
|
||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrincipal
|
||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||
import at.bitfire.davdroid.db.Collection
|
||
import at.bitfire.davdroid.log.StringHandler
|
||
import at.bitfire.davdroid.network.DnsRecordResolver
|
||
import at.bitfire.davdroid.network.HttpClient
|
||
import at.bitfire.davdroid.settings.Credentials
|
||
import dagger.assisted.Assisted
|
||
import dagger.assisted.AssistedFactory
|
||
import dagger.assisted.AssistedInject
|
||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||
import okhttp3.HttpUrl
|
||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||
import org.xbill.DNS.Type
|
||
import java.io.InterruptedIOException
|
||
import java.net.SocketTimeoutException
|
||
import java.net.URI
|
||
import java.net.URISyntaxException
|
||
import java.util.LinkedList
|
||
import java.util.logging.Level
|
||
import java.util.logging.Logger
|
||
|
||
/**
|
||
* Does initial resource detection when an account is added. It uses the (user given) base URL to find
|
||
*
|
||
* - services (CalDAV and/or CardDAV),
|
||
* - principal,
|
||
* - homeset/collections (multistatus responses are handled through dav4jvm).
|
||
*
|
||
* @param context to build the HTTP client
|
||
* @param baseURI user-given base URI (either mailto: URI or http(s):// URL)
|
||
* @param credentials optional login credentials (username/password, client certificate, OAuth state)
|
||
*/
|
||
class DavResourceFinder @AssistedInject constructor(
|
||
@Assisted private val baseURI: URI,
|
||
@Assisted private val credentials: Credentials? = null,
|
||
@ApplicationContext val context: Context,
|
||
private val dnsRecordResolver: DnsRecordResolver,
|
||
httpClientBuilder: HttpClient.Builder
|
||
): AutoCloseable {
|
||
|
||
@AssistedFactory
|
||
interface Factory {
|
||
fun create(baseURI: URI, credentials: Credentials?): DavResourceFinder
|
||
}
|
||
|
||
enum class Service(val wellKnownName: String) {
|
||
CALDAV("caldav"),
|
||
CARDDAV("carddav");
|
||
|
||
override fun toString() = wellKnownName
|
||
}
|
||
|
||
val log: Logger = Logger.getLogger(javaClass.name)
|
||
private val logBuffer: StringHandler = initLogging()
|
||
|
||
private var encountered401 = false
|
||
|
||
private val httpClient = httpClientBuilder
|
||
.setLogger(log)
|
||
.apply {
|
||
if (credentials != null)
|
||
authenticate(
|
||
host = null,
|
||
getCredentials = { credentials }
|
||
)
|
||
}
|
||
.build()
|
||
|
||
override fun close() {
|
||
httpClient.close()
|
||
}
|
||
|
||
private fun initLogging(): StringHandler {
|
||
// don't use more than 1/4 of the available memory for a log string
|
||
val activityManager = context.getSystemService<ActivityManager>()!!
|
||
val maxLogSize = activityManager.memoryClass * (1024 * 1024 / 8)
|
||
val handler = StringHandler(maxLogSize)
|
||
|
||
// add StringHandler to logger
|
||
log.level = Level.ALL
|
||
log.addHandler(handler)
|
||
|
||
return handler
|
||
}
|
||
|
||
|
||
/**
|
||
* Finds the initial configuration (= runs the service detection process).
|
||
*
|
||
* In case of an error, it returns an empty [Configuration] with error logs
|
||
* instead of throwing an [Exception].
|
||
*
|
||
* @return service information – if there's neither a CalDAV service nor a CardDAV service,
|
||
* service detection was not successful
|
||
*/
|
||
fun findInitialConfiguration(): Configuration {
|
||
var cardDavConfig: Configuration.ServiceInfo? = null
|
||
var calDavConfig: Configuration.ServiceInfo? = null
|
||
|
||
try {
|
||
try {
|
||
cardDavConfig = findInitialConfiguration(Service.CARDDAV)
|
||
} catch (e: Exception) {
|
||
log.log(Level.INFO, "CardDAV service detection failed", e)
|
||
processException(e)
|
||
}
|
||
|
||
try {
|
||
calDavConfig = findInitialConfiguration(Service.CALDAV)
|
||
} catch (e: Exception) {
|
||
log.log(Level.INFO, "CalDAV service detection failed", e)
|
||
processException(e)
|
||
}
|
||
} catch(_: Exception) {
|
||
// we have been interrupted; reset results so that an error message will be shown
|
||
cardDavConfig = null
|
||
calDavConfig = null
|
||
}
|
||
|
||
return Configuration(
|
||
cardDAV = cardDavConfig,
|
||
calDAV = calDavConfig,
|
||
encountered401 = encountered401,
|
||
logs = logBuffer.toString()
|
||
)
|
||
}
|
||
|
||
private fun findInitialConfiguration(service: Service): Configuration.ServiceInfo? {
|
||
// domain for service discovery
|
||
var discoveryFQDN: String? = null
|
||
|
||
// discovered information goes into this config
|
||
val config = Configuration.ServiceInfo()
|
||
|
||
// Start discovering
|
||
log.info("Finding initial ${service.wellKnownName} service configuration")
|
||
when (baseURI.scheme.lowercase()) {
|
||
"http", "https" ->
|
||
baseURI.toHttpUrlOrNull()?.let { baseURL ->
|
||
// remember domain for service discovery
|
||
if (baseURL.scheme.equals("https", true))
|
||
// service discovery will only be tried for https URLs, because only secure service discovery is implemented
|
||
discoveryFQDN = baseURL.host
|
||
|
||
// Actual discovery process
|
||
checkBaseURL(baseURL, service, config)
|
||
|
||
// If principal was not found already, try well known URI
|
||
if (config.principal == null)
|
||
try {
|
||
config.principal = getCurrentUserPrincipal(baseURL.resolve("/.well-known/" + service.wellKnownName)!!, service)
|
||
} catch(e: Exception) {
|
||
log.log(Level.FINE, "Well-known URL detection failed", e)
|
||
processException(e)
|
||
}
|
||
}
|
||
"mailto" -> {
|
||
val mailbox = baseURI.schemeSpecificPart
|
||
val posAt = mailbox.lastIndexOf("@")
|
||
if (posAt != -1)
|
||
discoveryFQDN = mailbox.substring(posAt + 1)
|
||
}
|
||
}
|
||
|
||
// Second try: If user-given URL didn't reveal a principal, search for it (SERVICE DISCOVERY)
|
||
if (config.principal == null)
|
||
discoveryFQDN?.let { fqdn ->
|
||
log.info("No principal found at user-given URL, trying to discover for domain $fqdn")
|
||
try {
|
||
config.principal = discoverPrincipalUrl(fqdn, service)
|
||
} catch(e: Exception) {
|
||
log.log(Level.FINE, "$service service discovery failed", e)
|
||
processException(e)
|
||
}
|
||
}
|
||
|
||
// detect email address
|
||
if (service == Service.CALDAV)
|
||
config.principal?.let { principal ->
|
||
config.emails.addAll(queryEmailAddress(principal))
|
||
}
|
||
|
||
// return config or null if config doesn't contain useful information
|
||
val serviceAvailable = config.principal != null || config.homeSets.isNotEmpty() || config.collections.isNotEmpty()
|
||
return if (serviceAvailable)
|
||
config
|
||
else
|
||
null
|
||
}
|
||
|
||
/**
|
||
* Entry point of the actual discovery process.
|
||
*
|
||
* Queries the user-given URL (= base URL) to detect whether it contains a current-user-principal
|
||
* or whether it is a homeset or collection.
|
||
*
|
||
* @param baseURL base URL provided by the user
|
||
* @param service service to detect configuration for
|
||
* @param config found configuration will be written to this object
|
||
*/
|
||
private fun checkBaseURL(baseURL: HttpUrl, service: Service, config: Configuration.ServiceInfo) {
|
||
log.info("Checking user-given URL: $baseURL")
|
||
|
||
val davBaseURL = DavResource(httpClient.okHttpClient, baseURL, log)
|
||
try {
|
||
when (service) {
|
||
Service.CARDDAV -> {
|
||
davBaseURL.propfind(
|
||
0,
|
||
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME,
|
||
AddressbookHomeSet.NAME,
|
||
CurrentUserPrincipal.NAME
|
||
) { response, _ ->
|
||
scanResponse(ResourceType.ADDRESSBOOK, response, config)
|
||
}
|
||
}
|
||
Service.CALDAV -> {
|
||
davBaseURL.propfind(
|
||
0,
|
||
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME,
|
||
CalendarHomeSet.NAME,
|
||
CurrentUserPrincipal.NAME
|
||
) { response, _ ->
|
||
scanResponse(ResourceType.CALENDAR, response, config)
|
||
}
|
||
}
|
||
}
|
||
} catch(e: Exception) {
|
||
log.log(Level.FINE, "PROPFIND/OPTIONS on user-given URL failed", e)
|
||
processException(e)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Queries a user's email address using CalDAV scheduling: calendar-user-address-set.
|
||
* @param principal principal URL of the user
|
||
* @return list of found email addresses (empty if none)
|
||
*/
|
||
fun queryEmailAddress(principal: HttpUrl): List<String> {
|
||
val mailboxes = LinkedList<String>()
|
||
try {
|
||
DavResource(httpClient.okHttpClient, principal, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ ->
|
||
response[CalendarUserAddressSet::class.java]?.let { addressSet ->
|
||
for (href in addressSet.hrefs)
|
||
try {
|
||
val uri = URI(href)
|
||
if (uri.scheme.equals("mailto", true))
|
||
mailboxes.add(uri.schemeSpecificPart)
|
||
} catch(e: URISyntaxException) {
|
||
log.log(Level.WARNING, "Couldn't parse user address", e)
|
||
}
|
||
}
|
||
}
|
||
} catch(e: Exception) {
|
||
log.log(Level.WARNING, "Couldn't query user email address", e)
|
||
processException(e)
|
||
}
|
||
return mailboxes
|
||
}
|
||
|
||
/**
|
||
* Depending on [resourceType] (CalDAV or CardDAV), this method checks whether [davResponse] references
|
||
* - an address book or calendar (actual resource), and/or
|
||
* - an "address book home set" or a "calendar home set", and/or
|
||
* - whether it's a principal.
|
||
*
|
||
* Respectively, this method will add the response to [config.collections], [config.homesets] and/or [config.principal].
|
||
* Collection URLs will be stored with trailing "/".
|
||
*
|
||
* @param resourceType type of service to search for in the response
|
||
* @param davResponse response whose properties are evaluated
|
||
* @param config structure storing the references
|
||
*/
|
||
fun scanResponse(resourceType: Property.Name, davResponse: Response, config: Configuration.ServiceInfo) {
|
||
var principal: HttpUrl? = null
|
||
|
||
// Type mapping
|
||
val homeSetClass: Class<out HrefListProperty>
|
||
val serviceType: Service
|
||
when (resourceType) {
|
||
ResourceType.ADDRESSBOOK -> {
|
||
homeSetClass = AddressbookHomeSet::class.java
|
||
serviceType = Service.CARDDAV
|
||
}
|
||
ResourceType.CALENDAR -> {
|
||
homeSetClass = CalendarHomeSet::class.java
|
||
serviceType = Service.CALDAV
|
||
}
|
||
else -> throw IllegalArgumentException()
|
||
}
|
||
|
||
// check for current-user-principal
|
||
davResponse[CurrentUserPrincipal::class.java]?.href?.let { currentUserPrincipal ->
|
||
principal = davResponse.requestedUrl.resolve(currentUserPrincipal)
|
||
}
|
||
|
||
davResponse[ResourceType::class.java]?.let {
|
||
// Is it a calendar or an address book, ...
|
||
if (it.types.contains(resourceType))
|
||
Collection.fromDavResponse(davResponse)?.let { info ->
|
||
log.info("Found resource of type $resourceType at ${info.url}")
|
||
config.collections[info.url] = info
|
||
}
|
||
|
||
// ... and/or a principal?
|
||
if (it.types.contains(ResourceType.PRINCIPAL))
|
||
principal = davResponse.href
|
||
}
|
||
|
||
// Is it an addressbook-home-set or calendar-home-set?
|
||
davResponse[homeSetClass]?.let { homeSet ->
|
||
for (href in homeSet.hrefs) {
|
||
davResponse.requestedUrl.resolve(href)?.let {
|
||
val location = UrlUtils.withTrailingSlash(it)
|
||
log.info("Found home-set of type $resourceType at $location")
|
||
config.homeSets += location
|
||
}
|
||
}
|
||
}
|
||
|
||
// Is there a principal too?
|
||
principal?.let {
|
||
if (providesService(it, serviceType))
|
||
config.principal = principal
|
||
else
|
||
log.warning("Principal $principal doesn't provide $serviceType service")
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sends an OPTIONS request to determine whether a URL provides a given service.
|
||
*
|
||
* @param url URL to check; often a principal URL
|
||
* @param service service to check for
|
||
*
|
||
* @return whether the URL provides the given service
|
||
*/
|
||
fun providesService(url: HttpUrl, service: Service): Boolean {
|
||
var provided = false
|
||
try {
|
||
DavResource(httpClient.okHttpClient, url, log).options { capabilities, _ ->
|
||
if ((service == Service.CARDDAV && capabilities.contains("addressbook")) ||
|
||
(service == Service.CALDAV && capabilities.contains("calendar-access")))
|
||
provided = true
|
||
}
|
||
} catch(e: Exception) {
|
||
log.log(Level.SEVERE, "Couldn't detect services on $url", e)
|
||
if (e !is HttpException && e !is DavException)
|
||
throw e
|
||
}
|
||
return provided
|
||
}
|
||
|
||
|
||
/**
|
||
* Try to find the principal URL by performing service discovery on a given domain name.
|
||
* Only secure services (caldavs, carddavs) will be discovered!
|
||
*
|
||
* @param domain domain name, e.g. "icloud.com"
|
||
* @param service service to discover (CALDAV or CARDDAV)
|
||
* @return principal URL, or null if none found
|
||
*/
|
||
fun discoverPrincipalUrl(domain: String, service: Service): HttpUrl? {
|
||
val scheme: String
|
||
val fqdn: String
|
||
var port = 443
|
||
val paths = LinkedList<String>() // there may be multiple paths to try
|
||
|
||
val query = "_${service.wellKnownName}s._tcp.$domain"
|
||
log.fine("Looking up SRV records for $query")
|
||
|
||
val srvRecords = dnsRecordResolver.resolve(query, Type.SRV)
|
||
val srv = dnsRecordResolver.bestSRVRecord(srvRecords)
|
||
|
||
if (srv != null) {
|
||
// choose SRV record to use (query may return multiple SRV records)
|
||
scheme = "https"
|
||
fqdn = srv.target.toString(true)
|
||
port = srv.port
|
||
log.info("Found $service service at https://$fqdn:$port")
|
||
} else {
|
||
// no SRV records, try domain name as FQDN
|
||
log.info("Didn't find $service service, trying at https://$domain:$port")
|
||
|
||
scheme = "https"
|
||
fqdn = domain
|
||
}
|
||
|
||
// look for TXT record too (for initial context path)
|
||
val txtRecords = dnsRecordResolver.resolve(query, Type.TXT)
|
||
paths.addAll(dnsRecordResolver.pathsFromTXTRecords(txtRecords))
|
||
|
||
// in case there's a TXT record, but it's wrong, try well-known
|
||
paths.add("/.well-known/" + service.wellKnownName)
|
||
// if this fails too, try "/"
|
||
paths.add("/")
|
||
|
||
for (path in paths)
|
||
try {
|
||
val initialContextPath = HttpUrl.Builder()
|
||
.scheme(scheme)
|
||
.host(fqdn).port(port)
|
||
.encodedPath(path)
|
||
.build()
|
||
|
||
log.info("Trying to determine principal from initial context path=$initialContextPath")
|
||
val principal = getCurrentUserPrincipal(initialContextPath, service)
|
||
|
||
principal?.let { return it }
|
||
} catch(e: Exception) {
|
||
log.log(Level.WARNING, "No resource found", e)
|
||
processException(e)
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Queries a given URL for current-user-principal
|
||
*
|
||
* @param url URL to query with PROPFIND (Depth: 0)
|
||
* @param service required service (may be null, in which case no service check is done)
|
||
* @return current-user-principal URL that provides required service, or null if none
|
||
*/
|
||
fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? {
|
||
var principal: HttpUrl? = null
|
||
DavResource(httpClient.okHttpClient, url, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ ->
|
||
response[CurrentUserPrincipal::class.java]?.href?.let { href ->
|
||
response.requestedUrl.resolve(href)?.let {
|
||
log.info("Found current-user-principal: $it")
|
||
|
||
// service check
|
||
if (service != null && !providesService(it, service))
|
||
log.warning("Principal $it doesn't provide $service service")
|
||
else
|
||
principal = it
|
||
}
|
||
}
|
||
}
|
||
return principal
|
||
}
|
||
|
||
/**
|
||
* Processes a thrown exception like this:
|
||
*
|
||
* - If the Exception is an [UnauthorizedException] (HTTP 401), [encountered401] is set to *true*.
|
||
* - Re-throws the exception if it signals that the current thread was interrupted to stop the current operation.
|
||
*/
|
||
private fun processException(e: Exception) {
|
||
if (e is UnauthorizedException)
|
||
encountered401 = true
|
||
else if ((e is InterruptedIOException && e !is SocketTimeoutException) || e is InterruptedException)
|
||
throw e
|
||
}
|
||
|
||
|
||
// data classes
|
||
|
||
class Configuration(
|
||
val cardDAV: ServiceInfo?,
|
||
val calDAV: ServiceInfo?,
|
||
|
||
val encountered401: Boolean,
|
||
val logs: String
|
||
) {
|
||
|
||
data class ServiceInfo(
|
||
var principal: HttpUrl? = null,
|
||
val homeSets: MutableSet<HttpUrl> = HashSet(),
|
||
val collections: MutableMap<HttpUrl, Collection> = HashMap(),
|
||
|
||
val emails: MutableList<String> = LinkedList()
|
||
)
|
||
|
||
override fun toString() =
|
||
"DavResourceFinder.Configuration(cardDAV=$cardDAV, calDAV=$calDAV, encountered401=$encountered401, logs=(${logs.length} chars))"
|
||
|
||
}
|
||
|
||
}
|