feat(android): access-token support — same frozen contract as iOS
Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written Cookie header on every request and on the WS upgrade (no CookieJar, matching the frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401 upgrade as a terminal state with no reconnect loop.
This commit is contained in:
@@ -45,6 +45,10 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
// Tink AEAD encrypts the per-host access-token blob at rest under an AndroidKeystore-wrapped
|
||||
// master key (B5 / ios-completion §1.1). A SEPARATE keyset from :client-tls-android's cert store —
|
||||
// different secrets, different lifecycles, and no module edge to the TLS half.
|
||||
implementation(libs.tink.android)
|
||||
|
||||
testImplementation(libs.bundles.unit.test)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Instrumented (device) contract tests for [TinkAccessTokenStore] — Tink's AEAD + the
|
||||
* AndroidKeystore-wrapped master key need a real device Keystore, so these run on hardware (no
|
||||
* emulator in the build env → compiled here, executed in device QA, plan §7 / DEVICE_QA_CHECKLIST).
|
||||
*
|
||||
* They pin the same contract the JVM [InMemoryAccessTokenStoreTest] pins for the double, PLUS the two
|
||||
* things only the real store can prove: the token **survives a restart** (a fresh store instance
|
||||
* decrypts the blob) and the **at-rest bytes are ciphertext**, never the token in the clear.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TinkAccessTokenStoreTest {
|
||||
|
||||
private companion object {
|
||||
const val TOKEN = "0123456789abcdefTOKEN"
|
||||
}
|
||||
|
||||
private val context: Context get() = ApplicationProvider.getApplicationContext()
|
||||
|
||||
/** A store on a per-test keyset/pref-file so tests never share state. */
|
||||
private fun newStore(suffix: String): TinkAccessTokenStore = TinkAccessTokenStore(
|
||||
context = context,
|
||||
keysetName = "test_token_keyset_$suffix",
|
||||
prefFileName = "test_token_prefs_$suffix",
|
||||
masterKeyUri = "android-keystore://test_token_master_$suffix",
|
||||
)
|
||||
|
||||
private fun endpoint(url: String = "http://10.0.0.5:3000"): HostEndpoint =
|
||||
requireNotNull(HostEndpoint.fromBaseUrl(url))
|
||||
|
||||
@Test
|
||||
fun put_then_tokenFor_returns_the_token() = runBlocking {
|
||||
val store = newStore(UUID.randomUUID().toString())
|
||||
|
||||
assertTrue(store.put(endpoint(), TOKEN))
|
||||
|
||||
assertEquals(TOKEN, store.tokenFor(endpoint()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_fresh_store_instance_decrypts_the_persisted_token() = runBlocking {
|
||||
val suffix = UUID.randomUUID().toString()
|
||||
newStore(suffix).put(endpoint(), TOKEN)
|
||||
|
||||
// A new instance = a cold app start: no in-memory cache, must decrypt from disk.
|
||||
assertEquals(TOKEN, newStore(suffix).tokenFor(endpoint()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun the_token_is_never_stored_in_the_clear() = runBlocking {
|
||||
val suffix = UUID.randomUUID().toString()
|
||||
newStore(suffix).put(endpoint(), TOKEN)
|
||||
|
||||
val raw = context
|
||||
.getSharedPreferences("test_token_prefs_$suffix", Context.MODE_PRIVATE)
|
||||
.all
|
||||
.values
|
||||
.joinToString(" ") { it.toString() }
|
||||
|
||||
assertFalse("the at-rest blob must be ciphertext", raw.contains(TOKEN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun remove_drops_the_token_durably() = runBlocking {
|
||||
val suffix = UUID.randomUUID().toString()
|
||||
val store = newStore(suffix)
|
||||
store.put(endpoint(), TOKEN)
|
||||
|
||||
store.remove(endpoint())
|
||||
|
||||
assertNull(store.tokenFor(endpoint()))
|
||||
assertNull("a removed token must not come back on a cold start", newStore(suffix).tokenFor(endpoint()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_malformed_token_is_rejected_without_touching_storage() = runBlocking {
|
||||
val suffix = UUID.randomUUID().toString()
|
||||
val store = newStore(suffix)
|
||||
|
||||
assertFalse(store.put(endpoint(), "short"))
|
||||
|
||||
assertNull(store.tokenFor(endpoint()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tokens_are_keyed_per_host() = runBlocking {
|
||||
val store = newStore(UUID.randomUUID().toString())
|
||||
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
|
||||
|
||||
assertNull(store.tokenFor(endpoint("http://10.0.0.9:3000")))
|
||||
assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000/")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import wang.yaojia.webterm.wire.AccessTokenRule
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
|
||||
/**
|
||||
* Per-host storage for the optional shared access token (`WEBTERM_TOKEN`, ios-completion §1.1).
|
||||
*
|
||||
* It IS an [AccessTokenSource], so the very same instance that the pairing screen writes to is what
|
||||
* `:api-client` (every REST route) and `:transport-okhttp` (the WS upgrade) read from — one store, no
|
||||
* adapter, no cache to fall out of sync.
|
||||
*
|
||||
* ### Keying: the canonical origin, not the raw base URL
|
||||
* The key is [HostEndpoint.originHeader] — already single-point-derived, lower-cased, default-port
|
||||
* normalized. So `http://box:3000`, `HTTP://box:3000/` and `http://box:3000/some/path` are ONE host
|
||||
* (they are literally the same server, and the token is the server's), while a different port or host
|
||||
* is a different key. Storing under the raw `baseUrl` would silently lose the token when the same
|
||||
* server was re-paired from a URL with a trailing slash.
|
||||
*
|
||||
* ### Security contract (non-negotiable)
|
||||
* Implementations MUST keep the token device-only (Android-Keystore-wrapped, excluded from backup),
|
||||
* MUST validate through [AccessTokenRule] before persisting, and MUST NEVER log it.
|
||||
*/
|
||||
public interface AccessTokenStore : AccessTokenSource {
|
||||
/** The token stored for [endpoint]'s canonical origin, or null. Never logs. */
|
||||
override suspend fun tokenFor(endpoint: HostEndpoint): String?
|
||||
|
||||
/**
|
||||
* Store [token] for [endpoint] (replacing any previous one). Returns **false** and stores nothing
|
||||
* when the token fails [AccessTokenRule] — a value the server could never accept must not be
|
||||
* persisted (nor round-tripped to disk) at all.
|
||||
*/
|
||||
public suspend fun put(endpoint: HostEndpoint, token: String): Boolean
|
||||
|
||||
/** Drop [endpoint]'s token. Idempotent — removing an unknown host is a no-op. */
|
||||
public suspend fun remove(endpoint: HostEndpoint)
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory [AccessTokenStore]. Lives in `main` (not `test`) on purpose — it doubles for the encrypted
|
||||
* store in this module's contract tests AND in the `:app` pairing-ViewModel tests (mirrors
|
||||
* [InMemoryHostStore]).
|
||||
*
|
||||
* A [Mutex] serializes the read-modify-write; the state is an immutable map replaced wholesale on every
|
||||
* change (never mutated in place).
|
||||
*/
|
||||
public class InMemoryAccessTokenStore(
|
||||
initial: Map<String, String> = emptyMap(),
|
||||
) : AccessTokenStore {
|
||||
private val mutex = Mutex()
|
||||
|
||||
// Defensive copy so a mutable map handed to the ctor cannot alias our state.
|
||||
private var tokensByOrigin: Map<String, String> = initial.toMap()
|
||||
|
||||
override suspend fun tokenFor(endpoint: HostEndpoint): String? =
|
||||
mutex.withLock { tokensByOrigin[endpoint.originHeader] }
|
||||
|
||||
override suspend fun put(endpoint: HostEndpoint, token: String): Boolean {
|
||||
val normalized = AccessTokenRule.normalize(token) ?: return false
|
||||
mutex.withLock { tokensByOrigin = tokensByOrigin + (endpoint.originHeader to normalized) }
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun remove(endpoint: HostEndpoint) {
|
||||
mutex.withLock { tokensByOrigin = tokensByOrigin - endpoint.originHeader }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import com.google.crypto.tink.Aead
|
||||
import com.google.crypto.tink.KeyTemplates
|
||||
import com.google.crypto.tink.RegistryConfiguration
|
||||
import com.google.crypto.tink.aead.AeadConfig
|
||||
import com.google.crypto.tink.integration.android.AndroidKeysetManager
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import wang.yaojia.webterm.wire.AccessTokenRule
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* The production [AccessTokenStore]: per-host access tokens AEAD-encrypted with Tink under an
|
||||
* **AndroidKeystore-wrapped master key** (ios-completion §1.1 storage rule — the Android analogue of
|
||||
* iOS Keychain `…AfterFirstUnlockThisDeviceOnly` + no `kSecAttrSynchronizable`).
|
||||
*
|
||||
* What that buys, concretely:
|
||||
* - the master key lives in the Keystore (`android-keystore://…`) and is **non-exportable**, so the
|
||||
* on-disk ciphertext is useless on any other device — device-only, exactly like the cert store;
|
||||
* - the blob sits in an app-private `SharedPreferences` file (uninstall-wiped) and the app manifest
|
||||
* sets `android:allowBackup="false"`, so it is **never** in a cloud/adb backup;
|
||||
* - nothing here logs, and the token never enters a URL (see `AuthCookie`).
|
||||
*
|
||||
* Deliberately a SEPARATE keyset/pref-file/master-key from `:client-tls-android`'s `TinkCertStore`:
|
||||
* different secrets with different lifecycles must not share an AEAD keyset (and this module must not
|
||||
* gain a dependency on the TLS module). The ~20 lines of Tink setup are the price of that isolation.
|
||||
*
|
||||
* All disk + crypto work hops to [io] (never `Main`); the decrypted map is cached in memory after the
|
||||
* first read so the per-request [tokenFor] on the WS-dial / REST path is a cheap map lookup, and a
|
||||
* [Mutex] serializes the read-modify-write of a mutation. A blob that fails to decrypt/decode (tamper,
|
||||
* version skew, a wiped Keystore key after a device restore) degrades to "no tokens" — the user
|
||||
* re-enters the token — rather than throwing on every request.
|
||||
*/
|
||||
public class TinkAccessTokenStore(
|
||||
context: Context,
|
||||
private val io: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val keysetName: String = DEFAULT_KEYSET_NAME,
|
||||
private val prefFileName: String = DEFAULT_PREF_FILE,
|
||||
private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI,
|
||||
) : AccessTokenStore {
|
||||
private val appContext: Context = context.applicationContext
|
||||
private val mutex = Mutex()
|
||||
|
||||
/** Decrypted `origin → token` map; null until the first read. Replaced wholesale, never mutated. */
|
||||
private var cache: Map<String, String>? = null
|
||||
|
||||
// Built on first use (registers AEAD, loads-or-creates the Keystore-wrapped keyset) — slow, so it
|
||||
// only ever happens inside a withContext(io) block.
|
||||
private val aead: Aead by lazy { buildAead() }
|
||||
|
||||
override suspend fun tokenFor(endpoint: HostEndpoint): String? =
|
||||
loadTokens()[endpoint.originHeader]
|
||||
|
||||
/**
|
||||
* Validate → encrypt → durably persist → publish to the cache, in that order: a token the server
|
||||
* could never accept returns false without touching disk, and the in-memory view is only updated
|
||||
* after the write is on disk (so a failed write can never leave a "stored" token that vanishes on
|
||||
* the next launch).
|
||||
*
|
||||
* @throws IOException when the durable write fails (infrastructure failure, distinct from `false`,
|
||||
* which means the token itself was malformed).
|
||||
*/
|
||||
override suspend fun put(endpoint: HostEndpoint, token: String): Boolean {
|
||||
val normalized = AccessTokenRule.normalize(token) ?: return false
|
||||
mutex.withLock {
|
||||
val updated = readThrough() + (endpoint.originHeader to normalized)
|
||||
persist(updated)
|
||||
cache = updated
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun remove(endpoint: HostEndpoint) {
|
||||
mutex.withLock {
|
||||
val current = readThrough()
|
||||
if (!current.containsKey(endpoint.originHeader)) return // idempotent, no needless write
|
||||
val updated = current - endpoint.originHeader
|
||||
persist(updated)
|
||||
cache = updated
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadTokens(): Map<String, String> =
|
||||
cache ?: mutex.withLock {
|
||||
val loaded = readThrough()
|
||||
cache = loaded
|
||||
loaded
|
||||
}
|
||||
|
||||
/** Decrypt the stored blob (or the cache when warm). MUST be called with [mutex] held. */
|
||||
private suspend fun readThrough(): Map<String, String> =
|
||||
cache ?: withContext(io) { decodeBlob(prefs().getString(BLOB_KEY, null)) }
|
||||
|
||||
private suspend fun persist(tokens: Map<String, String>) {
|
||||
withContext(io) {
|
||||
val plaintext = JSON.encodeToString(TOKEN_MAP_SERIALIZER, tokens).encodeToByteArray()
|
||||
val ciphertext = aead.encrypt(plaintext, ASSOCIATED_DATA)
|
||||
val committed = prefs().edit()
|
||||
.putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP))
|
||||
.commit() // synchronous + reports failure (as TinkCertStore); apply() would hide it
|
||||
if (!committed) throw IOException("failed to durably persist the access-token blob")
|
||||
}
|
||||
}
|
||||
|
||||
/** Any decrypt/decode failure ⇒ "no tokens" (never crash a request path on a corrupt blob). */
|
||||
private fun decodeBlob(encoded: String?): Map<String, String> {
|
||||
if (encoded == null) return emptyMap()
|
||||
return runCatching {
|
||||
val ciphertext = Base64.decode(encoded, Base64.NO_WRAP)
|
||||
val plaintext = aead.decrypt(ciphertext, ASSOCIATED_DATA)
|
||||
JSON.decodeFromString(TOKEN_MAP_SERIALIZER, plaintext.decodeToString())
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
private fun buildAead(): Aead {
|
||||
AeadConfig.register()
|
||||
val keysetHandle = AndroidKeysetManager.Builder()
|
||||
.withSharedPref(appContext, keysetName, prefFileName)
|
||||
.withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE))
|
||||
.withMasterKeyUri(masterKeyUri)
|
||||
.build()
|
||||
.keysetHandle
|
||||
return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
|
||||
}
|
||||
|
||||
private fun prefs(): SharedPreferences =
|
||||
appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
|
||||
|
||||
public companion object {
|
||||
private const val DEFAULT_KEYSET_NAME = "webterm_access_token_keyset"
|
||||
private const val DEFAULT_PREF_FILE = "webterm_access_token_prefs"
|
||||
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_access_token_master_key"
|
||||
private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
|
||||
private const val BLOB_KEY = "access_tokens_blob"
|
||||
|
||||
/** AEAD associated data — binds the ciphertext to this context so a lifted blob won't decrypt. */
|
||||
private val ASSOCIATED_DATA: ByteArray = "webterm.host-registry.access-tokens".toByteArray()
|
||||
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
private val TOKEN_MAP_SERIALIZER = MapSerializer(String.serializer(), String.serializer())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
|
||||
/**
|
||||
* B5 · the per-host access-token store contract (ios-completion §1.1), exercised through the pure
|
||||
* in-memory double (the Tink/AndroidKeyStore-backed implementation is instrumented → device QA):
|
||||
* - a token is keyed by the endpoint's CANONICAL origin, so the same server dialed with a trailing
|
||||
* slash / different case / a path resolves to the SAME token, and a different host never does;
|
||||
* - a malformed token is REJECTED at the boundary and never stored;
|
||||
* - `remove` is idempotent, and the store doubles as the `AccessTokenSource` the transports consume.
|
||||
*/
|
||||
class InMemoryAccessTokenStoreTest {
|
||||
private companion object {
|
||||
const val TOKEN = "0123456789abcdefTOKEN"
|
||||
const val OTHER_TOKEN = "fedcba9876543210OTHER"
|
||||
}
|
||||
|
||||
private fun endpoint(url: String): HostEndpoint =
|
||||
requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" }
|
||||
|
||||
@Test
|
||||
fun `a stored token is returned for the same host`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
val host = endpoint("http://10.0.0.5:3000")
|
||||
|
||||
assertTrue(store.put(host, TOKEN))
|
||||
|
||||
assertEquals(TOKEN, store.tokenFor(host))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown host has no token`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
|
||||
|
||||
assertNull(store.tokenFor(endpoint("http://10.0.0.6:3000")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the key is the canonical origin so the same server matches however it was dialed`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
|
||||
|
||||
assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000/")))
|
||||
assertEquals(TOKEN, store.tokenFor(endpoint("HTTP://10.0.0.5:3000")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different port is a different host`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
|
||||
|
||||
assertNull(store.tokenFor(endpoint("http://10.0.0.5:3001")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `putting again replaces the token for that host only`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
val a = endpoint("http://10.0.0.5:3000")
|
||||
val b = endpoint("http://10.0.0.6:3000")
|
||||
store.put(a, TOKEN)
|
||||
store.put(b, OTHER_TOKEN)
|
||||
|
||||
store.put(a, OTHER_TOKEN)
|
||||
|
||||
assertEquals(OTHER_TOKEN, store.tokenFor(a))
|
||||
assertEquals(OTHER_TOKEN, store.tokenFor(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed token is rejected and never stored`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
val host = endpoint("http://10.0.0.5:3000")
|
||||
|
||||
assertFalse(store.put(host, "short"), "below the server's 16-char minimum")
|
||||
assertFalse(store.put(host, "has spaces in it here"), "outside the server charset")
|
||||
|
||||
assertNull(store.tokenFor(host), "a rejected token must not reach storage")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a whitespace-padded token is trimmed once on the way in`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
val host = endpoint("http://10.0.0.5:3000")
|
||||
|
||||
store.put(host, " $TOKEN\n")
|
||||
|
||||
assertEquals(TOKEN, store.tokenFor(host))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove drops the token and is idempotent`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
val host = endpoint("http://10.0.0.5:3000")
|
||||
store.put(host, TOKEN)
|
||||
|
||||
store.remove(host)
|
||||
store.remove(host)
|
||||
|
||||
assertNull(store.tokenFor(host))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the store is usable directly as the transports' AccessTokenSource`() = runTest {
|
||||
val store: wang.yaojia.webterm.wire.AccessTokenSource = InMemoryAccessTokenStore(
|
||||
initial = mapOf(endpoint("http://10.0.0.5:3000").originHeader to TOKEN),
|
||||
)
|
||||
|
||||
assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000")))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user