From c403e03a42bf3847d154430693f31b0f39ad6134 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:07:27 -0500 Subject: [PATCH] fix(mobile): logout on upgrade (#26827) * use cookiejar * cookie duping hook * remove old pref * handle network switching on logout * remove bootstrapCookies * dead code * fix cast * use constants * use new event name * update api --- mobile/android/app/build.gradle | 1 + .../alextran/immich/core/HttpClientManager.kt | 207 ++++++++++++++++-- .../app/alextran/immich/core/Network.g.kt | 5 +- .../alextran/immich/core/NetworkApiPlugin.kt | 6 +- .../immich/images/RemoteImagesImpl.kt | 1 + mobile/ios/Runner/Core/Network.g.swift | 5 +- mobile/ios/Runner/Core/NetworkApiImpl.swift | 33 ++- .../ios/Runner/Core/URLSessionManager.swift | 101 ++++++++- .../repositories/network.repository.dart | 4 +- mobile/lib/platform/network_api.g.dart | 4 +- mobile/lib/providers/auth.provider.dart | 3 +- mobile/lib/repositories/auth.repository.dart | 4 - mobile/lib/services/api.service.dart | 33 +-- mobile/lib/services/background.service.dart | 1 - .../services/backup_verification.service.dart | 5 - mobile/lib/utils/migration.dart | 14 +- mobile/pigeon/network_api.dart | 2 +- 17 files changed, 340 insertions(+), 89 deletions(-) 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..180ae4735d 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,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>(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 @@ -153,25 +179,50 @@ object HttpClientManager { synchronized(this) { clientChangedListeners.add(listener) } } - fun setRequestHeaders(headerMap: Map, serverUrls: List) { + fun setRequestHeaders(headerMap: Map, serverUrls: List, 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() + private var serverUrls = listOf() + private var prefs: SharedPreferences? = null + + + 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 (syncAuthCookies()) 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 synced = serverUrls.any { it.host == url.host } && syncAuthCookies() + if (changed || synced) persist() + } + + @Synchronized + override fun loadForRequest(url: HttpUrl): List { + 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>(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..869e312515 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 @@ -184,7 +184,7 @@ interface NetworkApi { fun removeCertificate(callback: (Result) -> Unit) fun hasCertificate(): Boolean fun getClientPointer(): Long - fun setRequestHeaders(headers: Map, serverUrls: List) + fun setRequestHeaders(headers: Map, serverUrls: List, token: String?) companion object { /** The codec used by NetworkApi. */ @@ -287,8 +287,9 @@ interface NetworkApi { val args = message as List val headersArg = args[0] as Map val serverUrlsArg = args[1] as List + val tokenArg = args[2] as String? val wrapped: List = try { - api.setRequestHeaders(headersArg, serverUrlsArg) + api.setRequestHeaders(headersArg, serverUrlsArg, tokenArg) listOf(null) } catch (exception: Throwable) { NetworkPigeonUtils.wrapError(exception) 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..85b7a6c730 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) { @@ -79,7 +79,7 @@ private class NetworkApiImpl() : NetworkApi { return HttpClientManager.getClientPointer() } - override fun setRequestHeaders(headers: Map, serverUrls: List) { - HttpClientManager.setRequestHeaders(headers, serverUrls) + override fun setRequestHeaders(headers: Map, serverUrls: List, token: String?) { + HttpClientManager.setRequestHeaders(headers, serverUrls, token) } } 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..5a8075f91a 100644 --- a/mobile/ios/Runner/Core/Network.g.swift +++ b/mobile/ios/Runner/Core/Network.g.swift @@ -225,7 +225,7 @@ protocol NetworkApi { func removeCertificate(completion: @escaping (Result) -> Void) func hasCertificate() throws -> Bool func getClientPointer() throws -> Int64 - func setRequestHeaders(headers: [String: String], serverUrls: [String]) throws + func setRequestHeaders(headers: [String: String], serverUrls: [String], token: String?) throws } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -315,8 +315,9 @@ class NetworkApiSetup { let args = message as! [Any?] let headersArg = args[0] as! [String: String] let serverUrlsArg = args[1] as! [String] + let tokenArg: String? = nilOrValue(args[2]) do { - try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg) + try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg, token: tokenArg) reply(wrapResult(nil)) } catch { reply(wrapError(error)) diff --git a/mobile/ios/Runner/Core/NetworkApiImpl.swift b/mobile/ios/Runner/Core/NetworkApiImpl.swift index 480286b2af..3c4be8e718 100644 --- a/mobile/ios/Runner/Core/NetworkApiImpl.swift +++ b/mobile/ios/Runner/Core/NetworkApiImpl.swift @@ -58,42 +58,39 @@ class NetworkApiImpl: NetworkApi { return Int64(Int(bitPattern: pointer)) } - func setRequestHeaders(headers: [String : String], serverUrls: [String]) throws { - var headers = headers - if let token = headers.removeValue(forKey: "x-immich-user-token") { + func setRequestHeaders(headers: [String : String], serverUrls: [String], token: String?) throws { + URLSessionManager.setServerUrls(serverUrls) + + if let token = token { + let expiry = Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 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), + let values: [AuthCookie: String] = [ + .accessToken: token, + .isAuthenticated: "true", + .authType: "password", ] - let expiry = Date().addingTimeInterval(400 * 24 * 60 * 60) - for (name, value, httpOnly) in cookies { + for (cookie, value) in values { var properties: [HTTPCookiePropertyKey: Any] = [ - .name: name, + .name: cookie.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 cookie.httpOnly { properties[.init("HttpOnly")] = "TRUE" } + if let httpCookie = HTTPCookie(properties: properties) { + URLSessionManager.cookieStorage.setCookie(httpCookie) } } } } - if serverUrls.first != UserDefaults.group.string(forKey: SERVER_URL_KEY) { - UserDefaults.group.set(serverUrls.first, forKey: SERVER_URL_KEY) - } - 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() } } } diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 411b828ea1..9868d4eb59 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -3,8 +3,30 @@ import native_video_player let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" let HEADERS_KEY = "immich.request_headers" -let SERVER_URL_KEY = "immich.server_url" +let SERVER_URLS_KEY = "immich.server_urls" let APP_GROUP = "group.app.immich.share" +let COOKIE_EXPIRY_DAYS: TimeInterval = 400 + +enum AuthCookie: CaseIterable { + case accessToken, isAuthenticated, authType + + var name: String { + switch self { + case .accessToken: return "immich_access_token" + case .isAuthenticated: return "immich_is_authenticated" + case .authType: return "immich_auth_type" + } + } + + var httpOnly: Bool { + switch self { + case .accessToken, .authType: return true + case .isAuthenticated: return false + } + } + + static let names: Set = Set(allCases.map(\.name)) +} extension UserDefaults { static let group = UserDefaults(suiteName: APP_GROUP)! @@ -34,21 +56,94 @@ class URLSessionManager: NSObject { return "Immich_iOS_\(version)" }() static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP) - + private static var serverUrls: [String] = [] + private static var isSyncing = false + var sessionPointer: UnsafeMutableRawPointer { Unmanaged.passUnretained(session).toOpaque() } - + private override init() { delegate = URLSessionManagerDelegate() session = Self.buildSession(delegate: delegate) super.init() + Self.serverUrls = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY) ?? [] + NotificationCenter.default.addObserver( + Self.self, + selector: #selector(Self.cookiesDidChange), + name: NSNotification.Name.NSHTTPCookieManagerCookiesChanged, + object: Self.cookieStorage + ) } func recreateSession() { session = Self.buildSession(delegate: delegate) } + static func setServerUrls(_ urls: [String]) { + guard urls != serverUrls else { return } + serverUrls = urls + UserDefaults.group.set(urls, forKey: SERVER_URLS_KEY) + syncAuthCookies() + } + + @objc private static func cookiesDidChange(_ notification: Notification) { + guard !isSyncing, !serverUrls.isEmpty else { return } + syncAuthCookies() + } + + private static func syncAuthCookies() { + let serverHosts = Set(serverUrls.compactMap { URL(string: $0)?.host }) + let allCookies = cookieStorage.cookies ?? [] + let now = Date() + + let serverAuthCookies = allCookies.filter { + AuthCookie.names.contains($0.name) && serverHosts.contains($0.domain) + } + + var sourceCookies: [String: HTTPCookie] = [:] + for cookie in serverAuthCookies { + if cookie.expiresDate.map({ $0 > now }) ?? true { + sourceCookies[cookie.name] = cookie + } + } + + isSyncing = true + defer { isSyncing = false } + + if sourceCookies.isEmpty { + for cookie in serverAuthCookies { + cookieStorage.deleteCookie(cookie) + } + 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(COOKIE_EXPIRY_DAYS * 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/infrastructure/repositories/network.repository.dart b/mobile/lib/infrastructure/repositories/network.repository.dart index adf1ee5694..bb5796e220 100644 --- a/mobile/lib/infrastructure/repositories/network.repository.dart +++ b/mobile/lib/infrastructure/repositories/network.repository.dart @@ -26,8 +26,8 @@ class NetworkRepository { } } - static Future setHeaders(Map headers, List serverUrls) async { - await networkApi.setRequestHeaders(headers, serverUrls); + static Future setHeaders(Map headers, List serverUrls, {String? token}) async { + await networkApi.setRequestHeaders(headers, serverUrls, token); if (Platform.isIOS) { await init(); } diff --git a/mobile/lib/platform/network_api.g.dart b/mobile/lib/platform/network_api.g.dart index 314a943f7d..0ecbb430d3 100644 --- a/mobile/lib/platform/network_api.g.dart +++ b/mobile/lib/platform/network_api.g.dart @@ -281,7 +281,7 @@ class NetworkApi { } } - Future setRequestHeaders(Map headers, List serverUrls) async { + Future setRequestHeaders(Map headers, List serverUrls, String? token) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( @@ -289,7 +289,7 @@ class NetworkApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([headers, serverUrls]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([headers, serverUrls, token]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); 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/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart index ba978b0df0..a8544ef6c0 100644 --- a/mobile/lib/repositories/auth.repository.dart +++ b/mobile/lib/repositories/auth.repository.dart @@ -38,10 +38,6 @@ class AuthRepository extends DatabaseRepository { }); } - String getAccessToken() { - return Store.get(StoreKey.accessToken); - } - bool getEndpointSwitchingFeature() { return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false; } diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 566ec7aa31..bc5e46f769 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).cast(); } 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..76916cee1e 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -25,8 +25,10 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.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 +37,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 +111,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 NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken); + } + } + } + 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..704efed770 100644 --- a/mobile/pigeon/network_api.dart +++ b/mobile/pigeon/network_api.dart @@ -43,5 +43,5 @@ abstract class NetworkApi { int getClientPointer(); - void setRequestHeaders(Map headers, List serverUrls); + void setRequestHeaders(Map headers, List serverUrls, String? token); }