Skip to content

Commit

Permalink
feat: 添加Cookie持久化实现PluginStoreCookieJar
Browse files Browse the repository at this point in the history
  • Loading branch information
muedsa committed Oct 25, 2024
1 parent 1b97335 commit a0c5aae
Show file tree
Hide file tree
Showing 5 changed files with 326 additions and 0 deletions.
4 changes: 4 additions & 0 deletions api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ dependencies {
val okhttp3LoggingVersion = "4.12.0"
val timberVersion = "5.0.1"
val datastoreVersion = "1.1.1"
val junitVersion = "4.13.2"
val kotlinxCoroutinesTestVersion = "1.9.0"

api("org.jsoup:jsoup:$jsoupVersion")
api("org.jetbrains.kotlinx:kotlinx-serialization-json:$ktxJsonVersion")
Expand All @@ -50,4 +52,6 @@ dependencies {
api("com.squareup.okhttp3:logging-interceptor:$okhttp3LoggingVersion")
api("com.jakewharton.timber:timber:$timberVersion")
compileOnlyApi("androidx.datastore:datastore-preferences:$datastoreVersion")
testImplementation("junit:junit:$junitVersion")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinxCoroutinesTestVersion")
}
141 changes: 141 additions & 0 deletions api/src/main/java/com/muedsa/tvbox/tool/PluginStoreCookieJar.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.muedsa.tvbox.tool

import com.muedsa.tvbox.api.store.IPluginPerfStore
import com.muedsa.tvbox.api.store.PluginPerfKey
import com.muedsa.tvbox.api.store.stringPluginPerfKey
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import java.util.TreeSet

class PluginStoreCookieJar(
private val store: IPluginPerfStore
) : CookieJar {

companion object {
const val COOKIE_STORE_PREFIX = "COOKIE|"

private fun createCookieKey(cookie: Cookie): PluginPerfKey<String> {
return stringPluginPerfKey(
"$COOKIE_STORE_PREFIX${if (cookie.secure) "https" else "http"}://${cookie.domain}${cookie.path}|${cookie.name}"
)
}
}

private val cache: TreeSet<Cookie> = TreeSet(compareBy<Cookie>(
{ it.name }, { it.domain }, { it.path }, { it.secure }, { it.hostOnly }
))

@Synchronized
fun cacheCookie(cookie: Cookie) {
if (cache.contains(cookie)) {
cache.remove(cookie)
}
cache.add(cookie)
}

init {
runBlocking {
newSession()
}
}

override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
cookies.forEach { cacheCookie(it) }
cookies.filter { it.persistent }.forEach {
runBlocking {
store.update(
key = createCookieKey(it),
value = LenientJson.encodeToString(SerializableCookie.from(it))
)
}
}
}

override fun loadForRequest(url: HttpUrl): List<Cookie> {
val willRemove: MutableSet<Cookie> = mutableSetOf()

val list = cache.filter {
val unexpired = it.expiresAt > System.currentTimeMillis()
if (!unexpired) {
willRemove.add(it)
}
unexpired && it.matches(url)
}
if (willRemove.isNotEmpty()) {
willRemove.forEach {
cache.remove(it)
runBlocking {
store.remove(key = createCookieKey(it))
}
}
}
return list
}

suspend fun newSession() {
cache.clear()
store.filter { it.startsWith(COOKIE_STORE_PREFIX) }
.forEach {
val cookieJsonStr = it.value as String?
cookieJsonStr?.let { cjs ->
val serializableCookie =
LenientJson.decodeFromString<SerializableCookie>(cjs)
cacheCookie(serializableCookie.toCookie())
}
}
}

suspend fun clearAll() {
cache.clear()
store.filter { it.startsWith(COOKIE_STORE_PREFIX) }
.forEach { store.remove(stringPluginPerfKey(it.key)) }
}
}

