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
@@ -1,14 +1,13 @@
package dev.obiente.nextcloudnative

import dev.obiente.nextcloudnative.app.JvmNetworkRequestAttempt
import dev.obiente.nextcloudnative.app.NextcloudAuthenticatedRequestPolicy
import dev.obiente.nextcloudnative.app.NextcloudSession
import dev.obiente.nextcloudnative.app.copyBoundedNetworkResponseTo
import dev.obiente.nextcloudnative.app.executeCancellableJvmHttpCall
import dev.obiente.nextcloudnative.app.executeCancellableNextcloudAuthenticatedRequest
import dev.obiente.nextcloudnative.app.isFullDetachedFileResponse
import dev.obiente.nextcloudnative.app.nextcloudBasicAuthorization
import java.io.FileOutputStream
import okhttp3.OkHttpClient
import okhttp3.Request

internal suspend fun downloadAndroidDetachedFile(
client: OkHttpClient,
Expand All @@ -28,42 +27,35 @@ internal suspend fun downloadAndroidDetachedFile(
require(maximumBytes > 0L)
val started = System.nanoTime()
val attempt = JvmNetworkRequestAttempt()
val request = Request.Builder()
.url(url)
val request = NextcloudAuthenticatedRequestPolicy(session, userAgent)
.requestBuilder(url)
.get()
.tag(JvmNetworkRequestAttempt::class.java, attempt)
.header("Accept", accept)
.header("User-Agent", userAgent)
.header("Authorization", nextcloudBasicAuthorization(session))
.apply { requestHeaders.forEach { (name, value) -> header(name, value) } }
.build()
val call = client.newCall(request)
return executeCancellableJvmHttpCall(client, call) { activeCall, shouldContinue ->
val response = try {
activeCall.execute()
} catch (failure: Throwable) {
onNetworkFailure(started, attempt, failure)
throw failure
}
response.use {
check(isFullDetachedFileResponse(response.code)) { failureMessage(response.code) }
val body = response.body
val contentLength = body.contentLength()
check(contentLength == -1L || contentLength <= maximumBytes) { limitMessage }
val copied = body.byteStream().copyBoundedNetworkResponseTo(
output = output,
maxBytes = maximumBytes,
onLimitExceeded = { error(limitMessage) },
onNetworkReadFailure = { failure -> onNetworkFailure(started, attempt, failure) },
shouldContinue = shouldContinue,
)
val responseEtag = response.header("ETag") ?: response.header("OC-Etag")
validateResponseEtag(responseEtag)
AndroidDetachedDownload(
byteCount = copied,
mimeType = body.contentType()?.toString(),
etag = handoffEtag ?: responseEtag,
)
}
return executeCancellableNextcloudAuthenticatedRequest(
client = client,
initialRequest = request,
onNetworkFailure = { failure -> onNetworkFailure(started, attempt, failure) },
) { response, shouldContinue ->
check(isFullDetachedFileResponse(response.code)) { failureMessage(response.code) }
val body = response.body
val contentLength = body.contentLength()
check(contentLength == -1L || contentLength <= maximumBytes) { limitMessage }
val copied = body.byteStream().copyBoundedNetworkResponseTo(
output = output,
maxBytes = maximumBytes,
onLimitExceeded = { error(limitMessage) },
onNetworkReadFailure = { failure -> onNetworkFailure(started, attempt, failure) },
shouldContinue = shouldContinue,
)
val responseEtag = response.header("ETag") ?: response.header("OC-Etag")
validateResponseEtag(responseEtag)
AndroidDetachedDownload(
byteCount = copied,
mimeType = body.contentType()?.toString(),
etag = handoffEtag ?: responseEtag,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import mockwebserver3.MockResponse
Expand All @@ -23,6 +24,12 @@ class AndroidDetachedDownloadTransportTest {
@Test
fun `detached download exposes and validates the response etag`() = runBlocking {
MockWebServer().use { server ->
server.enqueue(
MockResponse.Builder()
.code(307)
.addHeader("Location", "/cloud/version-2.bin")
.build(),
)
server.enqueue(
MockResponse.Builder()
.code(200)
Expand All @@ -36,8 +43,8 @@ class AndroidDetachedDownloadTransportTest {
val result = FileOutputStream(destination).use { output ->
downloadAndroidDetachedFile(
client = OkHttpClient(),
session = NextcloudSession(server.url("/").toString(), "alice", "secret"),
url = server.url("/version.bin").toString(),
session = NextcloudSession(server.url("/cloud").toString(), "alice", "secret"),
url = server.url("/cloud/version.bin").toString(),
output = output,
maximumBytes = Long.MAX_VALUE,
userAgent = "test",
Expand All @@ -51,12 +58,54 @@ class AndroidDetachedDownloadTransportTest {

assertEquals("\"listed-version\"", result.etag)
assertEquals("historical", destination.readText())
val initial = assertNotNull(server.takeRequest(2, TimeUnit.SECONDS))
val redirected = assertNotNull(server.takeRequest(2, TimeUnit.SECONDS))
assertEquals("Basic YWxpY2U6c2VjcmV0", initial.headers["Authorization"])
assertEquals(initial.headers["Authorization"], redirected.headers["Authorization"])
assertEquals("/cloud/version-2.bin", redirected.url.encodedPath)
} finally {
destination.delete()
}
}
}

@Test
fun `detached download rejects another origin before sending credentials`() = runBlocking {
MockWebServer().use { accountServer ->
MockWebServer().use { unrelatedServer ->
accountServer.start()
unrelatedServer.start()
val destination = Files.createTempFile("ncn-detached-origin-", ".tmp").toFile()
try {
assertFailsWith<IllegalArgumentException> {
FileOutputStream(destination).use { output ->
downloadAndroidDetachedFile(
client = OkHttpClient(),
session = NextcloudSession(
accountServer.url("/cloud").toString(),
"alice",
"secret",
),
url = unrelatedServer.url("/capture.bin").toString(),
output = output,
maximumBytes = Long.MAX_VALUE,
userAgent = "test",
failureMessage = { status -> "HTTP $status" },
limitMessage = "Too large",
onNetworkFailure = { _, _, _ -> },
)
}
}

assertEquals(0, accountServer.requestCount)
assertEquals(0, unrelatedServer.requestCount)
} finally {
destination.delete()
}
}
}
}

@Test
fun `coroutine cancellation cancels an in-flight detached download`() = runBlocking {
MockWebServer().use { server ->
Expand Down
7 changes: 7 additions & 0 deletions changes/unreleased/106-detached-download-request-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
category: fix
issue: 106
pull: 432
platforms: android, desktop
user-facing: yes

Keep credentials for file exports and attachment downloads inside the configured Nextcloud account, including when the server redirects the download.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package dev.obiente.nextcloudnative.app

import java.io.FileOutputStream
import okhttp3.OkHttpClient
import okhttp3.Request

internal suspend fun downloadDesktopDetachedFile(
client: OkHttpClient,
Expand All @@ -22,38 +21,31 @@ internal suspend fun downloadDesktopDetachedFile(
require(maximumBytes > 0L)
val started = System.nanoTime()
val attempt = JvmNetworkRequestAttempt()
val request = Request.Builder()
.url(url)
val request = NextcloudAuthenticatedRequestPolicy(session, userAgent)
.requestBuilder(url)
.get()
.tag(JvmNetworkRequestAttempt::class.java, attempt)
.header("Accept", accept)
.header("User-Agent", userAgent)
.header("Authorization", nextcloudBasicAuthorization(session))
.apply { requestHeaders.forEach { (name, value) -> header(name, value) } }
.build()
val call = client.newCall(request)
return executeCancellableJvmHttpCall(client, call) { activeCall, shouldContinue ->
val response = try {
activeCall.execute()
} catch (failure: Throwable) {
onNetworkFailure(started, attempt, failure)
throw failure
}
response.use {
check(isFullDetachedFileResponse(response.code)) { failureMessage(response.code) }
val body = response.body
val contentLength = body.contentLength()
check(contentLength == -1L || contentLength <= maximumBytes) { limitMessage }
val copied = body.byteStream().copyBoundedNetworkResponseTo(
output = output,
maxBytes = maximumBytes,
onLimitExceeded = { error(limitMessage) },
onNetworkReadFailure = { failure -> onNetworkFailure(started, attempt, failure) },
shouldContinue = shouldContinue,
)
val responseEtag = response.header("ETag")
validateResponseEtag(responseEtag)
DesktopDetachedDownload(copied, handoffEtag ?: responseEtag)
}
return executeCancellableNextcloudAuthenticatedRequest(
client = client,
initialRequest = request,
onNetworkFailure = { failure -> onNetworkFailure(started, attempt, failure) },
) { response, shouldContinue ->
check(isFullDetachedFileResponse(response.code)) { failureMessage(response.code) }
val body = response.body
val contentLength = body.contentLength()
check(contentLength == -1L || contentLength <= maximumBytes) { limitMessage }
val copied = body.byteStream().copyBoundedNetworkResponseTo(
output = output,
maxBytes = maximumBytes,
onLimitExceeded = { error(limitMessage) },
onNetworkReadFailure = { failure -> onNetworkFailure(started, attempt, failure) },
shouldContinue = shouldContinue,
)
val responseEtag = response.header("ETag")
validateResponseEtag(responseEtag)
DesktopDetachedDownload(copied, handoffEtag ?: responseEtag)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import mockwebserver3.MockResponse
Expand All @@ -20,8 +21,14 @@ import okhttp3.OkHttpClient

class DesktopDetachedDownloadTest {
@Test
fun `cancellation stops the desktop request and UTF-8 credentials are preserved`() = runBlocking {
fun `cancellation stops the redirected desktop request and UTF-8 credentials are preserved`() = runBlocking {
MockWebServer().use { server ->
server.enqueue(
MockResponse.Builder()
.code(307)
.addHeader("Location", "/cloud/redirected.bin")
.build(),
)
server.enqueue(
MockResponse.Builder()
.code(200)
Expand All @@ -31,14 +38,14 @@ class DesktopDetachedDownloadTest {
)
server.start()
val destination = Files.createTempFile("ncn-desktop-detached-cancel-", ".tmp").toFile()
val session = NextcloudSession(server.url("/").toString(), "alïce", "pässword")
val session = NextcloudSession(server.url("/cloud").toString(), "alïce", "pässword")
try {
val job = launch(Dispatchers.Default) {
FileOutputStream(destination).use { output ->
downloadDesktopDetachedFile(
client = OkHttpClient(),
session = session,
url = server.url("/large.bin").toString(),
url = server.url("/cloud/large.bin").toString(),
output = output,
maximumBytes = Long.MAX_VALUE,
userAgent = "test",
Expand All @@ -48,11 +55,13 @@ class DesktopDetachedDownloadTest {
)
}
}
val request = assertNotNull(server.takeRequest(5, TimeUnit.SECONDS))
val initial = assertNotNull(server.takeRequest(5, TimeUnit.SECONDS))
val redirected = assertNotNull(server.takeRequest(5, TimeUnit.SECONDS))
val expected = Base64.getEncoder().encodeToString(
"alïce:pässword".toByteArray(StandardCharsets.UTF_8),
)
assertEquals("Basic $expected", request.headers["Authorization"])
assertEquals("Basic $expected", initial.headers["Authorization"])
assertEquals(initial.headers["Authorization"], redirected.headers["Authorization"])

withTimeout(2_000L) { job.cancelAndJoin() }

Expand All @@ -62,4 +71,41 @@ class DesktopDetachedDownloadTest {
}
}
}

@Test
fun `desktop detached download rejects another origin before sending credentials`() = runBlocking {
MockWebServer().use { accountServer ->
MockWebServer().use { unrelatedServer ->
accountServer.start()
unrelatedServer.start()
val destination = Files.createTempFile("ncn-desktop-detached-origin-", ".tmp").toFile()
try {
assertFailsWith<IllegalArgumentException> {
FileOutputStream(destination).use { output ->
downloadDesktopDetachedFile(
client = OkHttpClient(),
session = NextcloudSession(
accountServer.url("/cloud").toString(),
"alice",
"secret",
),
url = unrelatedServer.url("/capture.bin").toString(),
output = output,
maximumBytes = Long.MAX_VALUE,
userAgent = "test",
failureMessage = { status -> "HTTP $status" },
limitMessage = "Too large",
onNetworkFailure = { _, _, _ -> },
)
}
}

assertEquals(0, accountServer.requestCount)
assertEquals(0, unrelatedServer.requestCount)
} finally {
destination.delete()
}
}
}
}
}
Loading
Loading