Release 1.2.0 (#103)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Renovate / renovate (push) Successful in 1m11s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 18m4s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 1m33s
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Renovate / renovate (push) Successful in 1m11s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 18m4s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 1m33s
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Stable 1.2.0, after 1.2.0-beta.1 and beta.2. - Version bumped to 1.2.0 (1020099); the CHANGELOG entries moved from Unreleased to 1.2.0. - What's New for every shipped locale, now including cs-CZ. - Full store listing (title, short and full description) for every locale; new for cs-CZ, ar and fr-FR. Play only gets these through the `listing` lane. - `main` is merged in with the branch side kept. #81 and the Weblate updates already came in through the #88 squash, and the only lines unique to `main` were string keys that #88 removed. The #75 fix landed after beta.2, so no reporter has tested it yet. Closes #72, closes #78, closes #75, closes #92 Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/103
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import org.xbill.DNS.Message
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.Section
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import org.xbill.DNS.Type
|
||||
|
||||
/**
|
||||
* Reads SRV and TXT records out of a raw DNS answer.
|
||||
*
|
||||
* The lookup itself is the platform's job — on Android the system resolver,
|
||||
* which follows the current network and honours Private DNS — so this only
|
||||
* parses the wire format it hands back. A malformed answer reads as no records.
|
||||
*/
|
||||
object DnsAnswers {
|
||||
|
||||
const val TYPE_SRV = Type.SRV
|
||||
const val TYPE_TXT = Type.TXT
|
||||
|
||||
fun srv(answer: ByteArray): List<SrvRecord> = records<SRVRecord>(answer)
|
||||
.map { SrvRecord(it.priority, it.weight, it.port, it.target.toString(true)) }
|
||||
|
||||
fun txt(answer: ByteArray): List<String> = records<TXTRecord>(answer).flatMap { it.strings }
|
||||
|
||||
private inline fun <reified T> records(answer: ByteArray): List<T> = runCatching {
|
||||
Message(answer).getSection(Section.ANSWER).filterIsInstance<T>()
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import org.xbill.DNS.Lookup
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import org.xbill.DNS.Type
|
||||
|
||||
/**
|
||||
* SRV/TXT lookups over dnsjava.
|
||||
*
|
||||
* Android exposes no usable SRV API — `DnsResolver` arrived in API 29 but is
|
||||
* callback-only and does not help with the `TXT path=` half — and JNDI's DNS
|
||||
* provider does not exist on Android at all. dnsjava is what DAVx5 uses.
|
||||
*
|
||||
* A lookup that fails returns an empty list rather than throwing: DNS being
|
||||
* unavailable means "no SRV record", which is an ordinary and common answer, and
|
||||
* the well-known ladder still has rungs left.
|
||||
*/
|
||||
class DnsJavaResolver : DnsResolver {
|
||||
|
||||
override fun srv(name: String): List<SrvRecord> = runCatching {
|
||||
Lookup(name, Type.SRV).run()
|
||||
.orEmpty()
|
||||
.filterIsInstance<SRVRecord>()
|
||||
.map { SrvRecord(it.priority, it.weight, it.port, it.target.toString(true)) }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
override fun txt(name: String): List<String> = runCatching {
|
||||
Lookup(name, Type.TXT).run()
|
||||
.orEmpty()
|
||||
.filterIsInstance<TXTRecord>()
|
||||
.flatMap { it.strings }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
@@ -11,18 +11,20 @@ data class SrvRecord(
|
||||
val target: String,
|
||||
)
|
||||
|
||||
/** The SRV and TXT records published under one name. */
|
||||
data class DnsRecords(val srv: List<SrvRecord>, val txt: List<String>)
|
||||
|
||||
/**
|
||||
* DNS lookups for RFC 6764. Behind an interface so the pipeline is testable
|
||||
* without a network, and so the resolver can be swapped per platform.
|
||||
*/
|
||||
interface DnsResolver {
|
||||
fun srv(name: String): List<SrvRecord>
|
||||
fun txt(name: String): List<String>
|
||||
/** The SRV and TXT records for [name], looked up together. */
|
||||
fun lookup(name: String): DnsRecords
|
||||
|
||||
/** For the base-URL path, where DNS is never consulted. */
|
||||
object None : DnsResolver {
|
||||
override fun srv(name: String) = emptyList<SrvRecord>()
|
||||
override fun txt(name: String) = emptyList<String>()
|
||||
override fun lookup(name: String) = DnsRecords(emptyList(), emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,14 +114,16 @@ object ServiceDiscovery {
|
||||
val domain = domainOf(trimmed) ?: return emptyList()
|
||||
val candidates = mutableListOf<Candidate>()
|
||||
|
||||
val srv = dns.srv("$SRV_SERVICE.$domain")
|
||||
val records = dns.lookup("$SRV_SERVICE.$domain")
|
||||
val srv = records.srv
|
||||
.filterNot { it.target == "." || it.target.isEmpty() || it.port == 0 }
|
||||
// DNS names are case-insensitive and dnsjava returns the wire case, so
|
||||
// DNS names are case-insensitive and answers keep the wire case, so
|
||||
// "Calendar.Google.com." would otherwise walk straight past the guard.
|
||||
.filterNot { it.target.trimEnd('.').lowercase() in SRV_DEAD_ENDS }
|
||||
// ⚠️ RFC 2782 does not require a target to live inside the domain it
|
||||
// was queried for, and this ladder is walked over plain UDP DNS with
|
||||
// no DNSSEC — then walked *again* after a 401, with the
|
||||
// was queried for, and the answer is not DNSSEC-validated (Private
|
||||
// DNS encrypts the hop, it does not authenticate the zone) — and
|
||||
// this ladder is walked *again* after a 401, with the
|
||||
// authenticated client. An out-of-domain target is also one the
|
||||
// credential would never be offered to, since CalDavHttp scopes it
|
||||
// to the typed address's registrable domain, so following it can
|
||||
@@ -131,7 +135,7 @@ object ServiceDiscovery {
|
||||
|
||||
// The TXT path applies to the SRV target, and to the bare domain when
|
||||
// there is no SRV record — that is the GMX/Web.de shape.
|
||||
val txtPath = dns.txt("$SRV_SERVICE.$domain")
|
||||
val txtPath = records.txt
|
||||
.firstNotNullOfOrNull { record ->
|
||||
record.split(' ', ';')
|
||||
.firstOrNull { it.startsWith("path=", ignoreCase = true) }
|
||||
@@ -205,6 +209,19 @@ object ServiceDiscovery {
|
||||
return input.toHttpUrlOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The email address [input] names, without a `mailto:` prefix, or null when
|
||||
* it is a URL or a bare domain — the same split [candidatesFor] makes.
|
||||
*/
|
||||
fun emailOf(input: String): String? {
|
||||
val trimmed = input.trim()
|
||||
if (asBaseUrl(trimmed) != null) return null
|
||||
val email = trimmed.removePrefix("mailto:").removePrefix("MAILTO:")
|
||||
return email.takeIf {
|
||||
it.substringBeforeLast('@', "").isNotEmpty() && domainOf(it) != null
|
||||
}
|
||||
}
|
||||
|
||||
/** The domain of an email address, a `mailto:` URI, or a bare domain. */
|
||||
internal fun domainOf(input: String): String? {
|
||||
val withoutScheme = input.removePrefix("mailto:").removePrefix("MAILTO:")
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
import org.xbill.DNS.DClass
|
||||
import org.xbill.DNS.Message
|
||||
import org.xbill.DNS.Name
|
||||
import org.xbill.DNS.Record
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.Section
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import org.xbill.DNS.Type
|
||||
|
||||
class DnsAnswersTest {
|
||||
|
||||
private val name = Name.fromString("_caldavs._tcp.mailbox.org.")
|
||||
|
||||
private fun answer(type: Int, vararg records: Record): ByteArray {
|
||||
val message = Message.newQuery(Record.newRecord(name, type, DClass.IN))
|
||||
records.forEach { message.addRecord(it, Section.ANSWER) }
|
||||
return message.toWire()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reads SRV records`() {
|
||||
val bytes = answer(
|
||||
Type.SRV,
|
||||
SRVRecord(name, DClass.IN, 300, 10, 1, 443, Name.fromString("dav.mailbox.org.")),
|
||||
)
|
||||
assertThat(DnsAnswers.srv(bytes)).containsExactly(SrvRecord(10, 1, 443, "dav.mailbox.org"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reads TXT strings`() {
|
||||
val bytes = answer(Type.TXT, TXTRecord(name, DClass.IN, 300, "path=/"))
|
||||
assertThat(DnsAnswers.txt(bytes)).containsExactly("path=/")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty or malformed answer has no records`() {
|
||||
assertThat(DnsAnswers.srv(answer(Type.SRV))).isEmpty()
|
||||
assertThat(DnsAnswers.srv(byteArrayOf(1, 2, 3))).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,7 @@ class ServiceDiscoveryTest {
|
||||
val srv: Map<String, List<SrvRecord>> = emptyMap(),
|
||||
val txt: Map<String, List<String>> = emptyMap(),
|
||||
) : DnsResolver {
|
||||
override fun srv(name: String) = srv[name].orEmpty()
|
||||
override fun txt(name: String) = txt[name].orEmpty()
|
||||
override fun lookup(name: String) = DnsRecords(srv[name].orEmpty(), txt[name].orEmpty())
|
||||
}
|
||||
|
||||
private fun urls(input: String, dns: DnsResolver = DnsResolver.None) =
|
||||
@@ -153,6 +152,15 @@ class ServiceDiscoveryTest {
|
||||
assertThat(ServiceDiscovery.domainOf("example.com")).isEqualTo("example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an email is told apart from a URL and loses its mailto prefix`() {
|
||||
assertThat(ServiceDiscovery.emailOf("mailto:me@example.com")).isEqualTo("me@example.com")
|
||||
assertThat(ServiceDiscovery.emailOf(" me@example.com ")).isEqualTo("me@example.com")
|
||||
assertThat(ServiceDiscovery.emailOf("https://me@example.com/")).isNull()
|
||||
assertThat(ServiceDiscovery.emailOf("example.com")).isNull()
|
||||
assertThat(ServiceDiscovery.emailOf("@example.com")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `something that is neither an address nor a URL yields nothing`() {
|
||||
assertThat(ServiceDiscovery.candidatesFor("hello")).isEmpty()
|
||||
|
||||
Reference in New Issue
Block a user