diff --git a/app/src/main/java/ru/forbion/f7cloud/mobile/OfficeEditorActivity.kt b/app/src/main/java/ru/forbion/f7cloud/mobile/OfficeEditorActivity.kt index 656a946..0f675ae 100644 --- a/app/src/main/java/ru/forbion/f7cloud/mobile/OfficeEditorActivity.kt +++ b/app/src/main/java/ru/forbion/f7cloud/mobile/OfficeEditorActivity.kt @@ -40,12 +40,17 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7Theme import ru.forbion.f7cloud.core.network.NetworkFactory +import ru.forbion.f7cloud.core.network.NextcloudWebSession import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch class OfficeEditorActivity : ComponentActivity() { @@ -200,7 +205,55 @@ class OfficeEditorActivity : ComponentActivity() { if (current == next) return editorVisible = false loadError = null - view.loadUrl(target.url) + startLoad(view, target) + } + + /** + * Collabora — грузим сразу (iframe самодостаточен по WOPI-токену). Web-viewer (доска и пр.) + * требует НАСТОЯЩУЮ веб-сессию: сначала логинимся app-паролем, переносим cookie в + * WebView CookieManager, только потом открываем URL — иначе viewer видит анонима («нет файлов»). + */ + private fun startLoad(view: WebView, target: OfficeEditorLaunch) { + if (target.collaboraBaseUrl.isNotBlank()) { + view.loadUrl(target.url) + return + } + lifecycleScope.launch { + val ok = runCatching { establishWebSession(target) }.getOrDefault(false) + if (!ok) { + loadError = "Не удалось открыть сессию. Проверьте интернет и попробуйте снова." + return@launch + } + view.loadUrl(target.url) + } + } + + /** Логин по форме `/login`, перенос session-cookie в WebView. Возвращает true при успехе. */ + private suspend fun establishWebSession(target: OfficeEditorLaunch): Boolean { + val serverUrl = target.serverUrl.ifBlank { target.url } + val cookies = withContext(Dispatchers.IO) { + NextcloudWebSession.login( + serverUrl = serverUrl, + username = target.username, + appPassword = target.password, + trustAllCerts = target.trustAllCerts, + ) + } + if (cookies.isEmpty()) return false + val cm = CookieManager.getInstance() + cm.setAcceptCookie(true) + webViewRef?.let { cm.setAcceptThirdPartyCookies(it, true) } + val base = serverUrl.trimEnd('/') + cookies.forEach { c -> + val attrs = buildString { + append("${c.name}=${c.value}; Path=${c.path}") + if (c.secure) append("; Secure") + if (c.httpOnly) append("; HttpOnly") + } + cm.setCookie(base, attrs) + } + withContext(Dispatchers.IO) { cm.flush() } + return true } private fun readLaunch(): OfficeEditorLaunch? { @@ -250,7 +303,7 @@ class OfficeEditorActivity : ComponentActivity() { val current = webView.url?.trimEnd('/').orEmpty() val target = launch.url.trimEnd('/') if (current != target) { - webView.loadUrl(launch.url) + startLoad(webView, launch) } } }, diff --git a/app/src/main/java/ru/forbion/f7cloud/mobile/OfficeWebViewClient.kt b/app/src/main/java/ru/forbion/f7cloud/mobile/OfficeWebViewClient.kt index 5ba1d36..5ffaf55 100644 --- a/app/src/main/java/ru/forbion/f7cloud/mobile/OfficeWebViewClient.kt +++ b/app/src/main/java/ru/forbion/f7cloud/mobile/OfficeWebViewClient.kt @@ -128,11 +128,10 @@ internal class OfficeWebViewClient( } fun buildInterceptPrefixes(launch: OfficeEditorLaunch): List { - val prefixes = mutableListOf(launch.serverUrl.trimEnd('/')) - if (launch.collaboraBaseUrl.isNotBlank()) { - prefixes += launch.collaboraBaseUrl.trimEnd('/') - } - return prefixes.distinct() + // Web-файлы (whiteboard): НЕ проксируем — WebView ходит нативно по веб-сессии + // (cookie+CSRF). Проксирование через OkHttp теряло тело POST/PROPFIND → пустой Files. + if (launch.collaboraBaseUrl.isBlank()) return emptyList() + return listOf(launch.serverUrl.trimEnd('/'), launch.collaboraBaseUrl.trimEnd('/')).distinct() } } } diff --git a/core/network/src/main/java/ru/forbion/f7cloud/core/network/NetworkFactory.kt b/core/network/src/main/java/ru/forbion/f7cloud/core/network/NetworkFactory.kt index 9694143..e9d17d2 100644 --- a/core/network/src/main/java/ru/forbion/f7cloud/core/network/NetworkFactory.kt +++ b/core/network/src/main/java/ru/forbion/f7cloud/core/network/NetworkFactory.kt @@ -33,6 +33,14 @@ object NetworkFactory { clients.clear() // пересобрать производные клиенты уже с кэшем } + /** Билдер на основе базового клиента (пул/кэш) c опциональным trust-all TLS, без auth. */ + fun newBaseBuilder(trustAllCerts: Boolean = false): OkHttpClient.Builder = + base.newBuilder() + .callTimeout(30, TimeUnit.SECONDS) + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .applyUnsafeSslIfNeeded(trustAllCerts) + fun newAuthedClient( username: String, appPassword: String, diff --git a/core/network/src/main/java/ru/forbion/f7cloud/core/network/NextcloudWebSession.kt b/core/network/src/main/java/ru/forbion/f7cloud/core/network/NextcloudWebSession.kt new file mode 100644 index 0000000..553ff65 --- /dev/null +++ b/core/network/src/main/java/ru/forbion/f7cloud/core/network/NextcloudWebSession.kt @@ -0,0 +1,82 @@ +package ru.forbion.f7cloud.core.network + +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.FormBody +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.ConcurrentHashMap + +/** + * Устанавливает НАСТОЯЩУЮ веб-сессию Nextcloud (cookie + CSRF) по форме `/login`, используя + * app-пароль как пароль (он валиден для входа и обходит 2FA). Нужно для встраивания веб-viewer'ов + * (доска Whiteboard и т.п.), которым мало Basic-auth: их Vue-XHR ходят по сессии. + */ +object NextcloudWebSession { + private val requestTokenRegex = Regex( + """(?:name=["']requesttoken["']\s+value| { + val base = serverUrl.trimEnd('/') + val host = base.toHttpUrl().host + val jar = MemoryCookieJar() + val client = NetworkFactory.newBaseBuilder(trustAllCerts) + .cookieJar(jar) + .followRedirects(true) + .followSslRedirects(true) + .build() + + // 1. GET страницы логина — начальные cookie + requesttoken. + val loginHtml = client.newCall( + Request.Builder().url("$base/login").header("User-Agent", USER_AGENT).get().build(), + ).execute().use { it.body?.string().orEmpty() } + val token = requestTokenRegex.find(loginHtml)?.groupValues?.getOrNull(1).orEmpty() + + // 2. POST формы логина app-паролем — сервер ставит session-cookie. + val form = FormBody.Builder() + .add("user", username) + .add("password", appPassword) + .apply { if (token.isNotBlank()) add("requesttoken", token) } + .add("remember_login", "1") + .add("timezone", "UTC") + .add("timezone_offset", "0") + .build() + client.newCall( + Request.Builder() + .url("$base/login") + .header("User-Agent", USER_AGENT) + .apply { if (token.isNotBlank()) header("requesttoken", token) } + .header("OCS-APIRequest", "true") + .post(form) + .build(), + ).execute().use { /* cookie осели в jar */ } + + return jar.cookiesForHost(host) + } + + private const val USER_AGENT = "Mozilla/5.0 (Android) F7cloud-Mobile" + + private class MemoryCookieJar : CookieJar { + private val store = ConcurrentHashMap>() + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val list = store.getOrPut(url.host) { mutableListOf() } + cookies.forEach { c -> + list.removeAll { it.name == c.name } + list += c + } + } + override fun loadForRequest(url: HttpUrl): List = + store[url.host].orEmpty().filter { it.matches(url) } + fun cookiesForHost(host: String): List = store[host].orEmpty().toList() + } +}