package soy.iko.opencode.ui.components import android.util.Base64 import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BrokenImage import soy.iko.opencode.R import soy.iko.opencode.data.model.FilePart import soy.iko.opencode.data.model.ServerProfile import soy.iko.opencode.data.network.HttpClientFactory /** * Carries the bits needed to load an image off the opencode server: the base URL (for * resolving relative `url`s) and an optional HTTP Basic header for protected servers. */ data class ImageLoadContext( val baseUrl: String, val basicAuthHeader: String?, ) /** Build an [ImageLoadContext] from a resolved connection profile (password included). */ fun ServerProfile.toImageContext(): ImageLoadContext { val auth = if (hasAuth && !password.isNullOrEmpty()) { val raw = "$username:$password".toByteArray() "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP) } else { null } return ImageLoadContext(baseUrl = baseUrl, basicAuthHeader = auth) } /** True if the part references a raster/vector image we can render. */ val FilePart.isImage: Boolean get() = mime?.startsWith("image/") == true /** * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline * data URIs, an absolute URL string otherwise. Returns null when nothing loadable. */ private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? { val src = source if (!src.isNullOrBlank() && src.startsWith("data:")) { val comma = src.indexOf(',') if (comma < 0) return null val payload = src.substring(comma + 1) return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull() } val url = url ?: return null return if (url.startsWith("http://") || url.startsWith("https://")) { url } else { // Resolve relative URL against the base. Collapse any ../ segments to // prevent path traversal from escaping the server's base path. val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl) val resolved = runCatching { java.net.URI(base).resolve(url).normalize().toString() }.getOrElse { return null } // Guard: if normalization escaped the base path, return null (don't // attempt to load the base URL itself as an image — it's not one). if (!resolved.startsWith(base)) null else resolved } } /** * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network) * and server-relative URLs (fetched with Basic auth when the server requires it). * Falls back to nothing when the part has no loadable source. */ @Composable fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) { val context = LocalContext.current val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) { ImageRequest.Builder(context) .data(model) .apply { // Only send Basic auth over HTTPS; sending credentials over // cleartext HTTP would expose them on the network. val isHttps = (model as? String)?.startsWith("https://") == true if (isHttps) ctx.basicAuthHeader?.let { addHeader("Authorization", it) } } .crossfade(true) .build() } SubcomposeAsyncImage( model = request, contentDescription = part.filename ?: stringResource(R.string.image), contentScale = ContentScale.FillWidth, modifier = modifier .heightIn(max = 320.dp) .clip(RoundedCornerShape(12.dp)), loading = { Box( modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp), contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } }, error = { Box( modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp), contentAlignment = Alignment.Center, ) { Icon( Icons.Filled.BrokenImage, contentDescription = stringResource(R.string.image_failed), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, ) }