Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import dev.obiente.nextcloudnative.app.FileOfflineKey
import dev.obiente.nextcloudnative.app.FileOfflineRequest
import dev.obiente.nextcloudnative.app.FileSyncDecisionReason
import dev.obiente.nextcloudnative.app.NextcloudFile
import dev.obiente.nextcloudnative.app.NextcloudFileContent
import dev.obiente.nextcloudnative.app.NextcloudSession
import dev.obiente.nextcloudnative.app.markFileOfflineJobRunning
import dev.obiente.nextcloudnative.app.jvmStagingStorageKey
Expand All @@ -45,23 +44,10 @@ import java.util.concurrent.TimeUnit

internal enum class AndroidOfflineExecutionOutcome { Complete, Retry }

internal data class AndroidOfflineContent(
val file: NextcloudFile,
val content: File,
val localRevision: String,
)

internal fun AndroidOfflineContent.readVerified(maximumBytes: Long): NextcloudFileContent? {
require(maximumBytes > 0L)
if (!content.isFile || content.length() > maximumBytes) return null
val bytes = content.readBytes()
if (bytes.size.toLong() > maximumBytes) return null
val expectedHash = localRevision.removePrefix("sha256:")
if (expectedHash.length != 64 || expectedHash.any { it !in '0'..'9' && it !in 'a'..'f' }) return null
val actualHash = MessageDigest.getInstance("SHA-256").digest(bytes).toHexString()
if (actualHash != expectedHash) return null
return NextcloudFileContent(bytes, file.mimeType, file.etag)
}
internal fun DocumentWebDavException.isRetryableOfflineDownloadFailure(): Boolean =
error == DocumentWebDavError.Locked ||
error == DocumentWebDavError.Throttled ||
error == DocumentWebDavError.Server

