Use new WebDavBackend in WebDavStoragePlugin

This commit is contained in:
Torsten Grote 2024-08-26 11:00:20 -03:00
parent 099e0ba6d5
commit 8c05ccc39d
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
18 changed files with 255 additions and 534 deletions

View file

@ -1,12 +0,0 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins.webdav
data class WebDavConfig(
val url: String,
val username: String,
val password: String,
)

View file

@ -9,13 +9,14 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.provider.Settings import android.provider.Settings
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
class WebDavFactory( class WebDavFactory(
private val context: Context, private val context: Context,
) { ) {
fun createAppStoragePlugin(config: WebDavConfig): StoragePlugin<WebDavConfig> { fun createAppStoragePlugin(config: WebDavConfig): StoragePlugin<WebDavConfig> {
return WebDavStoragePlugin(context, config) return WebDavStoragePlugin(config)
} }
fun createFilesStoragePlugin( fun createFilesStoragePlugin(

View file

@ -14,6 +14,7 @@ import com.stevesoltys.seedvault.settings.SettingsManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
import java.io.IOException import java.io.IOException
internal sealed interface WebDavConfigState { internal sealed interface WebDavConfigState {

View file

@ -7,6 +7,7 @@ package com.stevesoltys.seedvault.plugins.webdav
import android.content.Context import android.content.Context
import com.stevesoltys.seedvault.plugins.StorageProperties import com.stevesoltys.seedvault.plugins.StorageProperties
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
data class WebDavProperties( data class WebDavProperties(
override val config: WebDavConfig, override val config: WebDavConfig,

View file

@ -29,6 +29,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody import okhttp3.RequestBody
import okio.BufferedSink import okio.BufferedSink
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream

View file

@ -5,233 +5,95 @@
package com.stevesoltys.seedvault.plugins.webdav package com.stevesoltys.seedvault.plugins.webdav
import android.content.Context
import android.util.Log import android.util.Log
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.Response.HrefRelation.SELF
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes
import at.bitfire.dav4jvm.property.webdav.ResourceType
import com.stevesoltys.seedvault.plugins.EncryptedMetadata import com.stevesoltys.seedvault.plugins.EncryptedMetadata
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.chunkFolderRegex
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.plugins.saf.FILE_NO_MEDIA import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS
import com.stevesoltys.seedvault.plugins.tokenRegex import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import okhttp3.HttpUrl.Companion.toHttpUrl import org.calyxos.seedvault.core.backends.webdav.WebDavBackend
import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
internal class WebDavStoragePlugin( internal class WebDavStoragePlugin(
context: Context,
webDavConfig: WebDavConfig, webDavConfig: WebDavConfig,
root: String = DIRECTORY_ROOT, root: String = DIRECTORY_ROOT,
) : WebDavStorage(webDavConfig, root), StoragePlugin<WebDavConfig> { ) : WebDavStorage(webDavConfig, root), StoragePlugin<WebDavConfig> {
override suspend fun test(): Boolean { private val delegate = WebDavBackend(webDavConfig, root)
val location = (if (baseUrl.endsWith('/')) baseUrl else "$baseUrl/").toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
val webDavSupported = suspendCoroutine { cont -> override suspend fun test(): Boolean {
davCollection.options { davCapabilities, response -> return delegate.test()
debugLog { "test() = $davCapabilities $response" }
if (davCapabilities.contains("1")) cont.resume(true)
else if (davCapabilities.contains("2")) cont.resume(true)
else if (davCapabilities.contains("3")) cont.resume(true)
else cont.resume(false)
}
}
return webDavSupported
} }
override suspend fun getFreeSpace(): Long? { override suspend fun getFreeSpace(): Long? {
val location = "$url/".toHttpUrl() return delegate.getFreeSpace()
val davCollection = DavCollection(okHttpClient, location)
val availableBytes = suspendCoroutine { cont ->
davCollection.propfind(depth = 0, QuotaAvailableBytes.NAME) { response, _ ->
debugLog { "getFreeSpace() = $response" }
val quota = response.properties.getOrNull(0) as? QuotaAvailableBytes
val availableBytes = quota?.quotaAvailableBytes ?: -1
if (availableBytes > 0) {
cont.resume(availableBytes)
} else {
cont.resume(null)
}
}
}
return availableBytes
} }
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun startNewRestoreSet(token: Long) { override suspend fun startNewRestoreSet(token: Long) {
val location = "$url/$token/".toHttpUrl() // no-op
val davCollection = DavCollection(okHttpClient, location)
val response = davCollection.createFolder()
debugLog { "startNewRestoreSet($token) = $response" }
} }
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun initializeDevice() { override suspend fun initializeDevice() {
// TODO does it make sense to delete anything // no-op
// when [startNewRestoreSet] is always called first? Maybe unify both calls?
val location = "$url/".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
try {
davCollection.head { response ->
debugLog { "Root exists: $response" }
}
} catch (e: NotFoundException) {
val response = davCollection.createFolder()
debugLog { "initializeDevice() = $response" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
} }
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun getOutputStream(token: Long, name: String): OutputStream { override suspend fun getOutputStream(token: Long, name: String): OutputStream {
val location = "$url/$token/$name".toHttpUrl() val handle = when (name) {
return try { FILE_BACKUP_METADATA -> LegacyAppBackupFile.Metadata(token)
getOutputStream(location) FILE_BACKUP_ICONS -> LegacyAppBackupFile.IconsFile(token)
} catch (e: Exception) { else -> LegacyAppBackupFile.Blob(token, name)
if (e is IOException) throw e
else throw IOException("Error getting OutputStream for $token and $name: ", e)
} }
return delegate.save(handle).outputStream()
} }
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun getInputStream(token: Long, name: String): InputStream { override suspend fun getInputStream(token: Long, name: String): InputStream {
val location = "$url/$token/$name".toHttpUrl() val handle = when (name) {
return try { FILE_BACKUP_METADATA -> LegacyAppBackupFile.Metadata(token)
getInputStream(location) FILE_BACKUP_ICONS -> LegacyAppBackupFile.IconsFile(token)
} catch (e: Exception) { else -> LegacyAppBackupFile.Blob(token, name)
if (e is IOException) throw e
else throw IOException("Error getting InputStream for $token and $name: ", e)
} }
return delegate.load(handle).inputStream()
} }
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun removeData(token: Long, name: String) { override suspend fun removeData(token: Long, name: String) {
val location = "$url/$token/$name".toHttpUrl() val handle = when (name) {
val davCollection = DavCollection(okHttpClient, location) FILE_BACKUP_METADATA -> LegacyAppBackupFile.Metadata(token)
FILE_BACKUP_ICONS -> LegacyAppBackupFile.IconsFile(token)
try { else -> LegacyAppBackupFile.Blob(token, name)
val response = suspendCoroutine { cont ->
davCollection.delete { response ->
cont.resume(response)
}
}
debugLog { "removeData($token, $name) = $response" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
} }
delegate.remove(handle)
} }
override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? { override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? {
return try { return try {
doGetAvailableBackups() // get all restore set tokens in root folder that have a metadata file
val tokens = ArrayList<Long>()
delegate.list(null, LegacyAppBackupFile.Metadata::class) { fileInfo ->
val handle = fileInfo.fileHandle as LegacyAppBackupFile.Metadata
tokens.add(handle.token)
}
val tokenIterator = tokens.iterator()
return generateSequence {
if (!tokenIterator.hasNext()) return@generateSequence null // end sequence
val token = tokenIterator.next()
EncryptedMetadata(token) {
getInputStream(token, FILE_BACKUP_METADATA)
}
}
} catch (e: Throwable) { // NoClassDefFound isn't an [Exception], can get thrown by dav4jvm } catch (e: Throwable) { // NoClassDefFound isn't an [Exception], can get thrown by dav4jvm
Log.e(TAG, "Error getting available backups: ", e) Log.e(TAG, "Error getting available backups: ", e)
null null
} }
} }
private suspend fun doGetAvailableBackups(): Sequence<EncryptedMetadata> { override val providerPackageName: String? = null // 100% built-in plugin
val location = "$url/".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
// get all restore set tokens in root folder
val tokens = ArrayList<Long>()
try {
davCollection.propfind(
depth = 2,
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
) { response, relation ->
debugLog { "getAvailableBackups() = $response" }
// This callback will be called for every file in the folder
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 ->
tokens.add(token)
}
}
}
} catch (e: HttpException) {
if (e.isUnsupportedPropfind()) getBackupTokenWithDepthOne(davCollection, tokens)
else throw e
}
val tokenIterator = tokens.iterator()
return generateSequence {
if (!tokenIterator.hasNext()) return@generateSequence null // end sequence
val token = tokenIterator.next()
EncryptedMetadata(token) {
getInputStream(token, FILE_BACKUP_METADATA)
}
}
}
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? {
val looksLikeToken = name.isNotEmpty() && tokenRegex.matches(name)
if (looksLikeToken) {
return try {
name.toLong()
} catch (e: NumberFormatException) {
throw AssertionError(e) // regex must be wrong
}
}
if (isUnexpectedFile(name)) {
Log.w(TAG, "Found invalid backup set folder: $name")
}
return null
}
private fun isUnexpectedFile(name: String): Boolean {
return name != FILE_NO_MEDIA &&
!chunkFolderRegex.matches(name) &&
!name.endsWith(SNAPSHOT_EXT)
}
override val providerPackageName: String = context.packageName // 100% built-in plugin
} }

View file

@ -15,11 +15,11 @@ import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
import com.stevesoltys.seedvault.plugins.saf.SafStorage import com.stevesoltys.seedvault.plugins.saf.SafStorage
import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig
import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler.Companion.createWebDavProperties import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler.Companion.createWebDavProperties
import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties
import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
import java.util.concurrent.ConcurrentSkipListSet import java.util.concurrent.ConcurrentSkipListSet
internal const val PREF_KEY_TOKEN = "token" internal const val PREF_KEY_TOKEN = "token"

View file

@ -5,187 +5,75 @@
package com.stevesoltys.seedvault.storage package com.stevesoltys.seedvault.storage
import android.util.Log
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.Response.HrefRelation.SELF
import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.ResourceType
import com.stevesoltys.seedvault.plugins.chunkFolderRegex
import com.stevesoltys.seedvault.plugins.webdav.DIRECTORY_ROOT import com.stevesoltys.seedvault.plugins.webdav.DIRECTORY_ROOT
import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig
import com.stevesoltys.seedvault.plugins.webdav.WebDavStorage
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.calyxos.backup.storage.api.StoragePlugin import org.calyxos.backup.storage.api.StoragePlugin
import org.calyxos.backup.storage.api.StoredSnapshot import org.calyxos.backup.storage.api.StoredSnapshot
import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT import org.calyxos.seedvault.core.backends.FileBackupFileType
import org.calyxos.backup.storage.plugin.PluginConstants.chunkRegex import org.calyxos.seedvault.core.backends.TopLevelFolder
import org.calyxos.backup.storage.plugin.PluginConstants.snapshotRegex import org.calyxos.seedvault.core.backends.webdav.WebDavBackend
import org.koin.core.time.measureDuration import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
internal class WebDavStoragePlugin( internal class WebDavStoragePlugin(
/** /**
* The result of Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) * The result of Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
*/ */
androidId: String, private val androidId: String,
webDavConfig: WebDavConfig, webDavConfig: WebDavConfig,
root: String = DIRECTORY_ROOT, root: String = DIRECTORY_ROOT,
) : WebDavStorage(webDavConfig, root), StoragePlugin { ) : StoragePlugin {
/** private val topLevelFolder = TopLevelFolder("$androidId.sv")
* The folder name is our user ID plus .sv extension (for SeedVault). private val delegate = WebDavBackend(webDavConfig, root)
* The user or `androidId` is unique to each combination of app-signing key, user, and device
* so we don't leak anything by not hashing this and can use it as is.
*/
private val folder: String = "$androidId.sv"
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun init() { override suspend fun init() {
val location = "$url/".toHttpUrl() // no-op
val davCollection = DavCollection(okHttpClient, location)
try {
davCollection.head { response ->
debugLog { "Root exists: $response" }
}
} catch (e: NotFoundException) {
val response = davCollection.createFolder()
debugLog { "init() = $response" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
} }
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun getAvailableChunkIds(): List<String> { override suspend fun getAvailableChunkIds(): List<String> {
val location = "$url/$folder/".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
debugLog { "getAvailableChunkIds($location)" }
val expectedChunkFolders = (0x00..0xff).map {
Integer.toHexString(it).padStart(2, '0')
}.toHashSet()
val chunkIds = ArrayList<String>() val chunkIds = ArrayList<String>()
try { delegate.list(topLevelFolder, FileBackupFileType.Blob::class) { fileInfo ->
val duration = measureDuration { chunkIds.add(fileInfo.fileHandle.name)
davCollection.propfindDepthTwo { response, relation ->
debugLog { "getAvailableChunkIds() = $response" }
// This callback will be called for every file in the folder
if (relation != SELF && response.isFolder()) {
val name = response.hrefName()
if (chunkFolderRegex.matches(name)) {
expectedChunkFolders.remove(name)
}
} else if (relation != SELF && response.href.pathSize >= 2) {
val folderName =
response.href.pathSegments[response.href.pathSegments.size - 2]
if (folderName != folder && chunkFolderRegex.matches(folderName)) {
val name = response.hrefName()
if (chunkRegex.matches(name)) chunkIds.add(name)
}
}
}
}
Log.i(TAG, "Retrieving chunks took $duration")
} catch (e: NotFoundException) {
debugLog { "Folder not found: $location" }
davCollection.createFolder()
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error populating chunk folders: ", e)
} }
Log.i(TAG, "Got ${chunkIds.size} available chunks")
createMissingChunkFolders(expectedChunkFolders)
return chunkIds return chunkIds
} }
@Throws(IOException::class)
private suspend fun createMissingChunkFolders(
missingChunkFolders: Set<String>,
) {
val s = missingChunkFolders.size
for ((i, chunkFolderName) in missingChunkFolders.withIndex()) {
val location = "$url/$folder/$chunkFolderName/".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
val response = davCollection.createFolder()
debugLog { "Created missing folder $chunkFolderName (${i + 1}/$s) $response" }
}
}
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun getChunkOutputStream(chunkId: String): OutputStream { override suspend fun getChunkOutputStream(chunkId: String): OutputStream {
val chunkFolderName = chunkId.substring(0, 2) val fileHandle = FileBackupFileType.Blob(androidId, chunkId)
val location = "$url/$folder/$chunkFolderName/$chunkId".toHttpUrl() return delegate.save(fileHandle).outputStream()
debugLog { "getChunkOutputStream($location) for $chunkId" }
return try {
getOutputStream(location)
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting OutputStream for $chunkId: ", e)
}
} }
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream { override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
val location = "$url/$folder/$timestamp$SNAPSHOT_EXT".toHttpUrl() val fileHandle = FileBackupFileType.Snapshot(androidId, timestamp)
debugLog { "getBackupSnapshotOutputStream($location)" } return delegate.save(fileHandle).outputStream()
return try {
getOutputStream(location)
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting OutputStream for $timestamp$SNAPSHOT_EXT: ", e)
}
} }
/************************* Restore *******************************/ /************************* Restore *******************************/
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun getBackupSnapshotsForRestore(): List<StoredSnapshot> { override suspend fun getBackupSnapshotsForRestore(): List<StoredSnapshot> {
val location = "$url/".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
debugLog { "getBackupSnapshotsForRestore($location)" }
val snapshots = ArrayList<StoredSnapshot>() val snapshots = ArrayList<StoredSnapshot>()
try { delegate.list(null, FileBackupFileType.Snapshot::class) { fileInfo ->
davCollection.propfindDepthTwo { response, relation -> val handle = fileInfo.fileHandle as FileBackupFileType.Snapshot
debugLog { "getBackupSnapshotsForRestore() = $response" } val folderName = handle.topLevelFolder.name
// This callback will be called for every file in the folder val timestamp = handle.time
if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2) { val storedSnapshot = StoredSnapshot(folderName, timestamp)
val name = response.hrefName() snapshots.add(storedSnapshot)
val match = snapshotRegex.matchEntire(name)
if (match != null) {
val timestamp = match.groupValues[1].toLong()
val folderName =
response.href.pathSegments[response.href.pathSegments.size - 2]
val storedSnapshot = StoredSnapshot(folderName, timestamp)
snapshots.add(storedSnapshot)
}
}
}
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting snapshots for restore: ", e)
} }
return snapshots return snapshots
} }
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun getBackupSnapshotInputStream(storedSnapshot: StoredSnapshot): InputStream { override suspend fun getBackupSnapshotInputStream(storedSnapshot: StoredSnapshot): InputStream {
val timestamp = storedSnapshot.timestamp val androidId = storedSnapshot.androidId
val location = "$url/${storedSnapshot.userId}/$timestamp$SNAPSHOT_EXT".toHttpUrl() val handle = FileBackupFileType.Snapshot(androidId, storedSnapshot.timestamp)
debugLog { "getBackupSnapshotInputStream($location)" } return delegate.load(handle).inputStream()
return try {
getInputStream(location)
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting InputStream for $storedSnapshot: ", e)
}
} }
@Throws(IOException::class) @Throws(IOException::class)
@ -193,92 +81,38 @@ internal class WebDavStoragePlugin(
snapshot: StoredSnapshot, snapshot: StoredSnapshot,
chunkId: String, chunkId: String,
): InputStream { ): InputStream {
val chunkFolderName = chunkId.substring(0, 2) val handle = FileBackupFileType.Blob(snapshot.androidId, chunkId)
val location = "$url/${snapshot.userId}/$chunkFolderName/$chunkId".toHttpUrl() return delegate.load(handle).inputStream()
debugLog { "getChunkInputStream($location) for $chunkId" }
return try {
getInputStream(location)
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting InputStream for $chunkFolderName/$chunkId: ", e)
}
} }
/************************* Pruning *******************************/ /************************* Pruning *******************************/
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun getCurrentBackupSnapshots(): List<StoredSnapshot> { override suspend fun getCurrentBackupSnapshots(): List<StoredSnapshot> {
val location = "$url/$folder/".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
debugLog { "getCurrentBackupSnapshots($location)" }
val snapshots = ArrayList<StoredSnapshot>() val snapshots = ArrayList<StoredSnapshot>()
try { delegate.list(topLevelFolder, FileBackupFileType.Snapshot::class) { fileInfo ->
val duration = measureDuration { val handle = fileInfo.fileHandle as FileBackupFileType.Snapshot
davCollection.propfind( val folderName = handle.topLevelFolder.name
depth = 1, val timestamp = handle.time
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME), val storedSnapshot = StoredSnapshot(folderName, timestamp)
) { response, relation -> snapshots.add(storedSnapshot)
debugLog { "getCurrentBackupSnapshots() = $response" }
// This callback will be called for every file in the folder
if (relation != SELF && !response.isFolder()) {
val match = snapshotRegex.matchEntire(response.hrefName())
if (match != null) {
val timestamp = match.groupValues[1].toLong()
val storedSnapshot = StoredSnapshot(folder, timestamp)
snapshots.add(storedSnapshot)
}
}
}
}
Log.i(TAG, "getCurrentBackupSnapshots took $duration")
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting current snapshots: ", e)
} }
Log.i(TAG, "Got ${snapshots.size} snapshots.")
return snapshots return snapshots
} }
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun deleteBackupSnapshot(storedSnapshot: StoredSnapshot) { override suspend fun deleteBackupSnapshot(storedSnapshot: StoredSnapshot) {
val timestamp = storedSnapshot.timestamp val androidId = storedSnapshot.androidId
Log.d(TAG, "Deleting snapshot $timestamp") val handle = FileBackupFileType.Snapshot(androidId, storedSnapshot.timestamp)
delegate.remove(handle)
val location = "$url/${storedSnapshot.userId}/$timestamp$SNAPSHOT_EXT".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
try {
val response = suspendCoroutine { cont ->
davCollection.delete { response ->
cont.resume(response)
}
}
debugLog { "deleteBackupSnapshot() = $response" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
} }
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun deleteChunks(chunkIds: List<String>) { override suspend fun deleteChunks(chunkIds: List<String>) {
chunkIds.forEach { chunkId -> chunkIds.forEach { chunkId ->
val chunkFolderName = chunkId.substring(0, 2) val androidId = topLevelFolder.name.substringBefore(".sv")
val location = "$url/$folder/$chunkFolderName/$chunkId".toHttpUrl() val handle = FileBackupFileType.Blob(androidId, chunkId)
val davCollection = DavCollection(okHttpClient, location) delegate.remove(handle)
try {
val response = suspendCoroutine { cont ->
davCollection.delete { response ->
cont.resume(response)
}
}
debugLog { "deleteChunks($chunkId) = $response" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
} }
} }
} }

View file

@ -16,7 +16,6 @@ import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.SafHandler import com.stevesoltys.seedvault.plugins.saf.SafHandler
import com.stevesoltys.seedvault.plugins.saf.SafStorage import com.stevesoltys.seedvault.plugins.saf.SafStorage
import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig
import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler
import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties
import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin
@ -26,6 +25,7 @@ import com.stevesoltys.seedvault.ui.MutableLiveEvent
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
internal abstract class StorageViewModel( internal abstract class StorageViewModel(
private val app: Application, private val app: Application,

View file

@ -8,23 +8,20 @@ package com.stevesoltys.seedvault.plugins.webdav
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stevesoltys.seedvault.TestApp import com.stevesoltys.seedvault.TestApp
import com.stevesoltys.seedvault.getRandomByteArray import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.plugins.EncryptedMetadata import com.stevesoltys.seedvault.plugins.EncryptedMetadata
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.TransportTest
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
import org.junit.Test import org.junit.Test
import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import java.io.IOException
import kotlin.random.Random
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@Config( @Config(
@ -33,13 +30,13 @@ import kotlin.random.Random
) )
internal class WebDavStoragePluginTest : TransportTest() { internal class WebDavStoragePluginTest : TransportTest() {
private val plugin = WebDavStoragePlugin(context, WebDavTestConfig.getConfig()) private val plugin = WebDavStoragePlugin(WebDavTestConfig.getConfig())
@Test @Test
fun `test self-test`() = runBlocking { fun `test self-test`() = runBlocking {
assertTrue(plugin.test()) assertTrue(plugin.test())
val plugin2 = WebDavStoragePlugin(context, WebDavConfig("https://github.com/", "", "")) val plugin2 = WebDavStoragePlugin(WebDavConfig("https://github.com/", "", ""))
val e = assertThrows<Exception> { val e = assertThrows<Exception> {
assertFalse(plugin2.test()) assertFalse(plugin2.test())
} }
@ -86,37 +83,4 @@ internal class WebDavStoragePluginTest : TransportTest() {
} }
} }
@Test
fun `test streams for non-existent data`() = runBlocking {
val token = Random.nextLong(System.currentTimeMillis(), 9999999999999)
val file = getRandomString()
assertFalse(plugin.hasData(token, file))
assertThrows<IOException> {
plugin.getOutputStream(token, file).use { it.write(getRandomByteArray()) }
}
assertThrows<IOException> {
plugin.getInputStream(token, file).use {
it.readAllBytes()
}
}
Unit
}
@Test
fun `test missing root dir`() = runBlocking {
val plugin = WebDavStoragePlugin(context, WebDavTestConfig.getConfig(), getRandomString())
assertNull(plugin.getAvailableBackups())
assertFalse(plugin.hasData(42L, "foo"))
assertThrows<IOException> {
plugin.removeData(42L, "foo")
}
Unit
}
} }

View file

@ -5,6 +5,7 @@
package com.stevesoltys.seedvault.plugins.webdav package com.stevesoltys.seedvault.plugins.webdav
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
import org.junit.Assume.assumeFalse import org.junit.Assume.assumeFalse
import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.Assertions.fail

View file

@ -6,7 +6,6 @@
package com.stevesoltys.seedvault.storage package com.stevesoltys.seedvault.storage
import com.stevesoltys.seedvault.getRandomByteArray import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.plugins.webdav.WebDavTestConfig import com.stevesoltys.seedvault.plugins.webdav.WebDavTestConfig
import com.stevesoltys.seedvault.transport.backup.BackupTest import com.stevesoltys.seedvault.transport.backup.BackupTest
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -14,14 +13,13 @@ import org.calyxos.backup.storage.api.StoredSnapshot
import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.jupiter.api.assertThrows
import java.io.IOException
internal class WebDavStoragePluginTest : BackupTest() { internal class WebDavStoragePluginTest : BackupTest() {
private val plugin = WebDavStoragePlugin("foo", WebDavTestConfig.getConfig()) private val androidId = "abcdef0123456789"
private val plugin = WebDavStoragePlugin(androidId, WebDavTestConfig.getConfig())
private val snapshot = StoredSnapshot("foo.sv", System.currentTimeMillis()) private val snapshot = StoredSnapshot("$androidId.sv", System.currentTimeMillis())
@Test @Test
fun `test chunks`() = runBlocking { fun `test chunks`() = runBlocking {
@ -82,8 +80,9 @@ internal class WebDavStoragePluginTest : BackupTest() {
) )
// other device writes another snapshot // other device writes another snapshot
val otherPlugin = WebDavStoragePlugin("bar", WebDavTestConfig.getConfig()) val androidId2 = "0123456789abcdef"
val otherSnapshot = StoredSnapshot("bar.sv", System.currentTimeMillis()) val otherPlugin = WebDavStoragePlugin(androidId2, WebDavTestConfig.getConfig())
val otherSnapshot = StoredSnapshot("$androidId2.sv", System.currentTimeMillis())
val otherSnapshotBytes = getRandomByteArray() val otherSnapshotBytes = getRandomByteArray()
assertEquals(emptyList<String>(), otherPlugin.getAvailableChunkIds()) assertEquals(emptyList<String>(), otherPlugin.getAvailableChunkIds())
otherPlugin.getBackupSnapshotOutputStream(otherSnapshot.timestamp).use { otherPlugin.getBackupSnapshotOutputStream(otherSnapshot.timestamp).use {
@ -104,44 +103,6 @@ internal class WebDavStoragePluginTest : BackupTest() {
} }
} }
@Test
fun `test missing root dir`() = runBlocking {
val plugin = WebDavStoragePlugin(
androidId = "foo",
webDavConfig = WebDavTestConfig.getConfig(),
root = getRandomString(),
)
assertThrows<IOException> {
plugin.getCurrentBackupSnapshots()
}
assertThrows<IOException> {
plugin.getBackupSnapshotsForRestore()
}
assertThrows<IOException> {
plugin.getAvailableChunkIds()
}
assertThrows<IOException> {
plugin.deleteChunks(listOf("foo"))
}
assertThrows<IOException> {
plugin.deleteBackupSnapshot(snapshot)
}
assertThrows<IOException> {
plugin.getBackupSnapshotOutputStream(snapshot.timestamp).close()
}
assertThrows<IOException> {
plugin.getBackupSnapshotInputStream(snapshot).use { it.readAllBytes() }
}
assertThrows<IOException> {
plugin.getChunkOutputStream("foo").close()
}
assertThrows<IOException> {
plugin.getChunkInputStream(snapshot, "foo").use { it.readAllBytes() }
}
Unit
}
} }
private fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) } private fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }

View file

@ -0,0 +1 @@
org.slf4j.simpleLogger.defaultLogLevel=trace

View file

@ -5,12 +5,26 @@
package org.calyxos.seedvault.core.backends package org.calyxos.seedvault.core.backends
import androidx.annotation.VisibleForTesting
import okio.BufferedSink import okio.BufferedSink
import okio.BufferedSource import okio.BufferedSource
import kotlin.reflect.KClass import kotlin.reflect.KClass
public interface Backend { public interface Backend {
/**
* Returns true if the plugin is working, or false if it isn't.
* @throws Exception any kind of exception to provide more info on the error
*/
public suspend fun test(): Boolean
/**
* Retrieves the available storage space in bytes.
* @return the number of bytes available or null if the number is unknown.
* Returning a negative number or zero to indicate unknown is discouraged.
*/
public suspend fun getFreeSpace(): Long?
public suspend fun save(handle: FileHandle): BufferedSink public suspend fun save(handle: FileHandle): BufferedSink
public suspend fun load(handle: FileHandle): BufferedSource public suspend fun load(handle: FileHandle): BufferedSource
@ -25,7 +39,16 @@ public interface Backend {
public suspend fun rename(from: TopLevelFolder, to: TopLevelFolder) public suspend fun rename(from: TopLevelFolder, to: TopLevelFolder)
// TODO really all? @VisibleForTesting
public suspend fun removeAll() public suspend fun removeAll()
/**
* Returns the package name of the app that provides the storage backend
* which is used for the current backup location.
*
* Backends are advised to cache this as it will be requested frequently.
*
* @return null if no package name could be found
*/
public val providerPackageName: String?
} }

View file

@ -43,6 +43,12 @@ public sealed class LegacyAppBackupFile : FileHandle() {
public sealed class FileBackupFileType : FileHandle() { public sealed class FileBackupFileType : FileHandle() {
public abstract val androidId: String public abstract val androidId: String
/**
* The folder name is our user ID plus .sv extension (for SeedVault).
* The user or `androidId` is unique to each combination of app-signing key, user, and device
* so we don't leak anything by not hashing this and can use it as is.
*/
public val topLevelFolder: TopLevelFolder get() = TopLevelFolder("$androidId.sv") public val topLevelFolder: TopLevelFolder get() = TopLevelFolder("$androidId.sv")
public data class Blob( public data class Blob(

View file

@ -45,6 +45,14 @@ public class SafBackend(
private val context: Context get() = appContext.getBackendContext { safConfig.isUsb } private val context: Context get() = appContext.getBackendContext { safConfig.isUsb }
private val cache = DocumentFileCache(context, safConfig.getDocumentFile(context), root) private val cache = DocumentFileCache(context, safConfig.getDocumentFile(context), root)
override suspend fun test(): Boolean {
TODO("Not yet implemented")
}
override suspend fun getFreeSpace(): Long? {
TODO("Not yet implemented")
}
override suspend fun save(handle: FileHandle): BufferedSink { override suspend fun save(handle: FileHandle): BufferedSink {
val file = cache.getFile(handle) val file = cache.getFile(handle)
return file.getOutputStream(context.contentResolver).sink().buffer() return file.getOutputStream(context.contentResolver).sink().buffer()
@ -148,4 +156,6 @@ public class SafBackend(
} }
} }
override val providerPackageName: String? get() = TODO("Not yet implemented")
} }

View file

@ -11,6 +11,7 @@ import at.bitfire.dav4jvm.PropertyRegistry
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.HttpException
import at.bitfire.dav4jvm.exception.NotFoundException import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
@ -51,7 +52,7 @@ import kotlin.reflect.KClass
private const val DEBUG_LOG = true private const val DEBUG_LOG = true
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
internal class WebDavBackend( public class WebDavBackend(
webDavConfig: WebDavConfig, webDavConfig: WebDavConfig,
root: String = DIRECTORY_ROOT, root: String = DIRECTORY_ROOT,
) : Backend { ) : Backend {
@ -75,13 +76,53 @@ internal class WebDavBackend(
.retryOnConnectionFailure(true) .retryOnConnectionFailure(true)
.build() .build()
private val url = "${webDavConfig.url}/$root" private val baseUrl = webDavConfig.url.trimEnd('/')
private val url = "$baseUrl/$root"
private val folders = mutableSetOf<HttpUrl>() // cache for existing/created folders private val folders = mutableSetOf<HttpUrl>() // cache for existing/created folders
init { init {
PropertyRegistry.register(GetLastModified.Factory) PropertyRegistry.register(GetLastModified.Factory)
} }
override suspend fun test(): Boolean {
val location = "$baseUrl/".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
val hasCaps = suspendCoroutine { cont ->
davCollection.options { davCapabilities, response ->
log.debugLog { "test() = $davCapabilities $response" }
if (davCapabilities.contains("1")) cont.resume(true)
else if (davCapabilities.contains("2")) cont.resume(true)
else if (davCapabilities.contains("3")) cont.resume(true)
else cont.resume(false)
}
}
if (!hasCaps) return false
val rootCollection = DavCollection(okHttpClient, "$url/foo".toHttpUrl())
rootCollection.ensureFoldersExist(log, folders) // only considers parents, so foo isn't used
return true
}
override suspend fun getFreeSpace(): Long? {
val location = "$url/".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
val availableBytes = suspendCoroutine { cont ->
davCollection.propfind(depth = 0, QuotaAvailableBytes.NAME) { response, _ ->
log.debugLog { "getFreeSpace() = $response" }
val quota = response.properties.getOrNull(0) as? QuotaAvailableBytes
val availableBytes = quota?.quotaAvailableBytes ?: -1
if (availableBytes > 0) {
cont.resume(availableBytes)
} else {
cont.resume(null)
}
}
}
return availableBytes
}
override suspend fun save(handle: FileHandle): BufferedSink { override suspend fun save(handle: FileHandle): BufferedSink {
val location = handle.toHttpUrl() val location = handle.toHttpUrl()
val davCollection = DavCollection(okHttpClient, location) val davCollection = DavCollection(okHttpClient, location)
@ -118,10 +159,15 @@ internal class WebDavBackend(
val location = handle.toHttpUrl() val location = handle.toHttpUrl()
val davCollection = DavCollection(okHttpClient, location) val davCollection = DavCollection(okHttpClient, location)
val response = davCollection.get(accept = "", headers = null) val response = try {
davCollection.get(accept = "", headers = null)
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error loading $location", e)
}
log.debugLog { "load($location) = $response" } log.debugLog { "load($location) = $response" }
if (response.code / 100 != 2) throw IOException("HTTP error ${response.code}") if (response.code / 100 != 2) throw IOException("HTTP error ${response.code}")
return response.body?.source() ?: throw IOException() return response.body?.source() ?: throw IOException("Body was null for $location")
} }
override suspend fun list( override suspend fun list(
@ -145,58 +191,67 @@ internal class WebDavBackend(
} }
val davCollection = DavCollection(okHttpClient, location) val davCollection = DavCollection(okHttpClient, location)
val tokenFolders = mutableSetOf<HttpUrl>() val tokenFolders = mutableSetOf<HttpUrl>()
davCollection.propfindDepthInfinity(depth) { response, relation -> try {
log.debugLog { "list() = $response" } davCollection.propfindDepthInfinity(depth) { response, relation ->
log.debugLog { "list() = $response" }
// work around nginx's inability to find files starting with . // work around nginx's inability to find files starting with .
if (relation != SELF && LegacyAppBackupFile.Metadata::class in fileTypes && if (relation != SELF && LegacyAppBackupFile.Metadata::class in fileTypes &&
response.isFolder() && response.hrefName().matches(tokenRegex) response.isFolder() && response.hrefName().matches(tokenRegex)
) { ) {
tokenFolders.add(response.href) tokenFolders.add(response.href)
} }
if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2) { if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2) {
val name = response.hrefName() val name = response.hrefName()
val parentName = response.href.pathSegments[response.href.pathSegments.size - 2] val parentName = response.href.pathSegments[response.href.pathSegments.size - 2]
if (LegacyAppBackupFile.Metadata::class in fileTypes) { if (LegacyAppBackupFile.Metadata::class in fileTypes) {
if (name == FILE_BACKUP_METADATA && parentName.matches(tokenRegex)) { if (name == FILE_BACKUP_METADATA && parentName.matches(tokenRegex)) {
val metadata = LegacyAppBackupFile.Metadata(parentName.toLong()) val metadata = LegacyAppBackupFile.Metadata(parentName.toLong())
val size = response.properties.contentLength()
callback(FileInfo(metadata, size))
// we can find .backup.metadata files, so no need for nginx workaround
tokenFolders.clear()
}
}
if (FileBackupFileType.Snapshot::class in fileTypes ||
FileBackupFileType::class in fileTypes
) {
val match = snapshotRegex.matchEntire(name)
if (match != null) {
val size = response.properties.contentLength()
val snapshot = FileBackupFileType.Snapshot(
androidId = parentName.substringBefore('.'),
time = match.groupValues[1].toLong(),
)
callback(FileInfo(snapshot, size))
}
}
if ((FileBackupFileType.Blob::class in fileTypes ||
FileBackupFileType::class in fileTypes) && response.href.pathSize >= 3
) {
val androidIdSv =
response.href.pathSegments[response.href.pathSegments.size - 3]
if (folderRegex.matches(androidIdSv) && chunkFolderRegex.matches(parentName)) {
if (chunkRegex.matches(name)) {
val blob = FileBackupFileType.Blob(
androidId = androidIdSv.substringBefore('.'),
name = name,
)
val size = response.properties.contentLength() val size = response.properties.contentLength()
callback(FileInfo(blob, size)) callback(FileInfo(metadata, size))
// we can find .backup.metadata files, so no need for nginx workaround
tokenFolders.clear()
}
}
if (FileBackupFileType.Snapshot::class in fileTypes ||
FileBackupFileType::class in fileTypes
) {
val match = snapshotRegex.matchEntire(name)
if (match != null) {
val size = response.properties.contentLength()
val snapshot = FileBackupFileType.Snapshot(
androidId = parentName.substringBefore('.'),
time = match.groupValues[1].toLong(),
)
callback(FileInfo(snapshot, size))
}
}
if ((FileBackupFileType.Blob::class in fileTypes ||
FileBackupFileType::class in fileTypes) && response.href.pathSize >= 3
) {
val androidIdSv =
response.href.pathSegments[response.href.pathSegments.size - 3]
if (folderRegex.matches(androidIdSv) &&
chunkFolderRegex.matches(parentName)
) {
if (chunkRegex.matches(name)) {
val blob = FileBackupFileType.Blob(
androidId = androidIdSv.substringBefore('.'),
name = name,
)
val size = response.properties.contentLength()
callback(FileInfo(blob, size))
}
} }
} }
} }
} }
} catch (e: NotFoundException) {
log.warn(e) { "$location not found" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error listing $location", e)
} }
// direct query for .backup.metadata as nginx doesn't support listing hidden files // direct query for .backup.metadata as nginx doesn't support listing hidden files
tokenFolders.forEach { url -> tokenFolders.forEach { url ->
@ -251,8 +306,13 @@ internal class WebDavBackend(
val location = "$url/${from.name}/".toHttpUrl() val location = "$url/${from.name}/".toHttpUrl()
val toUrl = "$url/${to.name}/".toHttpUrl() val toUrl = "$url/${to.name}/".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location) val davCollection = DavCollection(okHttpClient, location)
davCollection.move(toUrl, false) { response -> try {
log.debugLog { "rename(${from.name}, ${to.name}) = $response" } davCollection.move(toUrl, false) { response ->
log.debugLog { "rename(${from.name}, ${to.name}) = $response" }
}
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error renaming $location to ${to.name}", e)
} }
} }
@ -265,9 +325,14 @@ internal class WebDavBackend(
} }
} catch (e: NotFoundException) { } catch (e: NotFoundException) {
log.info { "Not found: $location" } log.info { "Not found: $location" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error removing all at $location", e)
} }
} }
override val providerPackageName: String? = null // 100% built-in plugin
private fun FileHandle.toHttpUrl(): HttpUrl = when (this) { private fun FileHandle.toHttpUrl(): HttpUrl = when (this) {
// careful with trailing slashes, use only for folders/collections // careful with trailing slashes, use only for folders/collections
is TopLevelFolder -> "$url/$name/".toHttpUrl() is TopLevelFolder -> "$url/$name/".toHttpUrl()

View file

@ -29,7 +29,9 @@ public data class StoredSnapshot(
* The timestamp identifying a snapshot of the [userId]. * The timestamp identifying a snapshot of the [userId].
*/ */
public val timestamp: Long, public val timestamp: Long,
) ) {
public val androidId: String = userId.substringBefore(".sv")
}
/** /**
* Defines which backup snapshots should be retained when pruning backups. * Defines which backup snapshots should be retained when pruning backups.