diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt index b68ec9f68..60156f3c4 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt @@ -937,6 +937,33 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : } } + /** + * Set the custom proxy headers (JSON-encoded array of {name, value} pairs). + */ + @ReactMethod + override fun setCustomProxyHeaders(headersJson: String, promise: Promise) { + try { + webApiService.setCustomProxyHeaders(headersJson) + promise.resolve(null) + } catch (e: Exception) { + Log.e(TAG, "Error setting custom proxy headers", e) + promise.reject("ERR_SET_CUSTOM_PROXY_HEADERS", "Failed to set custom proxy headers: ${e.message}", e) + } + } + + /** + * Get the custom proxy headers as a JSON string. + */ + @ReactMethod + override fun getCustomProxyHeaders(promise: Promise) { + try { + promise.resolve(webApiService.getCustomProxyHeadersJson()) + } catch (e: Exception) { + Log.e(TAG, "Error getting custom proxy headers", e) + promise.reject("ERR_GET_CUSTOM_PROXY_HEADERS", "Failed to get custom proxy headers: ${e.message}", e) + } + } + // MARK: - WebAPI Token Management /** diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/webapi/WebApiService.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/webapi/WebApiService.kt index 89da94e5e..cf281680f 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/webapi/WebApiService.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/webapi/WebApiService.kt @@ -5,6 +5,7 @@ import android.content.pm.PackageManager import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.json.JSONArray import org.json.JSONObject import java.io.BufferedReader import java.io.InputStreamReader @@ -35,6 +36,7 @@ class WebApiService(private val context: Context) { private const val ACCESS_TOKEN_KEY = "accessToken" private const val REFRESH_TOKEN_KEY = "refreshToken" private const val APP_INSTANCE_ID_KEY = "appInstanceId" + private const val CUSTOM_PROXY_HEADERS_KEY = "customProxyHeaders" private const val DEFAULT_API_URL = "https://app.aliasvault.net/api" private const val SHARED_PREFS_NAME = "aliasvault" } @@ -73,6 +75,62 @@ class WebApiService(private val context: Context) { return sharedPreferences.getString(API_URL_KEY, DEFAULT_API_URL) ?: DEFAULT_API_URL } + /** + * Set the custom proxy headers (JSON-encoded array of {name, value} pairs). + */ + fun setCustomProxyHeaders(json: String) { + sharedPreferences.edit().putString(CUSTOM_PROXY_HEADERS_KEY, json).apply() + } + + /** + * Get the custom proxy headers as a raw JSON string. Returns "[]" when none configured. + */ + fun getCustomProxyHeadersJson(): String { + return sharedPreferences.getString(CUSTOM_PROXY_HEADERS_KEY, "[]") ?: "[]" + } + + /** + * Parse the stored custom proxy headers into a name->value map. + * Headers conflicting with built-in AliasVault headers are ignored. + */ + private fun getCustomProxyHeaders(): Map { + if (getApiUrl() == DEFAULT_API_URL) { + return emptyMap() + } + return try { + val array = JSONArray(getCustomProxyHeadersJson()) + (0 until array.length()) + .mapNotNull { parseProxyHeaderEntry(array.optJSONObject(it)) } + .toMap() + } catch (e: Exception) { + Log.w(TAG, "Failed to parse custom proxy headers", e) + emptyMap() + } + } + + /** + * Parse and validate a single proxy-header JSON entry. Returns null if the entry is missing, + * empty, or conflicts with a built-in AliasVault header. + */ + private fun parseProxyHeaderEntry(entry: JSONObject?): Pair? { + if (entry == null) { + return null + } + + val name = entry.optString("name", "").trim() + val value = entry.optString("value", "").trim() + if (name.isEmpty() || value.isEmpty()) { + return null + } + + val lower = name.lowercase() + if (lower == "authorization" || lower.startsWith("x-aliasvault-")) { + return null + } + + return name to value + } + /** * Get the base URL with /v1/ appended. */ @@ -202,8 +260,12 @@ class WebApiService(private val context: Context) { connection.readTimeout = 30000 // 30 seconds connection.doInput = true + // Add any custom proxy headers + val finalHeaders = getCustomProxyHeaders().toMutableMap() + finalHeaders.putAll(headers) + // Set headers - for ((key, value) in headers) { + for ((key, value) in finalHeaders) { connection.setRequestProperty(key, value) } diff --git a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm index 2ff9fcc9b..699d02e95 100644 --- a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm +++ b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm @@ -185,6 +185,14 @@ [vaultManager getApiUrl:resolve rejecter:reject]; } +- (void)setCustomProxyHeaders:(NSString *)headersJson resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [vaultManager setCustomProxyHeaders:headersJson resolver:resolve rejecter:reject]; +} + +- (void)getCustomProxyHeaders:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [vaultManager getCustomProxyHeaders:resolve rejecter:reject]; +} + // MARK: - WebAPI Token Management - (void)setAuthTokens:(NSString *)accessToken refreshToken:(NSString *)refreshToken resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { diff --git a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift index 3a127b8bc..b14175c6f 100644 --- a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift +++ b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift @@ -492,6 +492,20 @@ public class VaultManager: NSObject { resolve(apiUrl) } + @objc + func setCustomProxyHeaders(_ headersJson: String, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + webApiService.setCustomProxyHeaders(headersJson) + resolve(nil) + } + + @objc + func getCustomProxyHeaders(_ resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + resolve(webApiService.getCustomProxyHeadersJson()) + } + // MARK: - WebAPI Token Management @objc diff --git a/apps/mobile-app/ios/VaultStoreKit/Services/WebApiService.swift b/apps/mobile-app/ios/VaultStoreKit/Services/WebApiService.swift index 24d5086b5..8df127b37 100644 --- a/apps/mobile-app/ios/VaultStoreKit/Services/WebApiService.swift +++ b/apps/mobile-app/ios/VaultStoreKit/Services/WebApiService.swift @@ -11,6 +11,7 @@ public class WebApiService { private let apiUrlKey = "apiUrl" private let accessTokenKey = "accessToken" private let refreshTokenKey = "refreshToken" + private let customProxyHeadersKey = "customProxyHeaders" // Default API URL private let defaultApiUrl = "https://app.aliasvault.net/api" @@ -38,6 +39,54 @@ public class WebApiService { return userDefaults.string(forKey: apiUrlKey) ?? defaultApiUrl } + /** + * Set the custom proxy headers (JSON-encoded array of {name, value} pairs). + */ + public func setCustomProxyHeaders(_ json: String) { + userDefaults.set(json, forKey: customProxyHeadersKey) + userDefaults.synchronize() + } + + /** + * Get the custom proxy headers as a raw JSON string. Returns "[]" when none configured. + */ + public func getCustomProxyHeadersJson() -> String { + return userDefaults.string(forKey: customProxyHeadersKey) ?? "[]" + } + + /** + * Parse the stored custom proxy headers into a name->value dictionary. + * Headers whose name conflicts with built-in AliasVault headers are ignored. + */ + private func getCustomProxyHeaders() -> [String: String] { + if getApiUrl() == defaultApiUrl { + return [:] + } + let json = getCustomProxyHeadersJson() + guard let data = json.data(using: .utf8), + let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return [:] + } + return array + .compactMap(parseProxyHeaderEntry) + .reduce(into: [String: String]()) { result, pair in result[pair.0] = pair.1 } + } + + /** + * Parse and validate a single proxy-header entry. Returns nil if the entry is missing, + * empty, or conflicts with a built-in AliasVault header. + */ + private func parseProxyHeaderEntry(_ entry: [String: Any]) -> (String, String)? { + guard let name = entry["name"] as? String, + let value = entry["value"] as? String else { return nil } + let trimmedName = name.trimmingCharacters(in: .whitespaces) + let trimmedValue = value.trimmingCharacters(in: .whitespaces) + if trimmedName.isEmpty || trimmedValue.isEmpty { return nil } + let lower = trimmedName.lowercased() + if lower == "authorization" || lower.hasPrefix("x-aliasvault-") { return nil } + return (trimmedName, trimmedValue) + } + /** * Get the base URL with /v1/ appended */ @@ -161,8 +210,14 @@ public class WebApiService { var request = URLRequest(url: url) request.httpMethod = method.uppercased() - // Set headers + // Add any custom proxy headers + var finalHeaders = getCustomProxyHeaders() for (key, value) in headers { + finalHeaders[key] = value + } + + // Set headers + for (key, value) in finalHeaders { request.setValue(value, forHTTPHeaderField: key) } diff --git a/apps/mobile-app/specs/NativeVaultManager.ts b/apps/mobile-app/specs/NativeVaultManager.ts index b2e20c890..18e7b1cae 100644 --- a/apps/mobile-app/specs/NativeVaultManager.ts +++ b/apps/mobile-app/specs/NativeVaultManager.ts @@ -11,6 +11,9 @@ export interface Spec extends TurboModule { getAccessToken(): Promise; clearAuthTokens(): Promise; revokeTokens(): Promise; + // Custom proxy headers added to every outgoing API request + setCustomProxyHeaders(headersJson: string): Promise; + getCustomProxyHeaders(): Promise; // WebAPI request execution executeWebApiRequest(method: string, endpoint: string, body: string | null, headers: string, requiresAuth: boolean): Promise; @@ -22,18 +25,16 @@ export interface Spec extends TurboModule { clearSession(): Promise; // Clears session only, preserves vault for potential RPO recovery clearVault(): Promise; // Clears everything including vault data - // Vault sync - single method handles all sync logic including merge - // Returns detailed result about what action was taken + // Vault sync syncVaultWithServer(): Promise<{ success: boolean; action: 'uploaded' | 'downloaded' | 'merged' | 'already_in_sync' | 'error'; newRevision: number; wasOffline: boolean; error: string | null }>; - // Quick check if sync is needed without doing the actual sync - // Used to show appropriate UI indicator before starting sync + // Quick check if sync is needed checkSyncStatus(): Promise<{ success: boolean; hasNewerVault: boolean; hasDirtyChanges: boolean; isOffline: boolean; requiresLogout: boolean; errorKey: string | null }>; - // Sync state management (kept for local mutation tracking) + // Sync state management getSyncState(): Promise<{isDirty: boolean; mutationSequence: number; serverRevision: number; isSyncing: boolean}>; markVaultClean(mutationSeqAtStart: number, newServerRevision: number): Promise; - clearEncryptedVaultForFreshDownload(): Promise; // Deletes corrupted vault and resets sync state to force fresh download + clearEncryptedVaultForFreshDownload(): Promise; // Vault SQL operations executeQuery(query: string, params: (string | number | null)[]): Promise; @@ -42,8 +43,7 @@ export interface Spec extends TurboModule { beginTransaction(): Promise; commitTransaction(): Promise; rollbackTransaction(): Promise; - // Persist the in-memory database to encrypted storage and mark as dirty. - // Used after migrations where SQL handles its own transactions but we need to persist and sync. + // Persist the in-memory database to encrypted storage and mark as dirty persistAndMarkDirty(): Promise; // Cryptography operations