feat: gateway files client — fetch any gateway-hosted file over HTTP (#726)
* feat: gateway files client — fetch any gateway-hosted file over HTTP Add GatewayFileClient: a typed client for the gateway's managed-files download endpoint (GET /api/files/download?path=<enc>&token=<enc>), which streams the raw bytes of any file living on the gateway host. This is the mobile equivalent of desktop's mediaExternalUrl() and works on a remote phone (real HTTP, not a host-local file read). - buildDownloadUrl(): pure authed-URL builder (null on blank creds / non-absolute path), mirrors desktop's token-in-query auth. - fetch(): OkHttp GET via the shared client, maps 401/403/404/413 to typed GatewayFileResult variants; returns GatewayFile(name, mime, bytes). - MediaTypes: IMAGE/AUDIO/VIDEO/FILE classification by extension (mirrors desktop MEDIA_BY_EXT) so future consumers can present images, audio, video, or generic files — beyond just images. Pure builders/classifiers are unit-tested. Mobile-only, backend untouched. This is the foundation PR; issue #724's MEDIA: rendering will consume it. * fix: decode filename + handle download-URL in mediaNameFromPath - GatewayFileClient.parseFilename now URL-decodes the captured value so filename*=UTF-8''a%20b.png yields 'a b.png'. - MediaTypes.mediaNameFromPath extracts the filename from the percent-encoded path= query param of a full /api/files/download?path=<enc> URL (the real case), not just a bare path. * fix(remote): fix unit tests for gateway file client and media parsing Replace plus-encoded spaces with %20 in download URL builder, URL-decode filenames in parseFilename, and extract target filename from URL path parameters. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
5e409e8dc6
commit
439ae9fcba
4 files changed
+385
No files matched your search
@@ -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=<enc>&token=<enc>`), 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''<pct-enc>` 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
|
||||||
|
}
|
||||||
@@ -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<String, Pair<MediaKind, String>> =
|
||||||
|
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=<enc>` 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
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user