From c6dfeda1620cce6ec0f9a7e13479591b90360ec4 Mon Sep 17 00:00:00 2001 From: Niko Diamadis Date: Tue, 18 Jun 2024 21:09:39 +0200 Subject: [PATCH 1/4] Add basic auth support for images via URL --- .../kotlin/com/github/gotify/CoilInstance.kt | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/github/gotify/CoilInstance.kt b/app/src/main/kotlin/com/github/gotify/CoilInstance.kt index 611e6fbd..4f896000 100644 --- a/app/src/main/kotlin/com/github/gotify/CoilInstance.kt +++ b/app/src/main/kotlin/com/github/gotify/CoilInstance.kt @@ -13,7 +13,12 @@ import coil.request.ImageRequest import com.github.gotify.api.CertUtils import com.github.gotify.client.model.Application import java.io.IOException +import okhttp3.Authenticator +import okhttp3.Credentials import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route import org.tinylog.kotlin.Logger object CoilInstance { @@ -69,7 +74,9 @@ object CoilInstance { context: Context, sslSettings: SSLSettings ): Pair { - val builder = OkHttpClient.Builder() + val builder = OkHttpClient + .Builder() + .authenticator(BasicAuthAuthenticator()) CertUtils.applySslSettings(builder, sslSettings) val loader = ImageLoader.Builder(context) .okHttpClient(builder.build()) @@ -85,3 +92,22 @@ object CoilInstance { return sslSettings to loader } } + +private class BasicAuthAuthenticator : Authenticator { + override fun authenticate(route: Route?, response: Response): Request? { + // If there's no username, skip the authenticator + if (response.request.url.username.isEmpty()) return null + + val basicAuthString = "${response.request.url.username}:${response.request.url.password}@" + val url = response.request.url.toString().replace(basicAuthString, "") + return response + .request + .newBuilder() + .header( + "Authorization", + Credentials.basic(response.request.url.username, response.request.url.password) + ) + .url(url) + .build() + } +} From 7d6399b087a54702842a7953a055de8c939590e8 Mon Sep 17 00:00:00 2001 From: Niko Diamadis Date: Thu, 20 Jun 2024 10:08:36 +0200 Subject: [PATCH 2/4] Replace image basic auth authenticator with interceptor --- .../kotlin/com/github/gotify/CoilInstance.kt | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/app/src/main/kotlin/com/github/gotify/CoilInstance.kt b/app/src/main/kotlin/com/github/gotify/CoilInstance.kt index 4f896000..2af0de5c 100644 --- a/app/src/main/kotlin/com/github/gotify/CoilInstance.kt +++ b/app/src/main/kotlin/com/github/gotify/CoilInstance.kt @@ -13,12 +13,10 @@ import coil.request.ImageRequest import com.github.gotify.api.CertUtils import com.github.gotify.client.model.Application import java.io.IOException -import okhttp3.Authenticator import okhttp3.Credentials +import okhttp3.Interceptor import okhttp3.OkHttpClient -import okhttp3.Request import okhttp3.Response -import okhttp3.Route import org.tinylog.kotlin.Logger object CoilInstance { @@ -76,7 +74,7 @@ object CoilInstance { ): Pair { val builder = OkHttpClient .Builder() - .authenticator(BasicAuthAuthenticator()) + .addInterceptor(BasicAuthInterceptor()) CertUtils.applySslSettings(builder, sslSettings) val loader = ImageLoader.Builder(context) .okHttpClient(builder.build()) @@ -93,21 +91,23 @@ object CoilInstance { } } -private class BasicAuthAuthenticator : Authenticator { - override fun authenticate(route: Route?, response: Response): Request? { - // If there's no username, skip the authenticator - if (response.request.url.username.isEmpty()) return null +private class BasicAuthInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + var request = chain.request() - val basicAuthString = "${response.request.url.username}:${response.request.url.password}@" - val url = response.request.url.toString().replace(basicAuthString, "") - return response - .request - .newBuilder() - .header( - "Authorization", - Credentials.basic(response.request.url.username, response.request.url.password) - ) - .url(url) - .build() + // If there's no username, skip the authentication + if (request.url.username.isNotEmpty()) { + val basicAuthString = "${request.url.username}:${request.url.password}@" + val url = request.url.toString().replace(basicAuthString, "") + request = request + .newBuilder() + .header( + "Authorization", + Credentials.basic(request.url.username, request.url.password) + ) + .url(url) + .build() + } + return chain.proceed(request) } } From 337af76b58b633e3aa057549c4b097f62f2974e7 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sat, 22 Jun 2024 13:21:09 +0200 Subject: [PATCH 3/4] fix: log image load errors and show placeholder on error --- .../kotlin/com/github/gotify/CoilInstance.kt | 41 ++++++++++++------- .../com/github/gotify/MarkwonFactory.kt | 29 ++++++++++++- .../main/kotlin/com/github/gotify/Utils.kt | 10 +++++ 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/app/src/main/kotlin/com/github/gotify/CoilInstance.kt b/app/src/main/kotlin/com/github/gotify/CoilInstance.kt index 2af0de5c..9c0dd782 100644 --- a/app/src/main/kotlin/com/github/gotify/CoilInstance.kt +++ b/app/src/main/kotlin/com/github/gotify/CoilInstance.kt @@ -3,13 +3,17 @@ package com.github.gotify import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.graphics.drawable.BitmapDrawable +import androidx.annotation.DrawableRes +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.graphics.drawable.toBitmap import coil.ImageLoader import coil.annotation.ExperimentalCoilApi import coil.decode.SvgDecoder import coil.disk.DiskCache import coil.executeBlocking +import coil.request.ErrorResult import coil.request.ImageRequest +import coil.request.SuccessResult import com.github.gotify.api.CertUtils import com.github.gotify.client.model.Application import java.io.IOException @@ -23,11 +27,22 @@ object CoilInstance { private var holder: Pair? = null @Throws(IOException::class) - fun getImageFromUrl(context: Context, url: String?): Bitmap { - val request = ImageRequest.Builder(context) - .data(url) - .build() - return (get(context).executeBlocking(request).drawable as BitmapDrawable).bitmap + fun getImageFromUrl( + context: Context, + url: String?, + @DrawableRes placeholder: Int = R.drawable.ic_placeholder + ): Bitmap { + val request = ImageRequest.Builder(context).data(url).build() + + return when (val result = get(context).executeBlocking(request)) { + is SuccessResult -> result.drawable.toBitmap() + is ErrorResult -> { + Logger.error( + result.throwable + ) { "Could not load image ${Utils.redactPassword(url)}" } + AppCompatResources.getDrawable(context, placeholder)!!.toBitmap() + } + } } fun getIcon(context: Context, app: Application?): Bitmap { @@ -35,15 +50,11 @@ object CoilInstance { return BitmapFactory.decodeResource(context.resources, R.drawable.gotify) } val baseUrl = Settings(context).url - try { - return getImageFromUrl( - context, - Utils.resolveAbsoluteUrl("$baseUrl/", app.image) - ) - } catch (e: IOException) { - Logger.error(e, "Could not load image for notification") - } - return BitmapFactory.decodeResource(context.resources, R.drawable.gotify) + return getImageFromUrl( + context, + Utils.resolveAbsoluteUrl("$baseUrl/", app.image), + R.drawable.gotify + ) } @OptIn(ExperimentalCoilApi::class) diff --git a/app/src/main/kotlin/com/github/gotify/MarkwonFactory.kt b/app/src/main/kotlin/com/github/gotify/MarkwonFactory.kt index 0aebc294..566d3fef 100644 --- a/app/src/main/kotlin/com/github/gotify/MarkwonFactory.kt +++ b/app/src/main/kotlin/com/github/gotify/MarkwonFactory.kt @@ -11,6 +11,8 @@ import android.text.style.StyleSpan import android.text.style.TypefaceSpan import androidx.core.content.ContextCompat import coil.ImageLoader +import coil.request.Disposable +import coil.request.ImageRequest import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon import io.noties.markwon.MarkwonSpansFactory @@ -22,6 +24,7 @@ import io.noties.markwon.core.MarkwonTheme import io.noties.markwon.ext.strikethrough.StrikethroughPlugin import io.noties.markwon.ext.tables.TableAwareMovementMethod import io.noties.markwon.ext.tables.TablePlugin +import io.noties.markwon.image.AsyncDrawable import io.noties.markwon.image.coil.CoilImagesPlugin import io.noties.markwon.movement.MovementMethodPlugin import org.commonmark.ext.gfm.tables.TableCell @@ -34,13 +37,37 @@ import org.commonmark.node.Link import org.commonmark.node.ListItem import org.commonmark.node.StrongEmphasis import org.commonmark.parser.Parser +import org.tinylog.kotlin.Logger internal object MarkwonFactory { fun createForMessage(context: Context, imageLoader: ImageLoader): Markwon { return Markwon.builder(context) .usePlugin(CorePlugin.create()) .usePlugin(MovementMethodPlugin.create(TableAwareMovementMethod.create())) - .usePlugin(CoilImagesPlugin.create(context, imageLoader)) + .usePlugin( + CoilImagesPlugin.create( + object : CoilImagesPlugin.CoilStore { + override fun load(drawable: AsyncDrawable): ImageRequest { + return ImageRequest.Builder(context) + .data(drawable.destination) + .placeholder(R.drawable.ic_placeholder) + .listener(onError = { _, err -> + Logger.error(err.throwable) { + "Could not load markdown image: ${Utils.redactPassword( + drawable.destination + )}" + } + }) + .build() + } + + override fun cancel(disposable: Disposable) { + disposable.dispose() + } + }, + imageLoader + ) + ) .usePlugin(StrikethroughPlugin.create()) .usePlugin(TablePlugin.create(context)) .usePlugin(object : AbstractMarkwonPlugin() { diff --git a/app/src/main/kotlin/com/github/gotify/Utils.kt b/app/src/main/kotlin/com/github/gotify/Utils.kt index 6330cca2..babb389c 100644 --- a/app/src/main/kotlin/com/github/gotify/Utils.kt +++ b/app/src/main/kotlin/com/github/gotify/Utils.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.threeten.bp.OffsetDateTime import org.tinylog.kotlin.Logger @@ -92,4 +93,13 @@ internal object Utils { context.getSystemService(ActivityManager::class.java).appTasks?.getOrNull(0) ?.setExcludeFromRecents(excludeFromRecent) } + + fun redactPassword(stringUrl: String?): String { + val url = stringUrl?.toHttpUrlOrNull() + return when { + url == null -> "unknown" + url.password.isEmpty() -> url.toString() + else -> url.newBuilder().password("REDACTED").toString() + } + } } From d741e678a85b31e91575ec1a19bd28b626852882 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 7 Jul 2024 09:05:58 +0200 Subject: [PATCH 4/4] refactor: remove user/pass replacement --- app/src/main/kotlin/com/github/gotify/CoilInstance.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/main/kotlin/com/github/gotify/CoilInstance.kt b/app/src/main/kotlin/com/github/gotify/CoilInstance.kt index 9c0dd782..21adaa6c 100644 --- a/app/src/main/kotlin/com/github/gotify/CoilInstance.kt +++ b/app/src/main/kotlin/com/github/gotify/CoilInstance.kt @@ -108,15 +108,12 @@ private class BasicAuthInterceptor : Interceptor { // If there's no username, skip the authentication if (request.url.username.isNotEmpty()) { - val basicAuthString = "${request.url.username}:${request.url.password}@" - val url = request.url.toString().replace(basicAuthString, "") request = request .newBuilder() .header( "Authorization", Credentials.basic(request.url.username, request.url.password) ) - .url(url) .build() } return chain.proceed(request)