feat(files): веб-сессия Nextcloud для .whiteboard в WebView
Доска (whiteboard) открывается через веб-viewer, которому мало Basic-auth: Vue-XHR ходят по session-cookie+CSRF. Теперь перед загрузкой web-файла логинимся формой /login app-паролем (NextcloudWebSession, обходит 2FA), переносим cookie в CookieManager и грузим страницу нативно — для web-файлов отключено OkHttp-проксирование (оно теряло тело POST/PROPFIND → «нет файлов»). Collabora-путь не тронут (WOPI-токен самодостаточен). +NetworkFactory.newBaseBuilder — билдер на общем пуле без auth.
This commit is contained in:
@@ -40,12 +40,17 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||||
|
import ru.forbion.f7cloud.core.network.NextcloudWebSession
|
||||||
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
||||||
|
|
||||||
class OfficeEditorActivity : ComponentActivity() {
|
class OfficeEditorActivity : ComponentActivity() {
|
||||||
@@ -200,7 +205,55 @@ class OfficeEditorActivity : ComponentActivity() {
|
|||||||
if (current == next) return
|
if (current == next) return
|
||||||
editorVisible = false
|
editorVisible = false
|
||||||
loadError = null
|
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? {
|
private fun readLaunch(): OfficeEditorLaunch? {
|
||||||
@@ -250,7 +303,7 @@ class OfficeEditorActivity : ComponentActivity() {
|
|||||||
val current = webView.url?.trimEnd('/').orEmpty()
|
val current = webView.url?.trimEnd('/').orEmpty()
|
||||||
val target = launch.url.trimEnd('/')
|
val target = launch.url.trimEnd('/')
|
||||||
if (current != target) {
|
if (current != target) {
|
||||||
webView.loadUrl(launch.url)
|
startLoad(webView, launch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -128,11 +128,10 @@ internal class OfficeWebViewClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun buildInterceptPrefixes(launch: OfficeEditorLaunch): List<String> {
|
fun buildInterceptPrefixes(launch: OfficeEditorLaunch): List<String> {
|
||||||
val prefixes = mutableListOf(launch.serverUrl.trimEnd('/'))
|
// Web-файлы (whiteboard): НЕ проксируем — WebView ходит нативно по веб-сессии
|
||||||
if (launch.collaboraBaseUrl.isNotBlank()) {
|
// (cookie+CSRF). Проксирование через OkHttp теряло тело POST/PROPFIND → пустой Files.
|
||||||
prefixes += launch.collaboraBaseUrl.trimEnd('/')
|
if (launch.collaboraBaseUrl.isBlank()) return emptyList()
|
||||||
}
|
return listOf(launch.serverUrl.trimEnd('/'), launch.collaboraBaseUrl.trimEnd('/')).distinct()
|
||||||
return prefixes.distinct()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ object NetworkFactory {
|
|||||||
clients.clear() // пересобрать производные клиенты уже с кэшем
|
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(
|
fun newAuthedClient(
|
||||||
username: String,
|
username: String,
|
||||||
appPassword: String,
|
appPassword: String,
|
||||||
|
|||||||
@@ -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|<meta\s+name=["']requesttoken["']\s+content)=["']([^"']+)["']""",
|
||||||
|
RegexOption.IGNORE_CASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Cookie, собранные при логине (для переноса в WebView CookieManager). */
|
||||||
|
fun login(
|
||||||
|
serverUrl: String,
|
||||||
|
username: String,
|
||||||
|
appPassword: String,
|
||||||
|
trustAllCerts: Boolean = false,
|
||||||
|
): List<Cookie> {
|
||||||
|
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<String, MutableList<Cookie>>()
|
||||||
|
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||||
|
val list = store.getOrPut(url.host) { mutableListOf() }
|
||||||
|
cookies.forEach { c ->
|
||||||
|
list.removeAll { it.name == c.name }
|
||||||
|
list += c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun loadForRequest(url: HttpUrl): List<Cookie> =
|
||||||
|
store[url.host].orEmpty().filter { it.matches(url) }
|
||||||
|
fun cookiesForHost(host: String): List<Cookie> = store[host].orEmpty().toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user