|
|
|
@@ -8,11 +8,16 @@ import app.alextran.immich.BuildConfig
|
|
|
|
|
import app.alextran.immich.NativeBuffer
|
|
|
|
|
import okhttp3.Cache
|
|
|
|
|
import okhttp3.ConnectionPool
|
|
|
|
|
import okhttp3.Cookie
|
|
|
|
|
import okhttp3.CookieJar
|
|
|
|
|
import okhttp3.Credentials
|
|
|
|
|
import okhttp3.Dispatcher
|
|
|
|
|
import okhttp3.Headers
|
|
|
|
|
import okhttp3.Credentials
|
|
|
|
|
import okhttp3.HttpUrl
|
|
|
|
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
|
|
|
import okhttp3.OkHttpClient
|
|
|
|
|
import org.json.JSONObject
|
|
|
|
|
import kotlinx.serialization.Serializable
|
|
|
|
|
import kotlinx.serialization.json.Json
|
|
|
|
|
import java.io.ByteArrayInputStream
|
|
|
|
|
import java.io.File
|
|
|
|
|
import java.net.Socket
|
|
|
|
@@ -32,7 +37,19 @@ private const val CERT_ALIAS = "client_cert"
|
|
|
|
|
private const val PREFS_NAME = "immich.ssl"
|
|
|
|
|
private const val PREFS_CERT_ALIAS = "immich.client_cert"
|
|
|
|
|
private const val PREFS_HEADERS = "immich.request_headers"
|
|
|
|
|
private const val PREFS_SERVER_URL = "immich.server_url"
|
|
|
|
|
private const val PREFS_SERVER_URLS = "immich.server_urls"
|
|
|
|
|
private const val PREFS_COOKIES = "immich.cookies"
|
|
|
|
|
private const val COOKIE_EXPIRY_DAYS = 400L
|
|
|
|
|
|
|
|
|
|
private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
|
|
|
|
|
ACCESS_TOKEN("immich_access_token", httpOnly = true),
|
|
|
|
|
IS_AUTHENTICATED("immich_is_authenticated", httpOnly = false),
|
|
|
|
|
AUTH_TYPE("immich_auth_type", httpOnly = true);
|
|
|
|
|
|
|
|
|
|
companion object {
|
|
|
|
|
val names = entries.map { it.cookieName }.toSet()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Manages a shared OkHttpClient with SSL configuration support.
|
|
|
|
@@ -58,6 +75,8 @@ object HttpClientManager {
|
|
|
|
|
var headers: Headers = Headers.headersOf()
|
|
|
|
|
private set
|
|
|
|
|
|
|
|
|
|
private val cookieJar = PersistentCookieJar()
|
|
|
|
|
|
|
|
|
|
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
|
|
|
|
|
|
|
|
|
|
fun initialize(context: Context) {
|
|
|
|
@@ -69,16 +88,23 @@ object HttpClientManager {
|
|
|
|
|
prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
|
|
|
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
|
|
|
|
|
|
|
|
|
|
cookieJar.init(prefs)
|
|
|
|
|
|
|
|
|
|
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
|
|
|
|
|
if (savedHeaders != null) {
|
|
|
|
|
val json = JSONObject(savedHeaders)
|
|
|
|
|
val map = Json.decodeFromString<Map<String, String>>(savedHeaders)
|
|
|
|
|
val builder = Headers.Builder()
|
|
|
|
|
for (key in json.keys()) {
|
|
|
|
|
builder.add(key, json.getString(key))
|
|
|
|
|
for ((key, value) in map) {
|
|
|
|
|
builder.add(key, value)
|
|
|
|
|
}
|
|
|
|
|
headers = builder.build()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val serverUrlsJson = prefs.getString(PREFS_SERVER_URLS, null)
|
|
|
|
|
if (serverUrlsJson != null) {
|
|
|
|
|
cookieJar.setServerUrls(Json.decodeFromString<List<String>>(serverUrlsJson))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
|
|
|
|
|
client = build(cacheDir)
|
|
|
|
|
initialized = true
|
|
|
|
@@ -153,25 +179,50 @@ object HttpClientManager {
|
|
|
|
|
synchronized(this) { clientChangedListeners.add(listener) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>) {
|
|
|
|
|
fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>, token: String?) {
|
|
|
|
|
synchronized(this) {
|
|
|
|
|
val builder = Headers.Builder()
|
|
|
|
|
headerMap.forEach { (key, value) -> builder[key] = value }
|
|
|
|
|
val newHeaders = builder.build()
|
|
|
|
|
|
|
|
|
|
val headersChanged = headers != newHeaders
|
|
|
|
|
val newUrl = serverUrls.firstOrNull()
|
|
|
|
|
val urlChanged = newUrl != prefs.getString(PREFS_SERVER_URL, null)
|
|
|
|
|
if (!headersChanged && !urlChanged) return
|
|
|
|
|
val urlsChanged = Json.encodeToString(serverUrls) != prefs.getString(PREFS_SERVER_URLS, null)
|
|
|
|
|
|
|
|
|
|
headers = newHeaders
|
|
|
|
|
prefs.edit {
|
|
|
|
|
if (headersChanged) putString(PREFS_HEADERS, JSONObject(headerMap).toString())
|
|
|
|
|
if (urlChanged) {
|
|
|
|
|
if (newUrl != null) putString(PREFS_SERVER_URL, newUrl) else remove(PREFS_SERVER_URL)
|
|
|
|
|
cookieJar.setServerUrls(serverUrls)
|
|
|
|
|
|
|
|
|
|
if (headersChanged || urlsChanged) {
|
|
|
|
|
prefs.edit {
|
|
|
|
|
putString(PREFS_HEADERS, Json.encodeToString(headerMap))
|
|
|
|
|
putString(PREFS_SERVER_URLS, Json.encodeToString(serverUrls))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (token != null) {
|
|
|
|
|
val url = serverUrls.firstNotNullOfOrNull { it.toHttpUrlOrNull() } ?: return
|
|
|
|
|
val expiry = System.currentTimeMillis() + COOKIE_EXPIRY_DAYS * 24 * 60 * 60 * 1000
|
|
|
|
|
val values = mapOf(
|
|
|
|
|
AuthCookie.ACCESS_TOKEN to token,
|
|
|
|
|
AuthCookie.IS_AUTHENTICATED to "true",
|
|
|
|
|
AuthCookie.AUTH_TYPE to "password",
|
|
|
|
|
)
|
|
|
|
|
cookieJar.saveFromResponse(url, values.map { (cookie, value) ->
|
|
|
|
|
Cookie.Builder().name(cookie.cookieName).value(value).domain(url.host).path("/").expiresAt(expiry)
|
|
|
|
|
.apply {
|
|
|
|
|
if (url.isHttps) secure()
|
|
|
|
|
if (cookie.httpOnly) httpOnly()
|
|
|
|
|
}.build()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun loadCookieHeader(url: String): String? {
|
|
|
|
|
val httpUrl = url.toHttpUrlOrNull() ?: return null
|
|
|
|
|
return cookieJar.loadForRequest(httpUrl).takeIf { it.isNotEmpty() }
|
|
|
|
|
?.joinToString("; ") { "${it.name}=${it.value}" }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun build(cacheDir: File): OkHttpClient {
|
|
|
|
|
val connectionPool = ConnectionPool(
|
|
|
|
|
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
|
|
|
@@ -188,6 +239,7 @@ object HttpClientManager {
|
|
|
|
|
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
|
|
|
|
|
|
|
|
|
return OkHttpClient.Builder()
|
|
|
|
|
.cookieJar(cookieJar)
|
|
|
|
|
.addInterceptor {
|
|
|
|
|
val request = it.request()
|
|
|
|
|
val builder = request.newBuilder()
|
|
|
|
@@ -249,4 +301,131 @@ object HttpClientManager {
|
|
|
|
|
socket: Socket?
|
|
|
|
|
): String? = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Persistent CookieJar that duplicates auth cookies across equivalent server URLs.
|
|
|
|
|
* When the server sets cookies for one domain, copies are created for all other known
|
|
|
|
|
* server domains (for URL switching between local/remote endpoints of the same server).
|
|
|
|
|
*/
|
|
|
|
|
private class PersistentCookieJar : CookieJar {
|
|
|
|
|
private val store = mutableListOf<Cookie>()
|
|
|
|
|
private var serverUrls = listOf<HttpUrl>()
|
|
|
|
|
private var prefs: SharedPreferences? = null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fun init(prefs: SharedPreferences) {
|
|
|
|
|
this.prefs = prefs
|
|
|
|
|
restore()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Synchronized
|
|
|
|
|
fun setServerUrls(urls: List<String>) {
|
|
|
|
|
val parsed = urls.mapNotNull { it.toHttpUrlOrNull() }
|
|
|
|
|
if (parsed.map { it.host } == serverUrls.map { it.host }) return
|
|
|
|
|
serverUrls = parsed
|
|
|
|
|
if (syncAuthCookies()) persist()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Synchronized
|
|
|
|
|
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
|
|
|
|
val changed = cookies.any { new ->
|
|
|
|
|
store.none { it.name == new.name && it.domain == new.domain && it.path == new.path && it.value == new.value }
|
|
|
|
|
}
|
|
|
|
|
store.removeAll { existing ->
|
|
|
|
|
cookies.any { it.name == existing.name && it.domain == existing.domain && it.path == existing.path }
|
|
|
|
|
}
|
|
|
|
|
store.addAll(cookies)
|
|
|
|
|
val synced = serverUrls.any { it.host == url.host } && syncAuthCookies()
|
|
|
|
|
if (changed || synced) persist()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Synchronized
|
|
|
|
|
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
|
|
|
|
val now = System.currentTimeMillis()
|
|
|
|
|
if (store.removeAll { it.expiresAt < now }) {
|
|
|
|
|
syncAuthCookies()
|
|
|
|
|
persist()
|
|
|
|
|
}
|
|
|
|
|
return store.filter { it.matches(url) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun syncAuthCookies(): Boolean {
|
|
|
|
|
val serverHosts = serverUrls.map { it.host }.toSet()
|
|
|
|
|
val now = System.currentTimeMillis()
|
|
|
|
|
val sourceCookies = store
|
|
|
|
|
.filter { it.name in AuthCookie.names && it.domain in serverHosts && it.expiresAt > now }
|
|
|
|
|
.associateBy { it.name }
|
|
|
|
|
|
|
|
|
|
if (sourceCookies.isEmpty()) {
|
|
|
|
|
return store.removeAll { it.name in AuthCookie.names && it.domain in serverHosts }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var changed = false
|
|
|
|
|
for (url in serverUrls) {
|
|
|
|
|
for ((_, source) in sourceCookies) {
|
|
|
|
|
if (store.any { it.name == source.name && it.domain == url.host && it.value == source.value }) continue
|
|
|
|
|
store.removeAll { it.name == source.name && it.domain == url.host }
|
|
|
|
|
store.add(rebuildCookie(source, url))
|
|
|
|
|
changed = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return changed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun rebuildCookie(source: Cookie, url: HttpUrl): Cookie {
|
|
|
|
|
return Cookie.Builder()
|
|
|
|
|
.name(source.name).value(source.value)
|
|
|
|
|
.domain(url.host).path("/")
|
|
|
|
|
.expiresAt(source.expiresAt)
|
|
|
|
|
.apply {
|
|
|
|
|
if (url.isHttps) secure()
|
|
|
|
|
if (source.httpOnly) httpOnly()
|
|
|
|
|
}
|
|
|
|
|
.build()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun persist() {
|
|
|
|
|
val p = prefs ?: return
|
|
|
|
|
p.edit { putString(PREFS_COOKIES, Json.encodeToString(store.map { SerializedCookie.from(it) })) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun restore() {
|
|
|
|
|
val p = prefs ?: return
|
|
|
|
|
val jsonStr = p.getString(PREFS_COOKIES, null) ?: return
|
|
|
|
|
try {
|
|
|
|
|
store.addAll(Json.decodeFromString<List<SerializedCookie>>(jsonStr).map { it.toCookie() })
|
|
|
|
|
} catch (_: Exception) {
|
|
|
|
|
store.clear()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Serializable
|
|
|
|
|
private data class SerializedCookie(
|
|
|
|
|
val name: String,
|
|
|
|
|
val value: String,
|
|
|
|
|
val domain: String,
|
|
|
|
|
val path: String,
|
|
|
|
|
val expiresAt: Long,
|
|
|
|
|
val secure: Boolean,
|
|
|
|
|
val httpOnly: Boolean,
|
|
|
|
|
val hostOnly: Boolean,
|
|
|
|
|
) {
|
|
|
|
|
fun toCookie(): Cookie = Cookie.Builder()
|
|
|
|
|
.name(name).value(value).path(path).expiresAt(expiresAt)
|
|
|
|
|
.apply {
|
|
|
|
|
if (hostOnly) hostOnlyDomain(domain) else domain(domain)
|
|
|
|
|
if (secure) secure()
|
|
|
|
|
if (httpOnly) httpOnly()
|
|
|
|
|
}
|
|
|
|
|
.build()
|
|
|
|
|
|
|
|
|
|
companion object {
|
|
|
|
|
fun from(cookie: Cookie) = SerializedCookie(
|
|
|
|
|
name = cookie.name, value = cookie.value, domain = cookie.domain,
|
|
|
|
|
path = cookie.path, expiresAt = cookie.expiresAt, secure = cookie.secure,
|
|
|
|
|
httpOnly = cookie.httpOnly, hostOnly = cookie.hostOnly,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|