diff --git a/AndroidCompat/src/main/java/android/webkit/CookieManager.java b/AndroidCompat/src/main/java/android/webkit/CookieManager.java new file mode 100644 index 0000000000..030298122d --- /dev/null +++ b/AndroidCompat/src/main/java/android/webkit/CookieManager.java @@ -0,0 +1,247 @@ +package android.webkit; + +import android.annotation.Nullable; +import xyz.nulldev.androidcompat.webkit.CookieManagerImpl; + +public abstract class CookieManager { + /** + * @deprecated This class should not be constructed by applications, use {@link #getInstance} + * instead to fetch the singleton instance. + */ + // TODO(ntfschr): mark this as @SystemApi after a year. + @Deprecated + public CookieManager() {} + @Override + protected Object clone() throws CloneNotSupportedException { + throw new CloneNotSupportedException("doesn't implement Cloneable"); + } + + private static CookieManager INSTANCE = null; + private static final Object lock = new Object(); + /** + * Gets the singleton CookieManager instance. + * + * @return the singleton CookieManager instance + */ + public static CookieManager getInstance() { + if (INSTANCE != null) { + return INSTANCE; + } else { + synchronized (lock) { + if (INSTANCE == null) { + INSTANCE = new CookieManagerImpl(); + } + return INSTANCE; + } + } + } + /** + * Sets whether the application's {@link WebView} instances should send and + * accept cookies. + * By default this is set to {@code true} and the WebView accepts cookies. + *

+ * When this is {@code true} + * {@link CookieManager#setAcceptThirdPartyCookies setAcceptThirdPartyCookies} and + * {@link CookieManager#setAcceptFileSchemeCookies setAcceptFileSchemeCookies} + * can be used to control the policy for those specific types of cookie. + * + * @param accept whether {@link WebView} instances should send and accept + * cookies + */ + public abstract void setAcceptCookie(boolean accept); + /** + * Gets whether the application's {@link WebView} instances send and accept + * cookies. + * + * @return {@code true} if {@link WebView} instances send and accept cookies + */ + public abstract boolean acceptCookie(); + /** + * Sets whether the {@link WebView} should allow third party cookies to be set. + * Allowing third party cookies is a per WebView policy and can be set + * differently on different WebView instances. + *

+ * Apps that target {@link android.os.Build.VERSION_CODES#KITKAT} or below + * default to allowing third party cookies. Apps targeting + * {@link android.os.Build.VERSION_CODES#LOLLIPOP} or later default to disallowing + * third party cookies. + * + * @param webview the {@link WebView} instance to set the cookie policy on + * @param accept whether the {@link WebView} instance should accept + * third party cookies + */ + public abstract void setAcceptThirdPartyCookies(WebView webview, boolean accept); + /** + * Gets whether the {@link WebView} should allow third party cookies to be set. + * + * @param webview the {@link WebView} instance to get the cookie policy for + * @return {@code true} if the {@link WebView} accepts third party cookies + */ + public abstract boolean acceptThirdPartyCookies(WebView webview); + /** + * Sets a single cookie (key-value pair) for the given URL. Any existing cookie with the same + * host, path and name will be replaced with the new cookie. The cookie being set + * will be ignored if it is expired. To set multiple cookies, your application should invoke + * this method multiple times. + * + *

The {@code value} parameter must follow the format of the {@code Set-Cookie} HTTP + * response header defined by + * RFC6265bis. + * This is a key-value pair of the form {@code "key=value"}, optionally followed by a list of + * cookie attributes delimited with semicolons (ex. {@code "key=value; Max-Age=123"}). Please + * consult the RFC specification for a list of valid attributes. + * + *

Note: if specifying a {@code value} containing the {@code "Secure"} + * attribute, {@code url} must use the {@code "https://"} scheme. + * + * @param url the URL for which the cookie is to be set + * @param value the cookie as a string, using the format of the 'Set-Cookie' + * HTTP response header + */ + public abstract void setCookie(String url, String value); + /** + * Sets a single cookie (key-value pair) for the given URL. Any existing cookie with the same + * host, path and name will be replaced with the new cookie. The cookie being set + * will be ignored if it is expired. To set multiple cookies, your application should invoke + * this method multiple times. + * + *

The {@code value} parameter must follow the format of the {@code Set-Cookie} HTTP + * response header defined by + * RFC6265bis. + * This is a key-value pair of the form {@code "key=value"}, optionally followed by a list of + * cookie attributes delimited with semicolons (ex. {@code "key=value; Max-Age=123"}). Please + * consult the RFC specification for a list of valid attributes. + * + *

This method is asynchronous. If a {@link ValueCallback} is provided, + * {@link ValueCallback#onReceiveValue} will be called on the current + * thread's {@link android.os.Looper} once the operation is complete. + * The value provided to the callback indicates whether the cookie was set successfully. + * You can pass {@code null} as the callback if you don't need to know when the operation + * completes or whether it succeeded, and in this case it is safe to call the method from a + * thread without a Looper. + * + *

Note: if specifying a {@code value} containing the {@code "Secure"} + * attribute, {@code url} must use the {@code "https://"} scheme. + * + * @param url the URL for which the cookie is to be set + * @param value the cookie as a string, using the format of the 'Set-Cookie' + * HTTP response header + * @param callback a callback to be executed when the cookie has been set + */ + public abstract void setCookie(String url, String value, @Nullable ValueCallback + callback); + /** + * Gets all the cookies for the given URL. This may return multiple key-value pairs if multiple + * cookies are associated with this URL, in which case each cookie will be delimited by {@code + * "; "} characters (semicolon followed by a space). Each key-value pair will be of the form + * {@code "key=value"}. + * + * @param url the URL for which the cookies are requested + * @return value the cookies as a string, using the format of the 'Cookie' + * HTTP request header + */ + public abstract String getCookie(String url); + + /** + * Removes all session cookies, which are cookies without an expiration + * date. + * @deprecated use {@link #removeSessionCookies(ValueCallback)} instead. + */ + @Deprecated + public abstract void removeSessionCookie(); + /** + * Removes all session cookies, which are cookies without an expiration + * date. + *

+ * This method is asynchronous. + * If a {@link ValueCallback} is provided, + * {@link ValueCallback#onReceiveValue(Object)} will be called on the current + * thread's {@link android.os.Looper} once the operation is complete. + * The value provided to the callback indicates whether any cookies were removed. + * You can pass {@code null} as the callback if you don't need to know when the operation + * completes or whether any cookie were removed, and in this case it is safe to call the + * method from a thread without a Looper. + * @param callback a callback which is executed when the session cookies have been removed + */ + public abstract void removeSessionCookies(@Nullable ValueCallback callback); + /** + * Removes all cookies. + * @deprecated Use {@link #removeAllCookies(ValueCallback)} instead. + */ + @Deprecated + public abstract void removeAllCookie(); + /** + * Removes all cookies. + *

+ * This method is asynchronous. + * If a {@link ValueCallback} is provided, + * {@link ValueCallback#onReceiveValue(Object)} will be called on the current + * thread's {@link android.os.Looper} once the operation is complete. + * The value provided to the callback indicates whether any cookies were removed. + * You can pass {@code null} as the callback if you don't need to know when the operation + * completes or whether any cookies were removed, and in this case it is safe to call the + * method from a thread without a Looper. + * @param callback a callback which is executed when the cookies have been removed + */ + public abstract void removeAllCookies(@Nullable ValueCallback callback); + /** + * Gets whether there are stored cookies. + * + * @return {@code true} if there are stored cookies + */ + public abstract boolean hasCookies(); + /** + * Removes all expired cookies. + * @deprecated The WebView handles removing expired cookies automatically. + */ + @Deprecated + public abstract void removeExpiredCookie(); + /** + * Ensures all cookies currently accessible through the getCookie API are + * written to persistent storage. + * This call will block the caller until it is done and may perform I/O. + */ + public abstract void flush(); + /** + * Gets whether the application's {@link WebView} instances send and accept + * cookies for file scheme URLs. + * + * @return {@code true} if {@link WebView} instances send and accept cookies for + * file scheme URLs + */ + // Static for backward compatibility. + public static boolean allowFileSchemeCookies() { + return getInstance().allowFileSchemeCookiesImpl(); + } + + public abstract boolean allowFileSchemeCookiesImpl(); + + /** + * Sets whether the application's {@link WebView} instances should send and accept cookies for + * file scheme URLs. + *

+ * Use of cookies with file scheme URLs is potentially insecure and turned off by default. All + * {@code file://} URLs share all their cookies, which may lead to leaking private app cookies + * (ex. any malicious file can access cookies previously set by other (trusted) files). + *

+ * Loading content via {@code file://} URLs is generally discouraged. See the note in + * {@link WebSettings#setAllowFileAccess}. + * Using + * androidx.webkit.WebViewAssetLoader to load files over {@code http(s)://} URLs allows + * the standard web security model to be used for setting and sharing cookies for local files. + *

+ * Note that calls to this method will have no effect if made after calling other + * {@link CookieManager} APIs. + * + * @deprecated This setting is not secure, please use + * + * androidx.webkit.WebViewAssetLoader instead. + */ + // Static for backward compatibility. + @Deprecated + public static void setAcceptFileSchemeCookies(boolean accept) { + getInstance().setAcceptFileSchemeCookiesImpl(accept); + } + + public abstract void setAcceptFileSchemeCookiesImpl(boolean accept); +} \ No newline at end of file diff --git a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/CookieManagerImpl.kt b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/CookieManagerImpl.kt new file mode 100644 index 0000000000..af4340ea7d --- /dev/null +++ b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/CookieManagerImpl.kt @@ -0,0 +1,91 @@ +package xyz.nulldev.androidcompat.webkit + +import android.webkit.CookieManager +import android.webkit.ValueCallback +import android.webkit.WebView +import java.net.CookieHandler +import java.net.HttpCookie +import java.net.URI + +@Suppress("DEPRECATION") +class CookieManagerImpl : CookieManager() { + private val cookieHandler = CookieHandler.getDefault() as java.net.CookieManager + private var acceptCookie = true + private var acceptThirdPartyCookies = true + private var allowFileSchemeCookies = false + + override fun setAcceptCookie(accept: Boolean) { + acceptCookie = accept + } + + override fun acceptCookie(): Boolean { + return acceptCookie + } + + override fun setAcceptThirdPartyCookies(webview: WebView?, accept: Boolean) { + acceptThirdPartyCookies = accept + } + + override fun acceptThirdPartyCookies(webview: WebView?): Boolean { + return acceptThirdPartyCookies + } + + override fun setCookie(url: String, value: String?) { + val uri = if (url.startsWith("http")) { + URI(url) + } else { + URI("http://$url") + } + + HttpCookie.parse(value).forEach { + cookieHandler.cookieStore.add(uri, it) + } + } + + override fun setCookie(url: String, value: String?, callback: ValueCallback?) { + setCookie(url, value) + callback?.onReceiveValue(true) + } + + override fun getCookie(url: String): String { + val uri = if (url.startsWith("http")) { + URI(url) + } else { + URI("http://$url") + } + return cookieHandler.cookieStore.get(uri) + .joinToString("; ") { "${it.name}=${it.value}" } + } + + @Deprecated("Deprecated in Java") + override fun removeSessionCookie() {} + + override fun removeSessionCookies(callback: ValueCallback?) {} + + @Deprecated("Deprecated in Java") + override fun removeExpiredCookie() {} + + @Deprecated("Deprecated in Java") + override fun removeAllCookie() { + cookieHandler.cookieStore.removeAll() + } + + override fun removeAllCookies(callback: ValueCallback?) { + val removedCookies = cookieHandler.cookieStore.removeAll() + callback?.onReceiveValue(removedCookies) + } + + override fun hasCookies(): Boolean { + return cookieHandler.cookieStore.cookies.isNotEmpty() + } + + override fun flush() {} + + override fun allowFileSchemeCookiesImpl(): Boolean { + return allowFileSchemeCookies + } + + override fun setAcceptFileSchemeCookiesImpl(accept: Boolean) { + allowFileSchemeCookies = acceptCookie + } +} diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt index 97ce5ce67c..068d125a9c 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -21,6 +21,9 @@ import mu.KotlinLogging import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import suwayomi.tachidesk.server.serverConfig +import java.net.CookieHandler +import java.net.CookieManager +import java.net.CookiePolicy import java.util.concurrent.TimeUnit @Suppress("UNUSED_PARAMETER") @@ -32,12 +35,19 @@ class NetworkHelper(context: Context) { // private val cacheSize = 5L * 1024 * 1024 // 5 MiB - val cookieManager = PersistentCookieJar(context) + // Tachidesk --> + val cookieStore = PersistentCookieStore(context) + init { + CookieHandler.setDefault( + CookieManager(cookieStore, CookiePolicy.ACCEPT_ALL) + ) + } + // Tachidesk <-- private val baseClientBuilder: OkHttpClient.Builder get() { val builder = OkHttpClient.Builder() - .cookieJar(cookieManager) + .cookieJar(PersistentCookieJar(cookieStore)) .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .callTimeout(2, TimeUnit.MINUTES) @@ -72,9 +82,4 @@ class NetworkHelper(context: Context) { .addInterceptor(CloudflareInterceptor()) .build() } - - // Tachidesk --> - val cookies: PersistentCookieStore - get() = cookieManager.store - // Tachidesk <-- } diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/PersistentCookieJar.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/PersistentCookieJar.kt index 2daddab186..b9cd0245a1 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/network/PersistentCookieJar.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/PersistentCookieJar.kt @@ -1,14 +1,11 @@ package eu.kanade.tachiyomi.network -import android.content.Context import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl // from TachiWeb-Server -class PersistentCookieJar(context: Context) : CookieJar { - - val store = PersistentCookieStore(context) +class PersistentCookieJar(private val store: PersistentCookieStore) : CookieJar { override fun saveFromResponse(url: HttpUrl, cookies: List) { store.addAll(url, cookies) diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/PersistentCookieStore.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/PersistentCookieStore.kt index 20e26e8825..bf63d47e72 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/network/PersistentCookieStore.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/PersistentCookieStore.kt @@ -4,15 +4,23 @@ import android.content.Context import okhttp3.Cookie import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okio.withLock +import java.net.CookieStore +import java.net.HttpCookie import java.net.URI import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.ReentrantLock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds // from TachiWeb-Server -class PersistentCookieStore(context: Context) { +class PersistentCookieStore(context: Context) : CookieStore { private val cookieMap = ConcurrentHashMap>() private val prefs = context.getSharedPreferences("cookie_store", Context.MODE_PRIVATE) + private val lock = ReentrantLock() + init { for ((key, value) in prefs.all) { @Suppress("UNCHECKED_CAST") @@ -30,50 +38,153 @@ class PersistentCookieStore(context: Context) { } } - @Synchronized fun addAll(url: HttpUrl, cookies: List) { - val key = url.toUri().host - - // Append or replace the cookies for this domain. - val cookiesForDomain = cookieMap[key].orEmpty().toMutableList() - for (cookie in cookies) { - // Find a cookie with the same name. Replace it if found, otherwise add a new one. - val pos = cookiesForDomain.indexOfFirst { it.name == cookie.name } - if (pos == -1) { - cookiesForDomain.add(cookie) - } else { - cookiesForDomain[pos] = cookie - } - } - cookieMap.put(key, cookiesForDomain) + lock.withLock { + val uri = url.toUri() - // Get cookies to be stored in disk - val newValues = cookiesForDomain.asSequence() - .filter { it.persistent && !it.hasExpired() } - .map(Cookie::toString) - .toSet() + // Append or replace the cookies for this domain. + val cookiesForDomain = cookieMap[uri.host].orEmpty().toMutableList() + for (cookie in cookies) { + // Find a cookie with the same name. Replace it if found, otherwise add a new one. + val pos = cookiesForDomain.indexOfFirst { it.name == cookie.name } + if (pos == -1) { + cookiesForDomain.add(cookie) + } else { + cookiesForDomain[pos] = cookie + } + } + cookieMap[uri.host] = cookiesForDomain - prefs.edit().putStringSet(key, newValues).apply() + saveToDisk(uri) + } } - @Synchronized - fun removeAll() { - prefs.edit().clear().apply() - cookieMap.clear() + override fun removeAll(): Boolean { + return lock.withLock { + val wasNotEmpty = cookieMap.isEmpty() + prefs.edit().clear().apply() + cookieMap.clear() + wasNotEmpty + } } fun remove(uri: URI) { - prefs.edit().remove(uri.host).apply() - cookieMap.remove(uri.host) + lock.withLock { + prefs.edit().remove(uri.host).apply() + cookieMap.remove(uri.host) + } + } + + override fun get(uri: URI): List = get(uri.host).map { + it.toHttpCookie() } fun get(url: HttpUrl) = get(url.toUri().host) - fun get(uri: URI) = get(uri.host) + override fun add(uri: URI?, cookie: HttpCookie) { + @Suppress("NAME_SHADOWING") + val uri = uri ?: URI("http://" + cookie.domain.removePrefix(".")) + lock.withLock { + val cookies = cookieMap[uri.host] + cookieMap[uri.host] = cookies.orEmpty() + cookie.toCookie(uri) + saveToDisk(uri) + } + } + + override fun getCookies(): List { + return cookieMap.values.flatMap { + it.map { + it.toHttpCookie() + } + } + } + + override fun getURIs(): List { + return cookieMap.keys().toList().map { + URI("http://$it") + } + } + + override fun remove(uri: URI?, cookie: HttpCookie): Boolean { + @Suppress("NAME_SHADOWING") + val uri = uri ?: URI("http://" + cookie.domain.removePrefix(".")) + return lock.withLock { + val cookies = cookieMap[uri.host].orEmpty() + val index = cookies.indexOfFirst { + it.name == cookie.name && + it.path == cookie.path + } + if (index >= 0) { + val newList = cookies.toMutableList() + newList.removeAt(index) + cookieMap[uri.host] = newList.toList() + saveToDisk(uri) + true + } else { + false + } + } + } private fun get(url: String): List { return cookieMap[url].orEmpty().filter { !it.hasExpired() } } + private fun saveToDisk(uri: URI) { + // Get cookies to be stored in disk + val newValues = cookieMap[uri.host] + .orEmpty() + .asSequence() + .filter { it.persistent && !it.hasExpired() } + .map(Cookie::toString) + .toSet() + + prefs.edit().putStringSet(uri.host, newValues).apply() + } + private fun Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt + + private fun HttpCookie.toCookie(uri: URI) = Cookie.Builder() + .name(name) + .value(value) + .domain(uri.host) + .path(path ?: "/") + .let { + if (maxAge != -1L) { + it.expiresAt(System.currentTimeMillis() + maxAge.seconds.inWholeMilliseconds) + } else { + it.expiresAt(Long.MAX_VALUE) + } + } + .let { + if (secure) { + it.secure() + } else { + it + } + } + .let { + if (isHttpOnly) { + it.httpOnly() + } else { + it + } + } + .build() + + private fun Cookie.toHttpCookie(): HttpCookie { + val it = this + return HttpCookie(it.name, it.value).apply { + domain = it.domain + path = it.path + secure = it.secure + maxAge = if (it.persistent) { + -1 + } else { + (it.expiresAt.milliseconds - System.currentTimeMillis().milliseconds).inWholeSeconds + } + + isHttpOnly = it.httpOnly + } + } } diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt index 2c7222e227..9960720de6 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt @@ -44,7 +44,7 @@ class CloudflareInterceptor : Interceptor { return try { originalResponse.close() - network.cookies.remove(originalRequest.url.toUri()) + network.cookieStore.remove(originalRequest.url.toUri()) val request = resolveWithWebView(originalRequest) @@ -105,7 +105,7 @@ object CFClearance { // Copy cookies to cookie store cookies.groupBy { it.domain }.forEach { (domain, cookies) -> - network.cookies.addAll( + network.cookieStore.addAll( url = HttpUrl.Builder() .scheme("http") .host(domain)