Add fallback when WebDAV PROPFIND with depth=2 isn't supported

This commit is contained in:
Torsten Grote 2024-06-13 14:58:37 -03:00
parent c522c460fd
commit 1dd898b068
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
4 changed files with 98 additions and 21 deletions

View file

@ -8,7 +8,11 @@ package com.stevesoltys.seedvault.plugins.webdav
import android.util.Log import android.util.Log
import at.bitfire.dav4jvm.BasicDigestAuthHandler import at.bitfire.dav4jvm.BasicDigestAuthHandler
import at.bitfire.dav4jvm.DavCollection import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.Response.HrefRelation.SELF
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.DisplayName
import at.bitfire.dav4jvm.property.ResourceType import at.bitfire.dav4jvm.property.ResourceType
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -124,6 +128,47 @@ internal abstract class WebDavStorage(
return pipedInputStream return pipedInputStream
} }
/**
* Tries to do [DavCollection.propfind] with a depth of `2` which is not in RFC4918.
* Since `infinity` isn't supported by nginx either,
* we fallback to iterating over all folders found with depth `1`
* and do another PROPFIND on those, passing the given [callback].
*/
protected fun DavCollection.propfindDepthTwo(callback: MultiResponseCallback) {
try {
propfind(
depth = 2, // this isn't defined in RFC4918
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
callback = callback,
)
} catch (e: HttpException) {
if (e.code == 400) {
Log.i(TAG, "Got ${e.response}, trying two depth=1 PROPFINDs...")
propfindFakeTwo(callback)
} else {
throw e
}
}
}
private fun DavCollection.propfindFakeTwo(callback: MultiResponseCallback) {
propfind(
depth = 1,
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
) { response, relation ->
debugLog { "propFindFakeTwo() = $response" }
// This callback will be called for everything in the folder
callback.onResponse(response, relation)
if (relation != SELF && response.isFolder()) {
DavCollection(okHttpClient, response.href).propfind(
depth = 1,
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
callback = callback,
)
}
}
}
protected suspend fun DavCollection.createFolder(xmlBody: String? = null): okhttp3.Response { protected suspend fun DavCollection.createFolder(xmlBody: String? = null): okhttp3.Response {
return try { return try {
suspendCoroutine { cont -> suspendCoroutine { cont ->

View file

@ -9,6 +9,7 @@ import android.content.Context
import android.util.Log import android.util.Log
import at.bitfire.dav4jvm.DavCollection import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.Response.HrefRelation.SELF import at.bitfire.dav4jvm.Response.HrefRelation.SELF
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.NotFoundException import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.property.DisplayName import at.bitfire.dav4jvm.property.DisplayName
import at.bitfire.dav4jvm.property.QuotaAvailableBytes import at.bitfire.dav4jvm.property.QuotaAvailableBytes
@ -174,20 +175,25 @@ internal class WebDavStoragePlugin(
// get all restore set tokens in root folder // get all restore set tokens in root folder
val tokens = ArrayList<Long>() val tokens = ArrayList<Long>()
davCollection.propfind( try {
depth = 2, davCollection.propfind(
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME), depth = 2,
) { response, relation -> reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
debugLog { "getAvailableBackups() = $response" } ) { response, relation ->
// This callback will be called for every file in the folder debugLog { "getAvailableBackups() = $response" }
if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2 && // This callback will be called for every file in the folder
response.hrefName() == FILE_BACKUP_METADATA if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2 &&
) { response.hrefName() == FILE_BACKUP_METADATA
val tokenName = response.href.pathSegments[response.href.pathSegments.size - 2] ) {
getTokenOrNull(tokenName)?.let { token -> val tokenName = response.href.pathSegments[response.href.pathSegments.size - 2]
tokens.add(token) getTokenOrNull(tokenName)?.let { token ->
tokens.add(token)
}
} }
} }
} catch (e: HttpException) {
if (e.code == 400) getBackupTokenWithDepthOne(davCollection, tokens)
else throw e
} }
val tokenIterator = tokens.iterator() val tokenIterator = tokens.iterator()
return generateSequence { return generateSequence {
@ -199,6 +205,34 @@ internal class WebDavStoragePlugin(
} }
} }
private fun getBackupTokenWithDepthOne(davCollection: DavCollection, tokens: ArrayList<Long>) {
davCollection.propfind(
depth = 1,
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
) { response, relation ->
debugLog { "getBackupTokenWithDepthOne() = $response" }
// we are only interested in sub-folders, skip rest
if (relation == SELF || !response.isFolder()) return@propfind
val token = getTokenOrNull(response.hrefName()) ?: return@propfind
val tokenUrl = response.href.newBuilder()
.addPathSegment(FILE_BACKUP_METADATA)
.build()
// check if .backup.metadata file exists using HEAD request,
// because some servers (e.g. nginx don't list hidden files with PROPFIND)
try {
DavCollection(okHttpClient, tokenUrl).head {
debugLog { "getBackupTokenWithDepthOne() = $response" }
tokens.add(token)
}
} catch (e: Exception) {
// just log exception and continue, we want to find all files that are there
Log.e(TAG, "Error retrieving $tokenUrl: ", e)
}
}
}
private fun getTokenOrNull(name: String): Long? { private fun getTokenOrNull(name: String): Long? {
val looksLikeToken = name.isNotEmpty() && tokenRegex.matches(name) val looksLikeToken = name.isNotEmpty() && tokenRegex.matches(name)
if (looksLikeToken) { if (looksLikeToken) {

View file

@ -77,10 +77,7 @@ internal class WebDavStoragePlugin(
val chunkIds = ArrayList<String>() val chunkIds = ArrayList<String>()
try { try {
val duration = measureDuration { val duration = measureDuration {
davCollection.propfind( davCollection.propfindDepthTwo { response, relation ->
depth = 2,
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
) { response, relation ->
debugLog { "getAvailableChunkIds() = $response" } debugLog { "getAvailableChunkIds() = $response" }
// This callback will be called for every file in the folder // This callback will be called for every file in the folder
if (relation != SELF && response.isFolder()) { if (relation != SELF && response.isFolder()) {
@ -162,13 +159,10 @@ internal class WebDavStoragePlugin(
val snapshots = ArrayList<StoredSnapshot>() val snapshots = ArrayList<StoredSnapshot>()
try { try {
davCollection.propfind( davCollection.propfindDepthTwo { response, relation ->
depth = 2,
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
) { response, relation ->
debugLog { "getBackupSnapshotsForRestore() = $response" } debugLog { "getBackupSnapshotsForRestore() = $response" }
// This callback will be called for every file in the folder // This callback will be called for every file in the folder
if (relation != SELF && !response.isFolder()) { if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2) {
val name = response.hrefName() val name = response.hrefName()
val match = snapshotRegex.matchEntire(name) val match = snapshotRegex.matchEntire(name)
if (match != null) { if (match != null) {

View file

@ -71,6 +71,10 @@ internal class WebDavStoragePluginTest : TransportTest() {
plugin.getOutputStream(token, FILE_BACKUP_METADATA).use { plugin.getOutputStream(token, FILE_BACKUP_METADATA).use {
it.write(metadata) it.write(metadata)
} }
// now we have data
assertTrue(plugin.hasData(token, FILE_BACKUP_METADATA))
try { try {
// now we have one backup matching our token // now we have one backup matching our token
val backups = plugin.getAvailableBackups()?.toSet() ?: fail() val backups = plugin.getAvailableBackups()?.toSet() ?: fail()