Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`).
screens would show "Failed to load" (reliably on large responses,
intermittently at sign-in). Responses are now read off the main thread.

- **Bio editor image handling.** Uploading an image from the bio/description
editor now tags it correctly as a bio image and, when it fails, shows the
reason instead of failing silently. Uploaded images embed as a hosted
reference, and images served from the instance's file CDN are now labelled
"hosted" rather than "external" in the editor's image list.

## [1.2.1] - 2026-07-04

### Added
Expand Down
6 changes: 6 additions & 0 deletions sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import androidx.navigation.compose.*
import systems.lupine.sheaf.ui.auth.AuthViewModel
import systems.lupine.sheaf.ui.auth.LoginScreen
import systems.lupine.sheaf.ui.auth.OnboardingScreen
import systems.lupine.sheaf.ui.components.LocalFileCdnBase
import systems.lupine.sheaf.ui.debug.DebugScreen
import systems.lupine.sheaf.ui.groups.GroupDetailScreen
import systems.lupine.sheaf.ui.groups.GroupsScreen
Expand Down Expand Up @@ -151,6 +152,7 @@ fun SheafApp(
) {
val isLoggedIn by authViewModel.isLoggedIn.collectAsState()
val pendingRedeem by pendingRedemption.pending.collectAsState()
val fileCdnBase by authViewModel.fileCdnBase.collectAsState()
val navController = rememberNavController()

// React to login state changes
Expand Down Expand Up @@ -191,6 +193,9 @@ fun SheafApp(
val currentRoute = navBackStack?.destination?.route
val showBottomBar = currentRoute in topLevelDestinations.map { it.route }

// Provide the instance's file CDN base app-wide so image hosted/external
// classification (bio editor, journals) recognises CDN-served images.
CompositionLocalProvider(LocalFileCdnBase provides fileCdnBase) {
Scaffold(
bottomBar = {
if (showBottomBar) {
Expand Down Expand Up @@ -644,4 +649,5 @@ fun SheafApp(
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ class AuthViewModel @Inject constructor(
.map { it ?: "" }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "")

// The instance's file CDN base, persisted from the auth config. Surfaced so
// the app root can provide it to LocalFileCdnBase for hosted/external image
// classification.
val fileCdnBase: StateFlow<String?> = prefs.fileCdnBase
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)

@OptIn(ExperimentalCoroutinesApi::class)
val authConfig: StateFlow<AuthConfig?> = baseUrl
.filter { it.isNotBlank() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
Expand All @@ -20,23 +21,32 @@ import coil.compose.AsyncImage
// reader's IP to a third-party host.
data class ImageReference(val alt: String, val url: String, val hosted: Boolean)

// The instance's file CDN base (settings.s3_public_url, surfaced via the auth
// config's file_cdn_base and persisted in prefs). Provided once at the app root
// so the hosted/external classification can recognise CDN-served images without
// every caller threading it through. Null when the instance serves files from
// its own /v1/files/ path rather than a CDN.
val LocalFileCdnBase = staticCompositionLocalOf<String?> { null }

private val IMAGE_MD_REGEX = Regex("!\\[([^\\]]*)\\]\\(([^)\\s]+)(?:\\s+\"[^\"]*\")?\\)")

fun extractImageReferences(markdown: String): List<ImageReference> {
fun extractImageReferences(markdown: String, cdnBase: String? = null): List<ImageReference> {
if (markdown.isEmpty()) return emptyList()
return IMAGE_MD_REGEX.findAll(markdown).map { m ->
val alt = m.groupValues[1]
val url = m.groupValues[2]
ImageReference(alt = alt, url = url, hosted = isHostedImageUrl(url))
ImageReference(alt = alt, url = url, hosted = isHostedImageUrl(url, cdnBase))
}.toList()
}

// Heuristic: anything that looks like a /v1/files/ reference is hosted by the
// Sheaf instance, regardless of whether it's relative or absolute. Everything
// else is treated as external. Matches web's `src.startsWith("/v1/files/")`
// check with a slight relaxation to also catch absolute URLs that go to the
// instance's API base.
fun isHostedImageUrl(url: String): Boolean = url.contains("/v1/files/")
// Hosted = the Sheaf instance is serving the image, either from its own
// /v1/files/ path or from the configured file CDN (s3_public_url). Everything
// else is external. Mirrors web's isHostedImage(src, cdnBase): a /v1/files/
// reference OR a URL under the CDN base. Without this, CDN-served images (the
// default on the hosted instance) were misclassified as external.
fun isHostedImageUrl(url: String, cdnBase: String? = null): Boolean =
url.contains("/v1/files/") ||
(!cdnBase.isNullOrBlank() && url.startsWith("${cdnBase.trimEnd('/')}/"))

@Composable
fun ImageSourceBadge(hosted: Boolean, modifier: Modifier = Modifier) {
Expand All @@ -60,8 +70,12 @@ fun ImageSourceBadge(hosted: Boolean, modifier: Modifier = Modifier) {
// hosted/external badge. Renders nothing if no images are present, so it can
// be unconditionally inlined into compose screens.
@Composable
fun ImageReferencesPanel(markdown: String, modifier: Modifier = Modifier) {
val refs = extractImageReferences(markdown)
fun ImageReferencesPanel(
markdown: String,
modifier: Modifier = Modifier,
cdnBase: String? = LocalFileCdnBase.current,
) {
val refs = extractImageReferences(markdown, cdnBase)
if (refs.isEmpty()) return
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ data class MarkdownImagePicker(
val availableImages: List<FileRead>,
val isLoadingImages: Boolean,
val isUploadingImage: Boolean,
val uploadError: String?,
val pendingImageMarkdown: String?,
val onLoadAvailableImages: () -> Unit,
val onUploadImage: (Uri) -> Unit,
Expand Down Expand Up @@ -119,6 +120,13 @@ fun MarkdownBodyEditor(
)

if (imagePicker != null) {
imagePicker.uploadError?.let { err ->
Text(
err,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
ImageReferencesPanel(markdown = value)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,17 @@ class MarkdownImageDelegate @Inject constructor(
val ext = mimeType.substringAfter("/").let { if (it == "jpeg") "jpg" else it }
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", "image.$ext", body)
api.uploadFile(part)
api.uploadFile(part, purpose = "bio")
}
.onSuccess { resp ->
_state.update {
it.copy(
isUploadingImage = false,
pendingImageMarkdown = "![image](${resp.url})",
// Embed the canonical /v1/files/<key> path (matching the
// "existing image" picker and web), not the absolute
// response URL. The backend rewrites this ref to the real
// (CDN/signed) URL on read, and it classifies as hosted.
pendingImageMarkdown = "![image](/v1/files/${resp.key})",
)
}
}
Expand All @@ -98,13 +102,13 @@ class MarkdownImageDelegate @Inject constructor(
}

fun pickExistingFile(file: FileRead) {
_state.update { it.copy(pendingImageMarkdown = "![image](/v1/files/${file.key})") }
_state.update { it.copy(pendingImageMarkdown = "![image](/v1/files/${file.key})", error = null) }
}

fun pickExternalUrl(url: String) {
val trimmed = url.trim()
if (trimmed.isEmpty()) return
_state.update { it.copy(pendingImageMarkdown = "![image]($trimmed)") }
_state.update { it.copy(pendingImageMarkdown = "![image]($trimmed)", error = null) }
}

fun consumePendingImageMarkdown() {
Expand All @@ -130,6 +134,7 @@ fun rememberMarkdownImagePicker(
availableImages = s.availableImages,
isLoadingImages = s.isLoadingImages,
isUploadingImage = s.isUploadingImage,
uploadError = s.error,
pendingImageMarkdown = s.pendingImageMarkdown,
onLoadAvailableImages = { delegate.loadAvailableImages(scope) },
onUploadImage = { uri -> delegate.uploadImage(scope, uri) },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package systems.lupine.sheaf.ui.components

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class ImageSourceClassificationTest {

@Test fun `relative v1 files path is hosted`() {
assertTrue(isHostedImageUrl("/v1/files/bios/abc123.webp"))
}

@Test fun `signed v1 files path with query is hosted`() {
assertTrue(isHostedImageUrl("/v1/files/bios/abc123.webp?token=x&expires=1"))
}

@Test fun `absolute url through the instance api is hosted`() {
assertTrue(isHostedImageUrl("https://app.example.com/v1/files/bios/abc.webp"))
}

@Test fun `arbitrary external url is external without a cdn base`() {
assertFalse(isHostedImageUrl("https://imgur.com/cat.png"))
}

@Test fun `cdn-served image is hosted when it matches the cdn base`() {
// The default on the hosted instance: files resolve to the CDN, whose
// URLs never contain /v1/files/. Before this was recognised, they were
// misclassified as external.
assertTrue(isHostedImageUrl("https://cdn.example.com/bios/abc.webp", "https://cdn.example.com"))
}

@Test fun `cdn base with trailing slash still matches`() {
assertTrue(isHostedImageUrl("https://cdn.example.com/bios/abc.webp", "https://cdn.example.com/"))
}

@Test fun `url on a different host is external even with a cdn base set`() {
assertFalse(isHostedImageUrl("https://evil.example.net/bios/abc.webp", "https://cdn.example.com"))
}

@Test fun `a cdn-base prefix that is not a path boundary is not hosted`() {
// Guards against "https://cdn.example.com.evil.net/..." matching the
// base by bare startsWith; the trailing slash makes it a real boundary.
assertFalse(isHostedImageUrl("https://cdn.example.com.evil.net/x.png", "https://cdn.example.com"))
}

@Test fun `extract classifies each reference and honours the cdn base`() {
val md = """
![a](/v1/files/bios/one.webp)
![b](https://cdn.example.com/bios/two.webp)
![c](https://imgur.com/three.png)
""".trimIndent()
val refs = extractImageReferences(md, cdnBase = "https://cdn.example.com")
assertEquals(3, refs.size)
assertTrue(refs[0].hosted)
assertTrue(refs[1].hosted)
assertFalse(refs[2].hosted)
}
}
Loading