diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt new file mode 100644 index 00000000..6ebb27a4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt @@ -0,0 +1,176 @@ +package com.m57.hermescontrol.data.remote + +import com.m57.hermescontrol.data.local.AuthManager +import okhttp3.Request +import java.io.IOException +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.regex.Pattern + +/** + * Client for the gateway's managed-files download endpoint + * (`GET /api/files/download?path=&token=`), which streams the raw + * bytes of any file that lives on the *gateway* host (images, audio, video, + * CSV, PDF, arbitrary attachments). + * + * This is the mobile equivalent of the desktop app's + * `mediaExternalUrl()` (`apps/desktop/src/lib/media.ts`): a gateway-local + * path is rewritten into an authenticated URL the client can fetch over HTTP. + * Unlike a host-local file read it works on a **remote phone** too. + * + * The endpoint is gated by the same gateway session token (passed as the + * `?token=` query param, exactly like the WebSocket auth), and server-side + * guards apply: path resolution (`_resolve_managed_path`), a sensitive-path + * denylist (403), and a size cap (`_MANAGED_FILE_MAX_BYTES`, 413). + * + * Mobile-only, backend untouched. + */ +object GatewayFileClient { + private const val DOWNLOAD_PATH = "/api/files/download" + + /** + * Pure builder for the authenticated download URL. + * + * @return the full URL, or `null` if [baseUrl]/[token] are blank or + * [path] is not an absolute (or `~/`) host path. + */ + fun buildDownloadUrl( + baseUrl: String, + token: String, + path: String, + ): String? { + val trimmedBase = baseUrl.trimEnd('/') + if (trimmedBase.isBlank() || token.isBlank()) return null + val norm = normalizePath(path) ?: return null + val encPath = URLEncoder.encode(norm, StandardCharsets.UTF_8.name()).replace("+", "%20") + val encToken = URLEncoder.encode(token, StandardCharsets.UTF_8.name()).replace("+", "%20") + return "$trimmedBase$DOWNLOAD_PATH?path=$encPath&token=$encToken" + } + + /** + * Strip surrounding quotes/backticks, expand a leading `~`, and require an + * absolute path (`/...` or `X:\...`/`X:/...`). Returns `null` for paths + * that are not resolvable on the gateway host. + */ + internal fun normalizePath(raw: String): String? { + val trimmed = + raw + .trim() + .removeSurrounding("`") + .removeSurrounding("\"") + .removeSurrounding("'") + val expanded = + if (trimmed.startsWith("~")) { + val home = System.getenv("HOME") ?: return null + home + trimmed.removePrefix("~") + } else { + trimmed + } + if (!expanded.startsWith("/") && + !Pattern.compile("^[A-Za-z]:[/\\\\]").matcher(expanded).find() + ) { + return null + } + return expanded + } + + /** Map an HTTP status to a non-success result; `null` means "let the + * caller treat the body as a successful file." */ + internal fun classifyStatus(code: Int): GatewayFileResult? = + when (code) { + 401 -> GatewayFileResult.Unauthorized + 403 -> GatewayFileResult.Forbidden + 404 -> GatewayFileResult.NotFound + 413 -> GatewayFileResult.TooLarge + else -> null + } + + /** Fetch a gateway-hosted file using the current [AuthManager] credentials. */ + suspend fun fetch(path: String): GatewayFileResult = + fetch(path, AuthManager.getBaseUrl(), AuthManager.getToken().orEmpty()) + + /** Fetch a gateway-hosted file with explicit credentials (testable). */ + suspend fun fetch( + path: String, + baseUrl: String, + token: String, + ): GatewayFileResult { + val url = + buildDownloadUrl(baseUrl, token, path) + ?: return GatewayFileResult.Failure(IllegalArgumentException("not an absolute gateway path: $path")) + return try { + val request = Request.Builder().url(url).build() + OkHttpProvider.base.newCall(request).execute().use { resp -> + classifyStatus(resp.code)?.let { return it } + if (!resp.isSuccessful) { + return GatewayFileResult.Failure(IOException("HTTP ${resp.code}")) + } + val body = resp.body.bytes() + val name = + resp.header("Content-Disposition")?.let { parseFilename(it) } + ?: fileNameFromPath(path) + val mime = resp.header("Content-Type") ?: "application/octet-stream" + GatewayFileResult.Success(GatewayFile(name, mime, body)) + } + } catch (e: Throwable) { + GatewayFileResult.Failure(e) + } + } + + /** Best-effort filename pull from a `Content-Disposition: ...; filename="x"` + * or `filename*=UTF-8''` header. The captured value is URL-decoded + * so `filename*=UTF-8''a%20b.png` yields `a b.png`. */ + internal fun parseFilename(header: String): String? { + val m = FILENAME_RE.find(header) ?: return null + val raw = m.groupValues[1].takeIf { it.isNotBlank() } ?: return null + return runCatching { URLDecoder.decode(raw, StandardCharsets.UTF_8.name()) }.getOrDefault(raw) + } + + private fun fileNameFromPath(path: String): String = + path + .split('/', '\\') + .lastOrNull() + ?.takeIf { it.isNotBlank() } ?: "file" + + private val FILENAME_RE = Regex("""filename\*?=(?:UTF-8'')?"?([^";]+)"?""", RegexOption.IGNORE_CASE) +} + +/** A file fetched from the gateway. */ +data class GatewayFile( + val name: String, + val mimeType: String, + val bytes: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is GatewayFile) return false + return name == other.name && mimeType == other.mimeType && bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int { + var r = name.hashCode() + r = 31 * r + mimeType.hashCode() + r = 31 * r + bytes.contentHashCode() + return r + } +} + +/** Outcome of [GatewayFileClient.fetch]. */ +sealed interface GatewayFileResult { + data class Success( + val file: GatewayFile, + ) : GatewayFileResult + + data object NotFound : GatewayFileResult // 404 — file missing on gateway + + data object Forbidden : GatewayFileResult // 403 — sensitive path denied + + data object TooLarge : GatewayFileResult // 413 — exceeds managed-file cap + + data object Unauthorized : GatewayFileResult // 401 — bad/expired token + + data class Failure( + val throwable: Throwable, + ) : GatewayFileResult // network / unexpected +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt new file mode 100644 index 00000000..29c2a6c9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt @@ -0,0 +1,93 @@ +package com.m57.hermescontrol.ui.chat + +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +/** + * File-kind classification for gateway-hosted files, mirroring the desktop app's + * `MEDIA_BY_EXT` table (`apps/desktop/src/lib/media.ts`). Lets UI code decide + * how to present a downloaded file — inline image, audio player, video player, + * or a generic file card — without re-deriving the mapping at every call site. + * + * Extend the table (not the call sites) when new extensions are needed. + */ +enum class MediaKind { + IMAGE, + AUDIO, + VIDEO, + FILE, +} + +private val MEDIA_BY_EXT: Map> = + mapOf( + "avi" to (MediaKind.VIDEO to "video/x-msvideo"), + "bmp" to (MediaKind.IMAGE to "image/bmp"), + "csv" to (MediaKind.FILE to "text/csv"), + "flac" to (MediaKind.AUDIO to "audio/flac"), + "gif" to (MediaKind.IMAGE to "image/gif"), + "jpeg" to (MediaKind.IMAGE to "image/jpeg"), + "jpg" to (MediaKind.IMAGE to "image/jpeg"), + "m4a" to (MediaKind.AUDIO to "audio/mp4"), + "mkv" to (MediaKind.VIDEO to "video/x-matroska"), + "mov" to (MediaKind.VIDEO to "video/quicktime"), + "mp3" to (MediaKind.AUDIO to "audio/mpeg"), + "mp4" to (MediaKind.VIDEO to "video/mp4"), + "ogg" to (MediaKind.AUDIO to "audio/ogg"), + "opus" to (MediaKind.AUDIO to "audio/ogg; codecs=opus"), + "pdf" to (MediaKind.FILE to "application/pdf"), + "png" to (MediaKind.IMAGE to "image/png"), + "svg" to (MediaKind.IMAGE to "image/svg+xml"), + "wav" to (MediaKind.AUDIO to "audio/wav"), + "webm" to (MediaKind.VIDEO to "video/webm"), + "webp" to (MediaKind.IMAGE to "image/webp"), + ) + +/** Classify a path/URL by extension. Unknown extensions fall back to [MediaKind.FILE]. */ +fun mediaKindForPath(path: String): MediaKind { + val ext = + path + .split('?', limit = 2)[0] + .split('.') + .lastOrNull() + ?.lowercase() + .orEmpty() + return MEDIA_BY_EXT[ext]?.first ?: MediaKind.FILE +} + +/** Best-guess MIME type for a path/URL by extension. Falls back to octet-stream. */ +fun mediaMimeForPath(path: String): String { + val ext = + path + .split('?', limit = 2)[0] + .split('.') + .lastOrNull() + ?.lowercase() + .orEmpty() + return MEDIA_BY_EXT[ext]?.second ?: "application/octet-stream" +} + +/** Trailing filename from a path or URL. + * + * Handles both a bare gateway path (`/tmp/report.pdf`) and a full + * `/api/files/download?path=` URL, where the real filename lives in the + * percent-encoded `path=` query parameter. */ +fun mediaNameFromPath(path: String): String { + val queryPathMatch = Regex("""[?&]path=([^&]+)""", RegexOption.IGNORE_CASE).find(path) + if (queryPathMatch != null) { + val targetPath = + runCatching { + URLDecoder.decode(queryPathMatch.groupValues[1], StandardCharsets.UTF_8.name()) + }.getOrDefault(queryPathMatch.groupValues[1]) + targetPath.split('/', '\\').lastOrNull { it.isNotBlank() }?.let { return it } + } + val cleanPath = path.split('?', limit = 2)[0] + return runCatching { + java.net + .URI(cleanPath) + .path + .split('/') + .lastOrNull { it.isNotBlank() } + }.getOrNull() + ?: cleanPath.split('/', '\\').lastOrNull { it.isNotBlank() } + ?: path +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt new file mode 100644 index 00000000..25b701d3 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt @@ -0,0 +1,78 @@ +package com.m57.hermescontrol.data.remote + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayFileClientTest { + private val base = "https://gw.example.com:9119" + private val tok = "s e/cret" + + @Test + fun `buildDownloadUrl encodes path and token`() { + val url = GatewayFileClient.buildDownloadUrl(base, tok, "/tmp/foo.png")!! + assertEquals( + "$base/api/files/download?path=%2Ftmp%2Ffoo.png&token=${java.net.URLEncoder.encode( + tok, + "UTF-8", + ).replace("+", "%20")}", + url, + ) + assertTrue(url.contains("path=%2Ftmp%2Ffoo.png")) + assertTrue(url.contains("token=")) + } + + @Test + fun `buildDownloadUrl rejects blank base or token`() { + assertNull(GatewayFileClient.buildDownloadUrl("", tok, "/tmp/x.png")) + assertNull(GatewayFileClient.buildDownloadUrl(base, "", "/tmp/x.png")) + } + + @Test + fun `buildDownloadUrl rejects relative paths`() { + assertNull(GatewayFileClient.buildDownloadUrl(base, tok, "relative/path.png")) + assertNull(GatewayFileClient.buildDownloadUrl(base, tok, "MEDIA:relative.png")) + } + + @Test + fun `buildDownloadUrl handles quoted and spaced paths`() { + val url = GatewayFileClient.buildDownloadUrl(base, tok, "\"/tmp/a b.png\"")!! + assertTrue(url.contains("path=%2Ftmp%2Fa%20b.png")) + assertFalse(url.contains("MEDIA:")) + } + + @Test + fun `normalizePath expands tilde`() { + val home = System.getenv("HOME") ?: "/home/test" + assertEquals("$home/foo.png", GatewayFileClient.normalizePath("~/foo.png")) + } + + @Test + fun `normalizePath strips surrounding quotes`() { + assertEquals("/tmp/x.png", GatewayFileClient.normalizePath("'/tmp/x.png'")) + assertEquals("/tmp/x.png", GatewayFileClient.normalizePath("`/tmp/x.png`")) + } + + @Test + fun `normalizePath requires absolute path`() { + assertNull(GatewayFileClient.normalizePath("relative.png")) + } + + @Test + fun `classifyStatus maps known codes`() { + assertEquals(GatewayFileResult.NotFound, GatewayFileClient.classifyStatus(404)) + assertEquals(GatewayFileResult.Forbidden, GatewayFileClient.classifyStatus(403)) + assertEquals(GatewayFileResult.TooLarge, GatewayFileClient.classifyStatus(413)) + assertEquals(GatewayFileResult.Unauthorized, GatewayFileClient.classifyStatus(401)) + assertNull(GatewayFileClient.classifyStatus(200)) + assertNull(GatewayFileClient.classifyStatus(500)) + } + + @Test + fun `parseFilename extracts from content-disposition`() { + assertEquals("report.pdf", GatewayFileClient.parseFilename("attachment; filename=\"report.pdf\"")) + assertEquals("a b.png", GatewayFileClient.parseFilename("inline; filename*=UTF-8''a%20b.png")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaTypesTest.kt new file mode 100644 index 00000000..7060ef35 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaTypesTest.kt @@ -0,0 +1,38 @@ +package com.m57.hermescontrol.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MediaTypesTest { + @Test + fun `classifies known image extensions`() { + assertEquals(MediaKind.IMAGE, mediaKindForPath("/tmp/foo.png")) + assertEquals(MediaKind.IMAGE, mediaKindForPath("a/b.jpg")) + assertEquals(MediaKind.IMAGE, mediaKindForPath("x.WEBP")) + } + + @Test + fun `classifies audio and video`() { + assertEquals(MediaKind.AUDIO, mediaKindForPath("/tmp/clip.mp3")) + assertEquals(MediaKind.VIDEO, mediaKindForPath("/tmp/mov.mkv")) + } + + @Test + fun `unknown extension falls back to file`() { + assertEquals(MediaKind.FILE, mediaKindForPath("/tmp/notes.txt")) + assertEquals(MediaKind.FILE, mediaKindForPath("/tmp/noext")) + } + + @Test + fun `mime lookup matches kind`() { + assertEquals("image/png", mediaMimeForPath("/tmp/x.png")) + assertEquals("audio/mpeg", mediaMimeForPath("/tmp/x.mp3")) + assertEquals("application/octet-stream", mediaMimeForPath("/tmp/x.weird")) + } + + @Test + fun `name extraction handles urls and paths`() { + assertEquals("report.pdf", mediaNameFromPath("https://gw/api/files/download?path=%2Ftmp%2Freport.pdf")) + assertEquals("a.png", mediaNameFromPath("/home/u/a.png")) + } +}