/**
* Durable Android coordinator for offline pin intent, content generations, and WorkManager jobs.
Expand Down Expand Up @@ -520,16 +506,18 @@ internal class AndroidFileOfflineRepository(context: Context) {
System.currentTimeMillis() + seconds * 1_000L
},
)
DocumentWebDavError.Locked, DocumentWebDavError.Server ->
retry(job.id, failure.message ?: "Nextcloud is temporarily unavailable.")
else -> {
finish(
job.id,
FileOfflineJobResult.PermanentFailure(
failure.message ?: "Could not download this file for offline use.",
),
)
AndroidOfflineExecutionOutcome.Complete
if (failure.isRetryableOfflineDownloadFailure()) {
retry(job.id, failure.message ?: "Nextcloud is temporarily unavailable.")
} else {
finish(
job.id,
FileOfflineJobResult.PermanentFailure(
failure.message ?: "Could not download this file for offline use.",
),
)
AndroidOfflineExecutionOutcome.Complete
}
}
}
else -> {
Expand Down Expand Up @@ -745,9 +733,6 @@ private fun List<String>.sumOfKnownSizes(
return total
}

private fun ByteArray.toHexString(): String =
joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }

private fun AndroidFileOfflinePersistedState.folderAvailability(
accountId: String,
path: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,7 @@ internal fun incomingShareMutationOutcomeUnknown(failure: Throwable, mutationInF
DocumentWebDavError.InsufficientStorage,
DocumentWebDavError.TooLarge,
DocumentWebDavError.Throttled,
DocumentWebDavError.RedirectRejected,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ import dev.obiente.nextcloudnative.app.LoginChallenge
import dev.obiente.nextcloudnative.app.LoginPollResult
import dev.obiente.nextcloudnative.app.LoginTransportSecurity
import dev.obiente.nextcloudnative.app.LOGIN_FLOW_RESPONSE_MAX_BYTES
import dev.obiente.nextcloudnative.app.LoginPollHttpResponse
import dev.obiente.nextcloudnative.app.executeLoginPollHttp
import dev.obiente.nextcloudnative.app.interpretLoginChallengeHttpResponse
import dev.obiente.nextcloudnative.app.interpretLoginPollHttpResponse
import dev.obiente.nextcloudnative.app.loginPollEndpointFallbackDiagnostic
import dev.obiente.nextcloudnative.app.normalizeServerUrl
import dev.obiente.nextcloudnative.app.toApprovedDiagnostic
import dev.obiente.nextcloudnative.app.toStartedDiagnostic
import dev.obiente.nextcloudnative.app.confirmTextFileDavSave
Expand Down Expand Up @@ -159,7 +161,6 @@ import dev.obiente.nextcloudnative.app.toFileSyncActionDiagnosticSummary
import dev.obiente.nextcloudnative.app.toSupportDiagnosticExceptionDraft
import dev.obiente.nextcloudnative.app.trackJvmNetworkFailures
import dev.obiente.nextcloudnative.app.ambiguousLoginPollResponse
import dev.obiente.nextcloudnative.app.classifyLoginPollNetworkFailure
import dev.obiente.nextcloudnative.app.loginResultOriginMatchesEntered
import dev.obiente.nextcloudnative.app.toLoginPollFailureDiagnostic
import dev.obiente.nextcloudnative.app.validateLoginEndpointRelationships
Expand Down Expand Up @@ -246,7 +247,6 @@ import java.io.FileOutputStream
import java.security.MessageDigest
import java.io.IOException
import java.io.OutputStream
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.nio.file.AtomicMoveNotSupportedException
Expand Down Expand Up @@ -1332,66 +1332,43 @@ internal class AndroidNextcloudServices(
override suspend fun pollLogin(challenge: LoginChallenge): LoginPollResult = withContext(Dispatchers.IO) {
val formBody = "token=" + URLEncoder.encode(challenge.token, StandardCharsets.UTF_8.name())
var networkFailure: JvmNetworkFailureDiagnostic? = null
fun poll(endpoint: String): HttpResponse {
networkFailure = null
return request(
method = "POST",
url = endpoint,
body = formBody,
contentType = "application/x-www-form-urlencoded",
client = loginPollHttpClient,
maxResponseBytes = LOGIN_FLOW_RESPONSE_MAX_BYTES,
diagnosticIgnoredHttpStatuses = setOf(404),
onNetworkFailure = { networkFailure = it },
)
}
var usedFallback = challenge.token in loginPollFallbackTokens
val initialEndpoint = if (usedFallback) {
requireNotNull(challenge.pollFallbackEndpoint)
} else {
challenge.pollEndpoint
}
val response = try {
poll(initialEndpoint)
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
val initialResult = classifyLoginPollNetworkFailure(networkFailure)
val fallback = challenge.pollFallbackEndpoint
if (
initialResult is LoginPollResult.RetryablePreExchangeFailure &&
!usedFallback &&
fallback != null
) {
runCatching {
recordSupportDiagnostic(loginPollEndpointFallbackDiagnostic())
}
try {
poll(fallback).also {
usedFallback = true
loginPollFallbackTokens += challenge.token
}
} catch (fallbackFailure: Throwable) {
if (fallbackFailure is CancellationException) throw fallbackFailure
val result = classifyLoginPollNetworkFailure(networkFailure)
result.toLoginPollFailureDiagnostic()?.let(::recordSupportDiagnostic)
return@withContext result
}
} else {
initialResult.toLoginPollFailureDiagnostic()?.let(::recordSupportDiagnostic)
return@withContext initialResult
val execution = executeLoginPollHttp(
challenge = challenge,
fallbackAlreadySelected = challenge.token in loginPollFallbackTokens,
poll = { endpoint ->
networkFailure = null
request(
method = "POST",
url = endpoint,
body = formBody,
contentType = "application/x-www-form-urlencoded",
client = loginPollHttpClient,
maxResponseBytes = LOGIN_FLOW_RESPONSE_MAX_BYTES,
diagnosticIgnoredHttpStatuses = setOf(404),
onNetworkFailure = { networkFailure = it },
).let { LoginPollHttpResponse(it.status, it.text) }
},
networkFailure = { networkFailure },
)
execution.selectedFallbackReason?.let { reason ->
loginPollFallbackTokens += challenge.token
runCatching {
recordSupportDiagnostic(loginPollEndpointFallbackDiagnostic(reason, execution.interpretation.result))
}
}
val interpretation = interpretLoginPollHttpResponse(response.status, response.text, challenge)
val interpretation = execution.interpretation
when (val result = interpretation.result) {
LoginPollResult.Pending -> {
if (loginPollPendingTokens.add(challenge.token)) {
recordSupportDiagnostic(loginPollPendingDiagnostic(usedFallback))
recordSupportDiagnostic(loginPollPendingDiagnostic(execution.responseUsedFallback))
}
}
is LoginPollResult.Approved -> {
registerSupportDiagnosticPrivateValue(requireNotNull(interpretation.approvedLoginName))
registerSupportDiagnosticPrivateValue(requireNotNull(interpretation.approvedAppPassword))
runCatching { recordSupportDiagnostic(interpretation.toApprovedDiagnostic(usedFallback)) }
runCatching {
recordSupportDiagnostic(interpretation.toApprovedDiagnostic(execution.responseUsedFallback))
}
}
else -> result.toLoginPollFailureDiagnostic()?.let(::recordSupportDiagnostic)
}
Expand Down Expand Up @@ -3797,23 +3774,6 @@ internal class AndroidNextcloudServices(
.replace("\"", "&quot;")
.replace("'", "&apos;")

private fun normalizeServerUrl(
value: String,
transportSecurity: LoginTransportSecurity = LoginTransportSecurity.Tls,
): String {
val withScheme = value.trim().let { if ("://" in it) it else "https://$it" }
val uri = URI(withScheme)
val scheme = uri.scheme?.lowercase()
require(
scheme == "https" ||
(scheme == "http" && transportSecurity == LoginTransportSecurity.PlainHttp),
) {
"Use an HTTPS server address, or explicitly approve plain HTTP before connecting."
}
require(!uri.host.isNullOrBlank()) { "Enter a valid Nextcloud server address." }
return withScheme.trimEnd('/').removeSuffix("/index.php")
}

private fun loginTransportSecurity(serverUrl: String): LoginTransportSecurity =
if (serverUrl.startsWith("http://", ignoreCase = true)) {
LoginTransportSecurity.PlainHttp
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package dev.obiente.nextcloudnative

import dev.obiente.nextcloudnative.app.NextcloudFile
import dev.obiente.nextcloudnative.app.NextcloudFileContent
import java.io.File
import java.security.MessageDigest

internal data class AndroidOfflineContent(
val file: NextcloudFile,
val content: File,
val localRevision: String,
)

internal fun AndroidOfflineContent.readVerified(maximumBytes: Long): NextcloudFileContent? {
require(maximumBytes > 0L)
if (!content.isFile || content.length() > maximumBytes) return null
val bytes = content.readBytes()
if (bytes.size.toLong() > maximumBytes) return null
val expectedHash = localRevision.removePrefix("sha256:")
if (expectedHash.length != 64 || expectedHash.any { it !in '0'..'9' && it !in 'a'..'f' }) return null
val actualHash = MessageDigest.getInstance("SHA-256").digest(bytes).toHexString()
if (actualHash != expectedHash) return null
return NextcloudFileContent(bytes, file.mimeType, file.etag)
}

private fun ByteArray.toHexString(): String =
joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package dev.obiente.nextcloudnative

import dev.obiente.nextcloudnative.app.NextcloudAuthenticatedRedirectException
import dev.obiente.nextcloudnative.app.NextcloudAuthenticatedRequestPolicy
import dev.obiente.nextcloudnative.app.NextcloudSession
import dev.obiente.nextcloudnative.app.buildNextcloudFileUrl
import dev.obiente.nextcloudnative.app.executeNextcloudAuthenticatedRequest
import dev.obiente.nextcloudnative.app.hashExactJvmFileSyncSlice
import dev.obiente.nextcloudnative.app.isExactHttpByteContentRange
import java.io.ByteArrayInputStream
Expand All @@ -23,16 +26,23 @@ internal fun NextcloudDocumentWebDav.readFileRangeHash(
return hashExactJvmFileSyncSlice(ByteArrayInputStream(byteArrayOf()), 0)
}
val endInclusive = offset + length - 1L
val request = requestBuilder(session, buildNextcloudFileUrl(session.serverUrl, userId, path))
val request = NextcloudAuthenticatedRequestPolicy(session, RANGE_HASH_USER_AGENT)
.requestBuilder(buildNextcloudFileUrl(session.serverUrl, userId, path))
.header("If-Match", expectedEtag)
.header("Range", "bytes=$offset-$endInclusive")
.get()
.build()
cancellation.throwIfCancelled()
val call = client.newCall(request)
cancellation.setOnCancelAction(call::cancel)
val requestClient = client.newBuilder().followRedirects(false).followSslRedirects(false).build()
try {
return call.execute().use { response ->
return executeNextcloudAuthenticatedRequest(
client = requestClient,
initialRequest = request,
executeCall = { call ->
cancellation.setOnCancelAction(call::cancel)
call.execute()
},
) { response ->
check(response.code == 206) {
"The server did not honor bounded content verification (HTTP ${response.code})."
}
Expand All @@ -44,8 +54,12 @@ internal fun NextcloudDocumentWebDav.readFileRangeHash(
}
hashExactJvmFileSyncSlice(response.body.byteStream(), length, requireExhausted = true)
}
} catch (failure: NextcloudAuthenticatedRedirectException) {
throw failure.toDocumentException("verify document range")
} finally {
cancellation.setOnCancelAction(null)
cancellation.throwIfCancelled()
}
}

private const val RANGE_HASH_USER_AGENT = "Nextcloud-Native/0.1.0 (Android DocumentsProvider)"
Loading
Loading