diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 0839000dd0..bd90986f60 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -3,6 +3,7 @@ plugins { id "kotlin-android" id "dev.flutter.flutter-gradle-plugin" id 'com.google.devtools.ksp' + id 'org.jetbrains.kotlin.plugin.serialization' id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt index 37435a9f02..81080227b8 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -8,11 +8,17 @@ 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.encodeToString +import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream import java.io.File import java.net.Socket @@ -33,6 +39,8 @@ 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" /** * Manages a shared OkHttpClient with SSL configuration support. @@ -58,6 +66,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 +79,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>(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>(serverUrlsJson)) + } + val cacheDir = File(File(context.cacheDir, "okhttp"), "api") client = build(cacheDir) initialized = true @@ -158,20 +175,46 @@ object HttpClientManager { 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 + headers = newHeaders - prefs.edit { - if (headersChanged) putString(PREFS_HEADERS, JSONObject(headerMap).toString()) - if (urlChanged) { + cookieJar.setServerUrls(serverUrls) + + if (headersChanged || urlChanged) { + prefs.edit { + putString(PREFS_HEADERS, Json.encodeToString(headerMap)) + putString(PREFS_SERVER_URLS, Json.encodeToString(serverUrls)) if (newUrl != null) putString(PREFS_SERVER_URL, newUrl) else remove(PREFS_SERVER_URL) } } } } + fun bootstrapCookies(token: String, serverUrls: List) { + val url = serverUrls.firstNotNullOfOrNull { it.toHttpUrlOrNull() } ?: return + val expiry = System.currentTimeMillis() + 400L * 24 * 60 * 60 * 1000 + fun cookie(name: String, value: String, httpOnly: Boolean) = + Cookie.Builder().name(name).value(value).domain(url.host).path("/").expiresAt(expiry) + .apply { + if (url.isHttps) secure() + if (httpOnly) httpOnly() + }.build() + cookieJar.saveFromResponse(url, listOf( + cookie("immich_access_token", token, httpOnly = true), + cookie("immich_is_authenticated", "true", httpOnly = false), + cookie("immich_auth_type", "password", httpOnly = true), + )) + } + + 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 +231,7 @@ object HttpClientManager { HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) return OkHttpClient.Builder() + .cookieJar(cookieJar) .addInterceptor { val request = it.request() val builder = request.newBuilder() @@ -249,4 +293,124 @@ 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() + private var serverUrls = listOf() + private var prefs: SharedPreferences? = null + + companion object { + val AUTH_COOKIE_NAMES = setOf("immich_access_token", "immich_is_authenticated", "immich_auth_type") + } + + fun init(prefs: SharedPreferences) { + this.prefs = prefs + restore() + } + + @Synchronized + fun setServerUrls(urls: List) { + val parsed = urls.mapNotNull { it.toHttpUrlOrNull() } + if (parsed.map { it.host } == serverUrls.map { it.host }) return + serverUrls = parsed + if (duplicateAuthCookies()) persist() + } + + @Synchronized + override fun saveFromResponse(url: HttpUrl, cookies: List) { + 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 duplicated = serverUrls.any { it.host == url.host } && duplicateAuthCookies() + if (changed || duplicated) persist() + } + + @Synchronized + override fun loadForRequest(url: HttpUrl): List { + val now = System.currentTimeMillis() + store.removeAll { it.expiresAt < now } + return store.filter { it.matches(url) } + } + + private fun duplicateAuthCookies(): Boolean { + val sourceCookies = store.filter { it.name in AUTH_COOKIE_NAMES }.associateBy { it.name } + if (sourceCookies.isEmpty()) return false + + 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>(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, + ) + } + } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt index 5e48d7fef5..f40f29cb5b 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt @@ -185,6 +185,7 @@ interface NetworkApi { fun hasCertificate(): Boolean fun getClientPointer(): Long fun setRequestHeaders(headers: Map, serverUrls: List) + fun bootstrapCookies(token: String, serverUrls: List) companion object { /** The codec used by NetworkApi. */ @@ -299,6 +300,25 @@ interface NetworkApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.bootstrapCookies$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tokenArg = args[0] as String + val serverUrlsArg = args[1] as List + val wrapped: List = try { + api.bootstrapCookies(tokenArg, serverUrlsArg) + listOf(null) + } catch (exception: Throwable) { + NetworkPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt index 384c94cce9..57b2cc9397 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt @@ -39,7 +39,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware { } } -private class NetworkApiImpl() : NetworkApi { +private class NetworkApiImpl : NetworkApi { var activity: Activity? = null override fun addCertificate(clientData: ClientCertData, callback: (Result) -> Unit) { @@ -82,4 +82,8 @@ private class NetworkApiImpl() : NetworkApi { override fun setRequestHeaders(headers: Map, serverUrls: List) { HttpClientManager.setRequestHeaders(headers, serverUrls) } + + override fun bootstrapCookies(token: String, serverUrls: List) { + HttpClientManager.bootstrapCookies(token, serverUrls) + } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 21e3c603e6..b820b45425 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -192,6 +192,7 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche val callback = FetchCallback(onSuccess, onFailure, ::onComplete) val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor) HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } + HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) } url.toHttpUrlOrNull()?.let { httpUrl -> if (httpUrl.username.isNotEmpty()) { requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password)) diff --git a/mobile/ios/Runner/Core/Network.g.swift b/mobile/ios/Runner/Core/Network.g.swift index 96294c1cd4..ecd5153378 100644 --- a/mobile/ios/Runner/Core/Network.g.swift +++ b/mobile/ios/Runner/Core/Network.g.swift @@ -226,6 +226,7 @@ protocol NetworkApi { func hasCertificate() throws -> Bool func getClientPointer() throws -> Int64 func setRequestHeaders(headers: [String: String], serverUrls: [String]) throws + func bootstrapCookies(token: String, serverUrls: [String]) throws } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -325,5 +326,21 @@ class NetworkApiSetup { } else { setRequestHeadersChannel.setMessageHandler(nil) } + let bootstrapCookiesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.bootstrapCookies\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + bootstrapCookiesChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let tokenArg = args[0] as! String + let serverUrlsArg = args[1] as! [String] + do { + try api.bootstrapCookies(token: tokenArg, serverUrls: serverUrlsArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + bootstrapCookiesChannel.setMessageHandler(nil) + } } } diff --git a/mobile/ios/Runner/Core/NetworkApiImpl.swift b/mobile/ios/Runner/Core/NetworkApiImpl.swift index 480286b2af..4b30db7644 100644 --- a/mobile/ios/Runner/Core/NetworkApiImpl.swift +++ b/mobile/ios/Runner/Core/NetworkApiImpl.swift @@ -59,41 +59,42 @@ class NetworkApiImpl: NetworkApi { } func setRequestHeaders(headers: [String : String], serverUrls: [String]) throws { - var headers = headers - if let token = headers.removeValue(forKey: "x-immich-user-token") { - for serverUrl in serverUrls { - guard let url = URL(string: serverUrl), let domain = url.host else { continue } - let isSecure = serverUrl.hasPrefix("https") - let cookies: [(String, String, Bool)] = [ - ("immich_access_token", token, true), - ("immich_is_authenticated", "true", false), - ("immich_auth_type", "password", true), - ] - let expiry = Date().addingTimeInterval(400 * 24 * 60 * 60) - for (name, value, httpOnly) in cookies { - var properties: [HTTPCookiePropertyKey: Any] = [ - .name: name, - .value: value, - .domain: domain, - .path: "/", - .expires: expiry, - ] - if isSecure { properties[.secure] = "TRUE" } - if httpOnly { properties[.init("HttpOnly")] = "TRUE" } - if let cookie = HTTPCookie(properties: properties) { - URLSessionManager.cookieStorage.setCookie(cookie) - } - } - } - } - if serverUrls.first != UserDefaults.group.string(forKey: SERVER_URL_KEY) { UserDefaults.group.set(serverUrls.first, forKey: SERVER_URL_KEY) } + URLSessionManager.duplicateAuthCookies(serverUrls: serverUrls) + if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] { UserDefaults.group.set(headers, forKey: HEADERS_KEY) - URLSessionManager.shared.recreateSession() // Recreate session to apply custom headers without app restart + URLSessionManager.shared.recreateSession() + } + } + + func bootstrapCookies(token: String, serverUrls: [String]) throws { + let expiry = Date().addingTimeInterval(400 * 24 * 60 * 60) + for serverUrl in serverUrls { + guard let url = URL(string: serverUrl), let domain = url.host else { continue } + let isSecure = serverUrl.hasPrefix("https") + let cookies: [(String, String, Bool)] = [ + ("immich_access_token", token, true), + ("immich_is_authenticated", "true", false), + ("immich_auth_type", "password", true), + ] + for (name, value, httpOnly) in cookies { + var properties: [HTTPCookiePropertyKey: Any] = [ + .name: name, + .value: value, + .domain: domain, + .path: "/", + .expires: expiry, + ] + if isSecure { properties[.secure] = "TRUE" } + if httpOnly { properties[.init("HttpOnly")] = "TRUE" } + if let cookie = HTTPCookie(properties: properties) { + URLSessionManager.cookieStorage.setCookie(cookie) + } + } } } } diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 411b828ea1..b183f2b1a1 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -49,6 +49,45 @@ class URLSessionManager: NSObject { session = Self.buildSession(delegate: delegate) } + static func duplicateAuthCookies(serverUrls: [String]) { + let authCookieNames: Set = ["immich_access_token", "immich_is_authenticated", "immich_auth_type"] + let allCookies = cookieStorage.cookies ?? [] + + var sourceCookies: [String: HTTPCookie] = [:] + for cookie in allCookies { + if authCookieNames.contains(cookie.name) { + sourceCookies[cookie.name] = cookie + } + } + + guard !sourceCookies.isEmpty else { return } + + for serverUrl in serverUrls { + guard let url = URL(string: serverUrl), let domain = url.host else { continue } + let isSecure = serverUrl.hasPrefix("https") + + for (_, source) in sourceCookies { + if allCookies.contains(where: { $0.name == source.name && $0.domain == domain && $0.value == source.value }) { + continue + } + + var properties: [HTTPCookiePropertyKey: Any] = [ + .name: source.name, + .value: source.value, + .domain: domain, + .path: "/", + .expires: source.expiresDate ?? Date().addingTimeInterval(400 * 24 * 60 * 60), + ] + if isSecure { properties[.secure] = "TRUE" } + if source.isHTTPOnly { properties[.init("HttpOnly")] = "TRUE" } + + if let cookie = HTTPCookie(properties: properties) { + cookieStorage.setCookie(cookie) + } + } + } + } + private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession { let config = URLSessionConfiguration.default config.urlCache = urlCache diff --git a/mobile/lib/platform/network_api.g.dart b/mobile/lib/platform/network_api.g.dart index 314a943f7d..84050fd08a 100644 --- a/mobile/lib/platform/network_api.g.dart +++ b/mobile/lib/platform/network_api.g.dart @@ -14,39 +14,47 @@ PlatformException _createConnectionError(String channelName) { message: 'Unable to establish connection on channel: "$channelName".', ); } - bool _deepEquals(Object? a, Object? b) { if (a is List && b is List) { - return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); } if (a is Map && b is Map) { - return a.length == b.length && - a.entries.every( - (MapEntry entry) => - (b as Map).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]), - ); + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); } return a == b; } + class ClientCertData { - ClientCertData({required this.data, required this.password}); + ClientCertData({ + required this.data, + required this.password, + }); Uint8List data; String password; List _toList() { - return [data, password]; + return [ + data, + password, + ]; } Object encode() { - return _toList(); - } + return _toList(); } static ClientCertData decode(Object result) { result as List; - return ClientCertData(data: result[0]! as Uint8List, password: result[1]! as String); + return ClientCertData( + data: result[0]! as Uint8List, + password: result[1]! as String, + ); } @override @@ -63,11 +71,17 @@ class ClientCertData { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } class ClientCertPrompt { - ClientCertPrompt({required this.title, required this.message, required this.cancel, required this.confirm}); + ClientCertPrompt({ + required this.title, + required this.message, + required this.cancel, + required this.confirm, + }); String title; @@ -78,12 +92,16 @@ class ClientCertPrompt { String confirm; List _toList() { - return [title, message, cancel, confirm]; + return [ + title, + message, + cancel, + confirm, + ]; } Object encode() { - return _toList(); - } + return _toList(); } static ClientCertPrompt decode(Object result) { result as List; @@ -109,9 +127,11 @@ class ClientCertPrompt { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -119,10 +139,10 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is ClientCertData) { + } else if (value is ClientCertData) { buffer.putUint8(129); writeValue(buffer, value.encode()); - } else if (value is ClientCertPrompt) { + } else if (value is ClientCertPrompt) { buffer.putUint8(130); writeValue(buffer, value.encode()); } else { @@ -133,9 +153,9 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { - case 129: + case 129: return ClientCertData.decode(readValue(buffer)!); - case 130: + case 130: return ClientCertPrompt.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -148,8 +168,8 @@ class NetworkApi { /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. NetworkApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) - : pigeonVar_binaryMessenger = binaryMessenger, - pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); @@ -157,15 +177,15 @@ class NetworkApi { final String pigeonVar_messageChannelSuffix; Future addCertificate(ClientCertData clientData) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([clientData]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -180,15 +200,15 @@ class NetworkApi { } Future selectCertificate(ClientCertPrompt promptText) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([promptText]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -203,15 +223,15 @@ class NetworkApi { } Future removeCertificate() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -226,15 +246,15 @@ class NetworkApi { } Future hasCertificate() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -254,15 +274,15 @@ class NetworkApi { } Future getClientPointer() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -282,15 +302,38 @@ class NetworkApi { } Future setRequestHeaders(Map headers, List serverUrls) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([headers, serverUrls]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future bootstrapCookies(String token, List serverUrls) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NetworkApi.bootstrapCookies$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([token, serverUrls]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index ee3367eef2..825d9e7bc8 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -123,7 +123,7 @@ class AuthNotifier extends StateNotifier { } Future saveAuthInfo({required String accessToken}) async { - await _apiService.setAccessToken(accessToken); + await Store.put(StoreKey.accessToken, accessToken); await _apiService.updateHeaders(); final serverEndpoint = Store.get(StoreKey.serverEndpoint); @@ -145,7 +145,6 @@ class AuthNotifier extends StateNotifier { user = serverUser; await Store.put(StoreKey.deviceId, deviceId); await Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); - await Store.put(StoreKey.accessToken, accessToken); } } on ApiException catch (error, stackTrace) { if (error.code == 401) { diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 566ec7aa31..86e37b83c0 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/utils/url_helper.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; -class ApiService implements Authentication { +class ApiService { late ApiClient _apiClient; late UsersApi usersApi; @@ -45,7 +45,6 @@ class ApiService implements Authentication { setEndpoint(endpoint); } } - String? _accessToken; final _log = Logger("ApiService"); Future updateHeaders() async { @@ -54,11 +53,8 @@ class ApiService implements Authentication { } setEndpoint(String endpoint) { - _apiClient = ApiClient(basePath: endpoint, authentication: this); + _apiClient = ApiClient(basePath: endpoint); _apiClient.client = NetworkRepository.client; - if (_accessToken != null) { - setAccessToken(_accessToken!); - } usersApi = UsersApi(_apiClient); authenticationApi = AuthenticationApi(_apiClient); oAuthApi = AuthenticationApi(_apiClient); @@ -157,11 +153,6 @@ class ApiService implements Authentication { return ""; } - Future setAccessToken(String accessToken) async { - _accessToken = accessToken; - await Store.put(StoreKey.accessToken, accessToken); - } - Future setDeviceInfoHeader() async { DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); @@ -205,28 +196,12 @@ class ApiService implements Authentication { } static Map getRequestHeaders() { - var accessToken = Store.get(StoreKey.accessToken, ""); var customHeadersStr = Store.get(StoreKey.customHeaders, ""); - var header = {}; - if (accessToken.isNotEmpty) { - header['x-immich-user-token'] = accessToken; - } - if (customHeadersStr.isEmpty) { - return header; + return const {}; } - var customHeaders = jsonDecode(customHeadersStr) as Map; - customHeaders.forEach((key, value) { - header[key] = value; - }); - - return header; - } - - @override - Future applyToParams(List queryParams, Map headerParams) { - return Future.value(); + return jsonDecode(customHeadersStr) as Map; } ApiClient get apiClient => _apiClient; diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index d022d9a5cf..03278d25fc 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -340,7 +340,6 @@ class BackgroundService { ], ); - await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken)); await ref.read(authServiceProvider).setOpenApiServiceEndpoint(); dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}"); diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index 1e8d426df8..2efd52cc81 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -74,7 +74,6 @@ class BackupVerificationService { final lower = compute(_computeSaveToDelete, ( deleteCandidates: deleteCandidates.slice(0, half), originals: originals.slice(0, half), - auth: Store.get(StoreKey.accessToken), endpoint: Store.get(StoreKey.serverEndpoint), rootIsolateToken: isolateToken, fileMediaRepository: _fileMediaRepository, @@ -82,7 +81,6 @@ class BackupVerificationService { final upper = compute(_computeSaveToDelete, ( deleteCandidates: deleteCandidates.slice(half), originals: originals.slice(half), - auth: Store.get(StoreKey.accessToken), endpoint: Store.get(StoreKey.serverEndpoint), rootIsolateToken: isolateToken, fileMediaRepository: _fileMediaRepository, @@ -92,7 +90,6 @@ class BackupVerificationService { toDelete = await compute(_computeSaveToDelete, ( deleteCandidates: deleteCandidates, originals: originals, - auth: Store.get(StoreKey.accessToken), endpoint: Store.get(StoreKey.serverEndpoint), rootIsolateToken: isolateToken, fileMediaRepository: _fileMediaRepository, @@ -105,7 +102,6 @@ class BackupVerificationService { ({ List deleteCandidates, List originals, - String auth, String endpoint, RootIsolateToken rootIsolateToken, FileMediaRepository fileMediaRepository, @@ -120,7 +116,6 @@ class BackupVerificationService { await tuple.fileMediaRepository.enableBackgroundAccess(); final ApiService apiService = ApiService(); apiService.setEndpoint(tuple.endpoint); - await apiService.setAccessToken(tuple.auth); for (int i = 0; i < tuple.deleteCandidates.length; i++) { if (await _compareAssets(tuple.deleteCandidates[i], tuple.originals[i], apiService)) { result.add(tuple.deleteCandidates[i]); diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 6b6f1b251b..e6302764b7 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -27,6 +27,7 @@ import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/debug_print.dart'; @@ -35,7 +36,7 @@ import 'package:isar/isar.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 24; +const int targetVersion = 25; Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { final hasVersion = Store.tryGet(StoreKey.version) != null; @@ -109,6 +110,16 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { await _applyLocalAssetOrientation(drift); } + if (version < 25) { + final accessToken = Store.tryGet(StoreKey.accessToken); + if (accessToken != null && accessToken.isNotEmpty) { + final serverUrls = ApiService.getServerUrls(); + if (serverUrls.isNotEmpty) { + await networkApi.bootstrapCookies(accessToken, serverUrls); + } + } + } + if (version < 22 && !Store.isBetaTimelineEnabled) { await Store.put(StoreKey.needBetaMigration, true); } diff --git a/mobile/pigeon/network_api.dart b/mobile/pigeon/network_api.dart index 3ea29052d9..4878c627e4 100644 --- a/mobile/pigeon/network_api.dart +++ b/mobile/pigeon/network_api.dart @@ -44,4 +44,6 @@ abstract class NetworkApi { int getClientPointer(); void setRequestHeaders(Map headers, List serverUrls); + + void bootstrapCookies(String token, List serverUrls); }