@Serializable
data class SerializableCookie(
val name: String,
val value: String,
val expiresAt: Long,
val domain: String,
val path: String,
val secure: Boolean,
val httpOnly: Boolean,
val persistent: Boolean,
val hostOnly: Boolean
) {
fun toCookie(): Cookie {
val builder = Cookie.Builder()
.name(name)
.value(value)
.expiresAt(expiresAt)
.domain(domain)
.path(path)

if (secure) {
builder.secure()
}
if (httpOnly) {
builder.httpOnly()
}
return builder.build()
}

companion object {
fun from(cookie: Cookie): SerializableCookie = SerializableCookie(
name = cookie.name,
value = cookie.value,
expiresAt = cookie.expiresAt,
domain = cookie.domain,
path = cookie.path,
secure = cookie.secure,
httpOnly = cookie.httpOnly,
persistent = cookie.persistent,
hostOnly = cookie.hostOnly,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.muedsa.tvbox.api.store

@Suppress("UNCHECKED_CAST")
class FakePluginPrefStore : IPluginPerfStore {

private val store: MutableMap<String, Any> = mutableMapOf()

override suspend fun <T> get(key: PluginPerfKey<T>): T? =
store[key.name] as T?

override suspend fun <T> getOrDefault(key: PluginPerfKey<T>, default: T): T =
store[key.name] as T? ?: default

override suspend fun filter(predicate: (String) -> Boolean): Map<String, Any> =
store.filter{ predicate(it.key) }

override suspend fun <T> update(key: PluginPerfKey<T>, value: T) {
store[key.name] = value as Any
}

override suspend fun <T> remove(key: PluginPerfKey<T>) {
store.remove(key.name)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.muedsa.tvbox.tool

import com.muedsa.tvbox.api.store.FakePluginPrefStore
import kotlinx.coroutines.test.runTest
import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Test


class PluginStoreCookieJarTest {
private val url = "https://domain.com/".toHttpUrl()
private val otherUrl = "https://otherdomain.com".toHttpUrl()

private fun newCookieJar(): PluginStoreCookieJar =
PluginStoreCookieJar(store = FakePluginPrefStore())

@Test
fun regularCookie() {
val cookieJar = newCookieJar()
val cookie = TestCookieCreator.createPersistentCookie(hostOnlyDomain = false)
cookieJar.saveFromResponse(url = url, cookies = listOf(cookie))
val storedCookies = cookieJar.loadForRequest(url)
assert(cookie == storedCookies[0])
}

@Test
fun differentUrlRequest() {
val cookieJar = newCookieJar()
val cookie = TestCookieCreator.createPersistentCookie(hostOnlyDomain = false)
cookieJar.saveFromResponse(url = url, cookies = listOf(cookie))
val storedCookies = cookieJar.loadForRequest(otherUrl)
assert(storedCookies.isEmpty())
}


@Test
fun updateCookie() {
val cookieJar = newCookieJar()
cookieJar.saveFromResponse(
url = url,
cookies = listOf(
TestCookieCreator.createPersistentCookie(
name = "name",
value = "first"
)
)
)
val newCookie = TestCookieCreator.createPersistentCookie(name = "name", value = "last")
cookieJar.saveFromResponse(url = url, cookies = listOf(newCookie))
val storedCookies = cookieJar.loadForRequest(url)
assert(storedCookies.size == 1)
assert(newCookie == storedCookies[0])
}

@Test
fun expiredCookie() {
val cookieJar = newCookieJar()
cookieJar.saveFromResponse(url, listOf(TestCookieCreator.createExpiredCookie()))
val cookies: List<Cookie> = cookieJar.loadForRequest(url)
assert(cookies.isEmpty())
}

@Test
fun removeCookieWithExpiredOne() {
val cookieJar = newCookieJar()
cookieJar.saveFromResponse(url, listOf(TestCookieCreator.createPersistentCookie(false)))
cookieJar.saveFromResponse(url, listOf(TestCookieCreator.createExpiredCookie()))
assert(cookieJar.loadForRequest(url).isEmpty())
}

@Test
fun clearSessionCookies() = runTest {
val cookieJar = newCookieJar()
val persistentCookie = TestCookieCreator.createPersistentCookie(false)
cookieJar.saveFromResponse(url, listOf(persistentCookie))
cookieJar.saveFromResponse(url, listOf(TestCookieCreator.createNonPersistentCookie()))
cookieJar.newSession()
val storedCookies = cookieJar.loadForRequest(url)
assert(storedCookies.size == 1)
assert(storedCookies[0] == persistentCookie)
}
}
75 changes: 75 additions & 0 deletions api/src/test/java/com/muedsa/tvbox/tool/TestCookieCreator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.muedsa.tvbox.tool

import okhttp3.Cookie
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl

object TestCookieCreator {
private const val DEFAULT_DOMAIN = "domain.com"
private const val DEFAULT_PATH = "/"

val DEFAULT_URL: HttpUrl = "https://$DEFAULT_DOMAIN$DEFAULT_PATH".toHttpUrl()
val OTHER_URL: HttpUrl = "https://otherdomain.com/".toHttpUrl()

fun createPersistentCookie(hostOnlyDomain: Boolean): Cookie {
val builder = Cookie.Builder()
.path(DEFAULT_PATH)
.name("name")
.value("value")
.expiresAt(System.currentTimeMillis() + 24 * 60 * 60 * 1000)
.httpOnly()
.secure()
if (hostOnlyDomain) {
builder.hostOnlyDomain(DEFAULT_DOMAIN)
} else {
builder.domain(DEFAULT_DOMAIN)
}
return builder.build()
}

fun createPersistentCookie(name: String, value: String): Cookie {
return Cookie.Builder()
.domain(DEFAULT_DOMAIN)
.path(DEFAULT_PATH)
.name(name)
.value(value)
.expiresAt(System.currentTimeMillis() + 24 * 60 * 60 * 1000)
.httpOnly()
.secure()
.build()
}

fun createNonPersistentCookie(): Cookie {
return Cookie.Builder()
.domain(DEFAULT_DOMAIN)
.path(DEFAULT_PATH)
.name("name")
.value("value")
.httpOnly()
.secure()
.build()
}

fun createNonPersistentCookie(name: String, value: String): Cookie {
return Cookie.Builder()
.domain(DEFAULT_DOMAIN)
.path(DEFAULT_PATH)
.name(name)
.value(value)
.httpOnly()
.secure()
.build()
}

fun createExpiredCookie(): Cookie {
return Cookie.Builder()
.domain(DEFAULT_DOMAIN)
.path(DEFAULT_PATH)
.name("name")
.value("value")
.expiresAt(Long.MIN_VALUE)
.httpOnly()
.secure()
.build()
}
}

0 comments on commit a0c5aae

Please sign in to comment.