diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle
index cc92a3d..98cddb8 100644
--- a/app/android/app/build.gradle
+++ b/app/android/app/build.gradle
@@ -20,8 +20,8 @@ android {
applicationId "com.movix.app"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
- versionCode 12
- versionName "2.5.3"
+ versionCode 13
+ versionName "2.5.4"
buildConfigField "int", "VERSION_CODE_INT", "${versionCode}"
buildConfigField "String", "VERSION_NAME_STR", "\"${versionName}\""
@@ -66,16 +66,10 @@ android {
dependencies {
implementation("com.facebook.react:react-android")
implementation("com.facebook.react:hermes-android")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ testImplementation("junit:junit:4.13.2")
// Google Cast (Chromecast) — see app/src/main/java/com/movix/app/cast/
implementation("com.google.android.gms:play-services-cast-framework:21.5.0")
implementation("androidx.mediarouter:mediarouter:1.7.0")
}
-
-// androidx.legacy:legacy-support-core-utils:1.0.0 (tiré transitivement par
-// mediarouter → palette) embarque ses propres classes androidx.autofill.R$attr
-// qui entrent en collision avec le module androidx.autofill:autofill:1.1.0
-// utilisé par appcompat → "duplicate class" au mergeDexRelease.
-configurations.all {
- exclude group: 'androidx.legacy', module: 'legacy-support-core-utils'
-}
diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml
index 39e0e2d..e1117e2 100644
--- a/app/android/app/src/main/AndroidManifest.xml
+++ b/app/android/app/src/main/AndroidManifest.xml
@@ -6,6 +6,13 @@
+
+
+
+
+
+
+
-
-
+
+
diff --git a/app/android/app/src/main/java/com/movix/app/MainApplication.kt b/app/android/app/src/main/java/com/movix/app/MainApplication.kt
index 8ca5c99..f143252 100644
--- a/app/android/app/src/main/java/com/movix/app/MainApplication.kt
+++ b/app/android/app/src/main/java/com/movix/app/MainApplication.kt
@@ -8,6 +8,7 @@ import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.soloader.SoLoader
import com.movix.app.CastPackage
+import com.movix.app.proxy.MediaProxyPackage
import com.movix.app.update.UpdatePackage
class MainApplication : Application(), ReactApplication {
@@ -19,6 +20,7 @@ class MainApplication : Application(), ReactApplication {
add(DnsPackage())
add(UpdatePackage())
add(CastPackage())
+ add(MediaProxyPackage())
}
override fun getJSMainModuleName(): String = "index"
diff --git a/app/android/app/src/main/java/com/movix/app/proxy/MediaProxyModule.kt b/app/android/app/src/main/java/com/movix/app/proxy/MediaProxyModule.kt
new file mode 100644
index 0000000..434e02f
--- /dev/null
+++ b/app/android/app/src/main/java/com/movix/app/proxy/MediaProxyModule.kt
@@ -0,0 +1,79 @@
+package com.movix.app.proxy
+
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactContextBaseJavaModule
+import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.bridge.ReadableMap
+import com.facebook.react.bridge.ReadableType
+import java.util.concurrent.Executors
+
+class MediaProxyModule(
+ reactContext: ReactApplicationContext,
+) : ReactContextBaseJavaModule(reactContext) {
+ private val server = MediaProxyServer()
+ private val openExecutor = Executors.newFixedThreadPool(2) { task ->
+ Thread(task, "MovixMediaProxy-Open").apply { isDaemon = true }
+ }
+
+ override fun getName() = "MediaProxy"
+
+ @ReactMethod
+ fun open(
+ url: String,
+ method: String,
+ headers: ReadableMap,
+ promise: Promise,
+ ) {
+ openExecutor.execute {
+ try {
+ promise.resolve(
+ server.open(
+ upstreamUrl = url,
+ method = method,
+ headers = readableMapToStrings(headers),
+ ),
+ )
+ } catch (_: Throwable) {
+ promise.reject(
+ "MEDIA_PROXY_OPEN_FAILED",
+ "Local media proxy unavailable",
+ )
+ }
+ }
+ }
+
+ override fun invalidate() {
+ openExecutor.shutdownNow()
+ server.close()
+ super.invalidate()
+ }
+
+ private fun readableMapToStrings(input: ReadableMap): Map {
+ val result = linkedMapOf()
+ val iterator = input.keySetIterator()
+ while (iterator.hasNextKey() && result.size < MAX_HEADERS) {
+ val key = iterator.nextKey()
+ if (input.getType(key) != ReadableType.String) continue
+ val value = input.getString(key) ?: continue
+ if (
+ key.length > MAX_HEADER_NAME_LENGTH ||
+ value.length > MAX_HEADER_VALUE_LENGTH ||
+ key.contains('\r') ||
+ key.contains('\n') ||
+ value.contains('\r') ||
+ value.contains('\n')
+ ) {
+ continue
+ }
+ result[key] = value
+ }
+ return result
+ }
+
+ companion object {
+ private const val MAX_HEADERS = 32
+ private const val MAX_HEADER_NAME_LENGTH = 128
+ private const val MAX_HEADER_VALUE_LENGTH = 8_192
+ }
+}
diff --git a/app/android/app/src/main/java/com/movix/app/proxy/MediaProxyPackage.kt b/app/android/app/src/main/java/com/movix/app/proxy/MediaProxyPackage.kt
new file mode 100644
index 0000000..747e4c6
--- /dev/null
+++ b/app/android/app/src/main/java/com/movix/app/proxy/MediaProxyPackage.kt
@@ -0,0 +1,20 @@
+package com.movix.app.proxy
+
+import com.facebook.react.ReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.uimanager.ViewManager
+
+class MediaProxyPackage : ReactPackage {
+ override fun createNativeModules(
+ reactContext: ReactApplicationContext,
+ ): List {
+ return listOf(MediaProxyModule(reactContext))
+ }
+
+ override fun createViewManagers(
+ reactContext: ReactApplicationContext,
+ ): List> {
+ return emptyList()
+ }
+}
diff --git a/app/android/app/src/main/java/com/movix/app/proxy/MediaProxyPolicy.kt b/app/android/app/src/main/java/com/movix/app/proxy/MediaProxyPolicy.kt
new file mode 100644
index 0000000..d9aefb5
--- /dev/null
+++ b/app/android/app/src/main/java/com/movix/app/proxy/MediaProxyPolicy.kt
@@ -0,0 +1,179 @@
+package com.movix.app.proxy
+
+import java.net.Inet4Address
+import java.net.Inet6Address
+import java.net.InetAddress
+import java.net.URI
+import java.util.Locale
+
+object MediaProxyPolicy {
+ private const val MAX_URL_LENGTH = 16_384
+ private const val MAX_HEADER_VALUE_LENGTH = 8_192
+ private val tokenPattern = Regex("^[A-Za-z0-9_-]{8,128}$")
+ private val numericIpv4Pattern = Regex("^\\d{1,3}(?:\\.\\d{1,3}){3}$")
+ private val uriAttributePattern = Regex("""URI=(["'])(.*?)\1""", RegexOption.IGNORE_CASE)
+ private val allowedRequestHeaders = mapOf(
+ "accept" to "Accept",
+ "accept-language" to "Accept-Language",
+ "content-type" to "Content-Type",
+ "if-modified-since" to "If-Modified-Since",
+ "if-none-match" to "If-None-Match",
+ "origin" to "Origin",
+ "range" to "Range",
+ "referer" to "Referer",
+ "user-agent" to "User-Agent",
+ )
+ private val allowedLocalOverrideHeaders = setOf(
+ "accept",
+ "accept-language",
+ "if-modified-since",
+ "if-none-match",
+ "range",
+ )
+
+ fun validatePublicHttpsUrl(
+ rawUrl: String,
+ resolver: (String) -> List = {
+ InetAddress.getAllByName(it).toList()
+ },
+ ): URI {
+ val uri = validateHttpsUrlSyntax(rawUrl)
+ val host = requireNotNull(uri.host).lowercase(Locale.US)
+ val addresses = runCatching { resolver(host) }
+ .getOrElse { throw IllegalArgumentException("Upstream DNS failed") }
+ require(addresses.isNotEmpty()) { "Upstream DNS returned no address" }
+ require(addresses.none(::isForbiddenAddress)) {
+ "Private upstream is forbidden"
+ }
+ return uri
+ }
+
+ fun validateHttpsUrlSyntax(rawUrl: String): URI {
+ require(rawUrl.length in 1..MAX_URL_LENGTH) { "Invalid upstream URL" }
+ val uri = runCatching { URI(rawUrl) }
+ .getOrElse { throw IllegalArgumentException("Invalid upstream URL") }
+ require(uri.scheme?.lowercase(Locale.US) == "https") {
+ "HTTPS upstream required"
+ }
+ require(uri.userInfo == null) { "Upstream credentials are forbidden" }
+ require(uri.port == -1 || uri.port == 443) { "Unsupported upstream port" }
+
+ val host = uri.host?.trim()?.lowercase(Locale.US)
+ require(!host.isNullOrEmpty()) { "Missing upstream host" }
+ require(host != "localhost" && !host.endsWith(".localhost")) {
+ "Loopback upstream is forbidden"
+ }
+
+ if (numericIpv4Pattern.matches(host) || host.contains(':')) {
+ val literal = runCatching { InetAddress.getByName(host) }
+ .getOrElse { throw IllegalArgumentException("Invalid upstream address") }
+ require(!isForbiddenAddress(literal)) { "Private upstream is forbidden" }
+ }
+ return uri
+ }
+
+ fun isForbiddenAddress(address: InetAddress): Boolean {
+ if (
+ address.isAnyLocalAddress ||
+ address.isLoopbackAddress ||
+ address.isLinkLocalAddress ||
+ address.isSiteLocalAddress ||
+ address.isMulticastAddress
+ ) {
+ return true
+ }
+
+ val bytes = address.address
+ if (address is Inet4Address && bytes.size == 4) {
+ val first = bytes[0].toInt() and 0xff
+ val second = bytes[1].toInt() and 0xff
+ if (first == 0 || first >= 224) return true
+ if (first == 100 && second in 64..127) return true
+ if (first == 198 && second in 18..19) return true
+ }
+ if (address is Inet6Address && bytes.isNotEmpty()) {
+ val first = bytes[0].toInt() and 0xff
+ if (first and 0xfe == 0xfc) return true
+ }
+ return false
+ }
+
+ fun sanitizeRequestHeaders(input: Map): Map {
+ val output = linkedMapOf()
+ for ((rawName, rawValue) in input) {
+ val canonicalName = allowedRequestHeaders[rawName.trim().lowercase(Locale.US)]
+ ?: continue
+ val value = rawValue.trim()
+ if (
+ value.isEmpty() ||
+ value.length > MAX_HEADER_VALUE_LENGTH ||
+ value.contains('\r') ||
+ value.contains('\n')
+ ) {
+ continue
+ }
+ output[canonicalName] = value
+ }
+ return output
+ }
+
+ fun sanitizeLocalRequestHeaders(input: Map): Map {
+ return sanitizeRequestHeaders(
+ input.filterKeys {
+ it.trim().lowercase(Locale.US) in allowedLocalOverrideHeaders
+ },
+ )
+ }
+
+ fun rewritePlaylist(
+ playlist: String,
+ baseUrl: String,
+ localize: (String) -> String,
+ ): String {
+ val baseUri = runCatching { URI(baseUrl) }
+ .getOrElse { throw IllegalArgumentException("Invalid playlist base URL") }
+
+ fun rewrite(rawValue: String): String {
+ val value = rawValue.trim()
+ if (
+ value.isEmpty() ||
+ value.startsWith("data:", ignoreCase = true) ||
+ value.startsWith("blob:", ignoreCase = true)
+ ) {
+ return rawValue
+ }
+ val absolute = runCatching { baseUri.resolve(value).toString() }
+ .getOrElse { return rawValue }
+ return localize(absolute)
+ }
+
+ return playlist.lineSequence().joinToString("\n") { line ->
+ if (line.isBlank()) {
+ line
+ } else if (!line.trimStart().startsWith("#")) {
+ val leading = line.takeWhile(Char::isWhitespace)
+ val trailing = line.takeLastWhile(Char::isWhitespace)
+ leading + rewrite(line.trim()) + trailing
+ } else {
+ uriAttributePattern.replace(line) { match ->
+ val quote = match.groupValues[1]
+ val value = match.groupValues[2]
+ "URI=$quote${rewrite(value)}$quote"
+ }
+ }
+ }
+ }
+
+ fun buildLoopbackUrl(
+ port: Int,
+ processSecret: String,
+ sessionId: String,
+ resourceId: String,
+ ): String {
+ require(port in 1..65_535) { "Invalid loopback port" }
+ require(tokenPattern.matches(processSecret)) { "Invalid process secret" }
+ require(tokenPattern.matches(sessionId)) { "Invalid session id" }
+ require(tokenPattern.matches(resourceId)) { "Invalid resource id" }
+ return "http://127.0.0.1:$port/p/$processSecret/$sessionId/$resourceId"
+ }
+}
diff --git a/app/android/app/src/main/java/com/movix/app/proxy/MediaProxyServer.kt b/app/android/app/src/main/java/com/movix/app/proxy/MediaProxyServer.kt
new file mode 100644
index 0000000..c153986
--- /dev/null
+++ b/app/android/app/src/main/java/com/movix/app/proxy/MediaProxyServer.kt
@@ -0,0 +1,521 @@
+package com.movix.app.proxy
+
+import java.io.BufferedInputStream
+import java.io.BufferedOutputStream
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.Closeable
+import java.io.InputStream
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.net.ServerSocket
+import java.net.Socket
+import java.net.SocketException
+import java.net.URI
+import java.net.UnknownHostException
+import java.nio.charset.StandardCharsets
+import java.util.Locale
+import java.util.concurrent.ArrayBlockingQueue
+import java.util.concurrent.ThreadPoolExecutor
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import okhttp3.Dns
+import okhttp3.Headers
+import okhttp3.OkHttpClient
+import okhttp3.Request
+
+internal interface MediaProxyUpstream {
+ fun execute(
+ target: MediaProxyTarget,
+ localRequestHeaders: Map,
+ ): MediaProxyUpstreamResponse
+}
+
+internal class MediaProxyUpstreamResponse(
+ val statusCode: Int,
+ val statusMessage: String,
+ val headers: Map,
+ val body: InputStream,
+ val finalUrl: String,
+ private val onClose: () -> Unit = {},
+) : Closeable {
+ override fun close() {
+ try {
+ body.close()
+ } finally {
+ onClose()
+ }
+ }
+}
+
+internal class OkHttpMediaProxyUpstream(
+ private val validateUrl: (String) -> URI = {
+ MediaProxyPolicy.validatePublicHttpsUrl(it)
+ },
+) : MediaProxyUpstream {
+ private val safeDns = object : Dns {
+ override fun lookup(hostname: String): List {
+ val addresses = Dns.SYSTEM.lookup(hostname)
+ if (addresses.isEmpty() || addresses.any(MediaProxyPolicy::isForbiddenAddress)) {
+ throw UnknownHostException("Private or unresolved media host")
+ }
+ return addresses
+ }
+ }
+ private val client = OkHttpClient.Builder()
+ .dns(safeDns)
+ .followRedirects(false)
+ .followSslRedirects(false)
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(45, TimeUnit.SECONDS)
+ .writeTimeout(15, TimeUnit.SECONDS)
+ .build()
+
+ override fun execute(
+ target: MediaProxyTarget,
+ localRequestHeaders: Map,
+ ): MediaProxyUpstreamResponse {
+ val mergedHeaders = linkedMapOf()
+ mergedHeaders.putAll(MediaProxyPolicy.sanitizeRequestHeaders(target.headers))
+ mergedHeaders.putAll(MediaProxyPolicy.sanitizeLocalRequestHeaders(localRequestHeaders))
+ if (!mergedHeaders.containsKey("User-Agent")) {
+ mergedHeaders["User-Agent"] = DEFAULT_USER_AGENT
+ }
+
+ var currentUrl = target.upstreamUrl
+ repeat(MAX_REDIRECTS + 1) { redirectCount ->
+ validateUrl(currentUrl)
+ val headerBuilder = Headers.Builder()
+ for ((name, value) in mergedHeaders) {
+ headerBuilder.set(name, value)
+ }
+ val requestBuilder = Request.Builder()
+ .url(currentUrl)
+ .headers(headerBuilder.build())
+ if (target.method == "HEAD") {
+ requestBuilder.head()
+ } else {
+ requestBuilder.get()
+ }
+
+ val response = client.newCall(requestBuilder.build()).execute()
+ val location = response.header("Location")
+ if (response.code in 300..399 && location != null) {
+ if (redirectCount >= MAX_REDIRECTS) {
+ response.close()
+ throw IllegalStateException("Too many media redirects")
+ }
+ val nextUrl = response.request.url.resolve(location)?.toString()
+ response.close()
+ currentUrl = nextUrl
+ ?: throw IllegalArgumentException("Invalid media redirect")
+ return@repeat
+ }
+
+ val responseHeaders = linkedMapOf()
+ for (name in response.headers.names()) {
+ responseHeaders[name] = response.headers.values(name).joinToString(", ")
+ }
+ val responseBody = response.body
+ return MediaProxyUpstreamResponse(
+ statusCode = response.code,
+ statusMessage = response.message,
+ headers = responseHeaders,
+ body = responseBody?.byteStream() ?: ByteArrayInputStream(ByteArray(0)),
+ finalUrl = response.request.url.toString(),
+ onClose = response::close,
+ )
+ }
+ throw IllegalStateException("Media redirect resolution failed")
+ }
+
+ companion object {
+ private const val MAX_REDIRECTS = 5
+ private const val DEFAULT_USER_AGENT =
+ "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 " +
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
+ }
+}
+
+internal class MediaProxyServer(
+ private val upstream: MediaProxyUpstream = OkHttpMediaProxyUpstream(),
+ private val validateUrl: (String) -> URI = {
+ MediaProxyPolicy.validatePublicHttpsUrl(it)
+ },
+ private val validateDiscoveredUrl: (String) -> URI =
+ MediaProxyPolicy::validateHttpsUrlSyntax,
+ private val sessionStore: MediaProxySessionStore = MediaProxySessionStore(),
+) : Closeable {
+ private val running = AtomicBoolean(false)
+ private val closed = AtomicBoolean(false)
+ private val startLock = Any()
+ private val workerCounter = java.util.concurrent.atomic.AtomicInteger()
+ private val workers = ThreadPoolExecutor(
+ 4,
+ 32,
+ 30L,
+ TimeUnit.SECONDS,
+ ArrayBlockingQueue(128),
+ { task ->
+ Thread(
+ task,
+ "MovixMediaProxy-${workerCounter.incrementAndGet()}",
+ ).apply { isDaemon = true }
+ },
+ ThreadPoolExecutor.AbortPolicy(),
+ )
+
+ @Volatile
+ private var serverSocket: ServerSocket? = null
+
+ fun open(
+ upstreamUrl: String,
+ method: String,
+ headers: Map,
+ ): String {
+ check(!closed.get()) { "Media proxy is closed" }
+ val normalizedMethod = method.uppercase(Locale.US)
+ require(normalizedMethod == "GET" || normalizedMethod == "HEAD") {
+ "Unsupported media proxy method"
+ }
+ val validated = validateUrl(upstreamUrl).toString()
+ val sanitizedHeaders = MediaProxyPolicy.sanitizeRequestHeaders(headers)
+ require(sanitizedHeaders.isNotEmpty()) { "Protected media headers required" }
+ val port = ensureStarted()
+ return sessionStore.create(
+ upstreamUrl = validated,
+ method = normalizedMethod,
+ headers = sanitizedHeaders,
+ port = port,
+ )
+ }
+
+ private fun ensureStarted(): Int = synchronized(startLock) {
+ serverSocket?.takeIf { !it.isClosed }?.localPort?.let { return it }
+ check(!closed.get()) { "Media proxy is closed" }
+
+ val socket = ServerSocket()
+ socket.reuseAddress = true
+ socket.bind(InetSocketAddress(InetAddress.getByName(LOOPBACK_HOST), 0), 64)
+ serverSocket = socket
+ running.set(true)
+ Thread({ acceptLoop(socket) }, "MovixMediaProxy-Acceptor").apply {
+ isDaemon = true
+ start()
+ }
+ socket.localPort
+ }
+
+ private fun acceptLoop(socket: ServerSocket) {
+ while (running.get() && !socket.isClosed) {
+ val client = try {
+ socket.accept()
+ } catch (_: SocketException) {
+ break
+ } catch (_: Throwable) {
+ continue
+ }
+
+ if (!client.inetAddress.isLoopbackAddress) {
+ runCatching { client.close() }
+ continue
+ }
+ try {
+ workers.execute { handleClient(client) }
+ } catch (_: Throwable) {
+ runCatching { client.close() }
+ }
+ }
+ }
+
+ private fun handleClient(socket: Socket) {
+ socket.use { client ->
+ client.soTimeout = 30_000
+ val input = BufferedInputStream(client.getInputStream())
+ val output = BufferedOutputStream(client.getOutputStream())
+ try {
+ val requestLine = readAsciiLine(input, MAX_REQUEST_LINE)
+ ?: return
+ val requestParts = requestLine.split(' ')
+ if (requestParts.size != 3 || !requestParts[2].startsWith("HTTP/1.")) {
+ writeError(output, 400, "Bad Request")
+ return
+ }
+ val method = requestParts[0].uppercase(Locale.US)
+ val path = runCatching { URI(requestParts[1]).path }
+ .getOrNull()
+ ?: run {
+ writeError(output, 400, "Bad Request")
+ return
+ }
+ val requestHeaders = readHeaders(input)
+ val pathParts = path.split('/').filter(String::isNotEmpty)
+ if (pathParts.size != 4 || pathParts[0] != "p") {
+ writeError(output, 404, "Not Found")
+ return
+ }
+
+ val target = sessionStore.resolve(
+ suppliedSecret = pathParts[1],
+ sessionId = pathParts[2],
+ resourceId = pathParts[3],
+ ) ?: run {
+ writeError(output, 404, "Not Found")
+ return
+ }
+
+ if (method == "OPTIONS") {
+ writeHeaders(output, 204, "No Content", emptyMap(), 0L)
+ return
+ }
+ if (method != "GET" && method != "HEAD") {
+ writeError(output, 405, "Method Not Allowed")
+ return
+ }
+
+ validateUrl(target.upstreamUrl)
+ val localHeaders =
+ MediaProxyPolicy.sanitizeLocalRequestHeaders(requestHeaders)
+ upstream.execute(target, localHeaders).use { response ->
+ if (isPlaylist(response)) {
+ writePlaylistResponse(
+ output = output,
+ response = response,
+ sessionId = pathParts[2],
+ port = requireNotNull(serverSocket).localPort,
+ sendBody = method != "HEAD",
+ )
+ } else {
+ writeStreamingResponse(
+ output = output,
+ response = response,
+ sendBody = method != "HEAD",
+ )
+ }
+ }
+ } catch (_: Throwable) {
+ runCatching { writeError(output, 502, "Bad Gateway") }
+ }
+ }
+ }
+
+ private fun writePlaylistResponse(
+ output: BufferedOutputStream,
+ response: MediaProxyUpstreamResponse,
+ sessionId: String,
+ port: Int,
+ sendBody: Boolean,
+ ) {
+ val original = readLimited(response.body, MAX_PLAYLIST_BYTES)
+ .toString(StandardCharsets.UTF_8)
+ val rewritten = MediaProxyPolicy.rewritePlaylist(
+ playlist = original,
+ baseUrl = response.finalUrl,
+ ) { discoveredUrl ->
+ val validated = validateDiscoveredUrl(discoveredUrl).toString()
+ sessionStore.register(sessionId, validated, port)
+ }
+ val bytes = rewritten.toByteArray(StandardCharsets.UTF_8)
+ val headers = filteredResponseHeaders(response.headers).toMutableMap()
+ headers["Content-Type"] =
+ getHeader(response.headers, "Content-Type")
+ ?: "application/vnd.apple.mpegurl"
+ headers["Content-Length"] = bytes.size.toString()
+ writeHeaders(
+ output,
+ response.statusCode,
+ response.statusMessage,
+ headers,
+ bytes.size.toLong(),
+ )
+ if (sendBody) output.write(bytes)
+ output.flush()
+ }
+
+ private fun writeStreamingResponse(
+ output: BufferedOutputStream,
+ response: MediaProxyUpstreamResponse,
+ sendBody: Boolean,
+ ) {
+ val headers = filteredResponseHeaders(response.headers)
+ val contentLength = getHeader(response.headers, "Content-Length")?.toLongOrNull()
+ writeHeaders(
+ output,
+ response.statusCode,
+ response.statusMessage,
+ headers,
+ contentLength,
+ )
+ if (sendBody) {
+ response.body.copyTo(output, DEFAULT_BUFFER_SIZE)
+ }
+ output.flush()
+ }
+
+ private fun writeHeaders(
+ output: BufferedOutputStream,
+ statusCode: Int,
+ statusMessage: String,
+ headers: Map,
+ contentLength: Long?,
+ ) {
+ val safeMessage = statusMessage.replace(Regex("[^\\x20-\\x7E]"), "")
+ .ifBlank { defaultReason(statusCode) }
+ val lines = StringBuilder()
+ .append("HTTP/1.1 ")
+ .append(statusCode)
+ .append(' ')
+ .append(safeMessage)
+ .append("\r\n")
+ for ((name, value) in headers) {
+ if (
+ name.equals("Connection", ignoreCase = true) ||
+ name.equals("Transfer-Encoding", ignoreCase = true) ||
+ name.equals("Access-Control-Allow-Origin", ignoreCase = true)
+ ) {
+ continue
+ }
+ if (value.contains('\r') || value.contains('\n')) continue
+ lines.append(name).append(": ").append(value).append("\r\n")
+ }
+ if (contentLength != null && headers.keys.none {
+ it.equals("Content-Length", ignoreCase = true)
+ }
+ ) {
+ lines.append("Content-Length: ").append(contentLength).append("\r\n")
+ }
+ lines
+ .append("Access-Control-Allow-Origin: *\r\n")
+ .append("Access-Control-Allow-Methods: GET, HEAD, OPTIONS\r\n")
+ .append("Access-Control-Allow-Headers: Range, Accept, Content-Type\r\n")
+ .append("Access-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges\r\n")
+ .append("Connection: close\r\n\r\n")
+ output.write(lines.toString().toByteArray(StandardCharsets.ISO_8859_1))
+ output.flush()
+ }
+
+ private fun writeError(
+ output: BufferedOutputStream,
+ statusCode: Int,
+ reason: String,
+ ) {
+ val body = reason.toByteArray(StandardCharsets.UTF_8)
+ writeHeaders(
+ output,
+ statusCode,
+ reason,
+ mapOf(
+ "Content-Type" to "text/plain; charset=utf-8",
+ "Content-Length" to body.size.toString(),
+ ),
+ body.size.toLong(),
+ )
+ output.write(body)
+ output.flush()
+ }
+
+ private fun readHeaders(input: BufferedInputStream): Map {
+ val headers = linkedMapOf()
+ repeat(MAX_HEADER_COUNT) {
+ val line = readAsciiLine(input, MAX_HEADER_LINE)
+ ?: throw IllegalArgumentException("Incomplete request headers")
+ if (line.isEmpty()) return headers
+ val separator = line.indexOf(':')
+ if (separator <= 0) throw IllegalArgumentException("Malformed request header")
+ headers[line.substring(0, separator).trim()] =
+ line.substring(separator + 1).trim()
+ }
+ throw IllegalArgumentException("Too many request headers")
+ }
+
+ private fun readAsciiLine(input: InputStream, maxLength: Int): String? {
+ val bytes = ByteArrayOutputStream()
+ while (bytes.size() <= maxLength) {
+ val value = input.read()
+ if (value == -1) {
+ return if (bytes.size() == 0) null else bytes.toString("ISO-8859-1")
+ }
+ if (value == '\n'.code) {
+ val raw = bytes.toByteArray()
+ val length = if (raw.isNotEmpty() && raw.last() == '\r'.code.toByte()) {
+ raw.size - 1
+ } else {
+ raw.size
+ }
+ return String(raw, 0, length, StandardCharsets.ISO_8859_1)
+ }
+ bytes.write(value)
+ }
+ throw IllegalArgumentException("HTTP line too long")
+ }
+
+ private fun readLimited(input: InputStream, limit: Int): ByteArray {
+ val output = ByteArrayOutputStream()
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ var total = 0
+ while (true) {
+ val count = input.read(buffer)
+ if (count == -1) break
+ total += count
+ require(total <= limit) { "Playlist exceeds size limit" }
+ output.write(buffer, 0, count)
+ }
+ return output.toByteArray()
+ }
+
+ private fun isPlaylist(response: MediaProxyUpstreamResponse): Boolean {
+ val contentType = getHeader(response.headers, "Content-Type")
+ ?.lowercase(Locale.US)
+ .orEmpty()
+ val path = runCatching { URI(response.finalUrl).path.lowercase(Locale.US) }
+ .getOrDefault("")
+ return contentType.contains("mpegurl") || path.endsWith(".m3u8")
+ }
+
+ private fun filteredResponseHeaders(input: Map): Map {
+ val allowed = setOf(
+ "accept-ranges",
+ "cache-control",
+ "content-length",
+ "content-range",
+ "content-type",
+ "etag",
+ "expires",
+ "last-modified",
+ )
+ return input.filterKeys { it.lowercase(Locale.US) in allowed }
+ }
+
+ private fun getHeader(headers: Map, name: String): String? {
+ return headers.entries.firstOrNull {
+ it.key.equals(name, ignoreCase = true)
+ }?.value
+ }
+
+ private fun defaultReason(statusCode: Int): String = when (statusCode) {
+ 200 -> "OK"
+ 204 -> "No Content"
+ 206 -> "Partial Content"
+ 400 -> "Bad Request"
+ 404 -> "Not Found"
+ 405 -> "Method Not Allowed"
+ 416 -> "Range Not Satisfiable"
+ 502 -> "Bad Gateway"
+ else -> "Response"
+ }
+
+ override fun close() {
+ if (!closed.compareAndSet(false, true)) return
+ running.set(false)
+ runCatching { serverSocket?.close() }
+ workers.shutdownNow()
+ }
+
+ companion object {
+ private const val LOOPBACK_HOST = "127.0.0.1"
+ private const val MAX_REQUEST_LINE = 8_192
+ private const val MAX_HEADER_LINE = 8_192
+ private const val MAX_HEADER_COUNT = 64
+ private const val MAX_PLAYLIST_BYTES = 5 * 1024 * 1024
+ }
+}
diff --git a/app/android/app/src/main/java/com/movix/app/proxy/MediaProxySessionStore.kt b/app/android/app/src/main/java/com/movix/app/proxy/MediaProxySessionStore.kt
new file mode 100644
index 0000000..8213fd5
--- /dev/null
+++ b/app/android/app/src/main/java/com/movix/app/proxy/MediaProxySessionStore.kt
@@ -0,0 +1,155 @@
+package com.movix.app.proxy
+
+import java.security.MessageDigest
+import java.security.SecureRandom
+import java.util.Base64
+
+internal data class MediaProxyTarget(
+ val upstreamUrl: String,
+ val method: String,
+ val headers: Map,
+)
+
+internal class MediaProxySessionStore(
+ private val processSecret: String = randomToken(),
+ private val now: () -> Long = System::currentTimeMillis,
+ private val tokenFactory: () -> String = ::randomToken,
+ private val idleTtlMs: Long = DEFAULT_IDLE_TTL_MS,
+ private val maxSessions: Int = DEFAULT_MAX_SESSIONS,
+ private val maxResourcesPerSession: Int = DEFAULT_MAX_RESOURCES,
+) {
+ private data class Session(
+ val headers: Map,
+ val resources: LinkedHashMap = linkedMapOf(),
+ val resourceIdsByUrl: MutableMap = mutableMapOf(),
+ var lastAccessAt: Long,
+ )
+
+ private val lock = Any()
+ private val sessions = linkedMapOf()
+
+ fun create(
+ upstreamUrl: String,
+ method: String,
+ headers: Map,
+ port: Int,
+ ): String = synchronized(lock) {
+ cleanupExpiredLocked()
+ while (sessions.size >= maxSessions) {
+ val oldestId = sessions.minByOrNull { it.value.lastAccessAt }?.key ?: break
+ sessions.remove(oldestId)
+ }
+
+ val sessionId = uniqueSessionIdLocked()
+ val resourceId = tokenFactory()
+ val normalizedMethod = method.uppercase()
+ val copiedHeaders = headers.toMap()
+ val root = MediaProxyTarget(upstreamUrl, normalizedMethod, copiedHeaders)
+ val session = Session(headers = copiedHeaders, lastAccessAt = now())
+ session.resources[resourceId] = root
+ session.resourceIdsByUrl[upstreamUrl] = resourceId
+ sessions[sessionId] = session
+ MediaProxyPolicy.buildLoopbackUrl(
+ port,
+ processSecret,
+ sessionId,
+ resourceId,
+ )
+ }
+
+ fun register(
+ sessionId: String,
+ upstreamUrl: String,
+ port: Int,
+ ): String = synchronized(lock) {
+ cleanupExpiredLocked()
+ val session = sessions[sessionId]
+ ?: throw IllegalArgumentException("Unknown media proxy session")
+ session.lastAccessAt = now()
+ val existingId = session.resourceIdsByUrl[upstreamUrl]
+ if (existingId != null) {
+ return@synchronized MediaProxyPolicy.buildLoopbackUrl(
+ port,
+ processSecret,
+ sessionId,
+ existingId,
+ )
+ }
+ require(session.resources.size < maxResourcesPerSession) {
+ "Media proxy session resource limit reached"
+ }
+
+ val resourceId = uniqueResourceIdLocked(session)
+ session.resources[resourceId] = MediaProxyTarget(
+ upstreamUrl = upstreamUrl,
+ method = "GET",
+ headers = session.headers,
+ )
+ session.resourceIdsByUrl[upstreamUrl] = resourceId
+ MediaProxyPolicy.buildLoopbackUrl(
+ port,
+ processSecret,
+ sessionId,
+ resourceId,
+ )
+ }
+
+ fun resolve(
+ suppliedSecret: String,
+ sessionId: String,
+ resourceId: String,
+ ): MediaProxyTarget? = synchronized(lock) {
+ if (!constantTimeEquals(processSecret, suppliedSecret)) return@synchronized null
+ cleanupExpiredLocked()
+ val session = sessions[sessionId] ?: return@synchronized null
+ val target = session.resources[resourceId] ?: return@synchronized null
+ session.lastAccessAt = now()
+ target
+ }
+
+ private fun cleanupExpiredLocked() {
+ val cutoff = now() - idleTtlMs
+ val iterator = sessions.iterator()
+ while (iterator.hasNext()) {
+ if (iterator.next().value.lastAccessAt < cutoff) {
+ iterator.remove()
+ }
+ }
+ }
+
+ private fun uniqueSessionIdLocked(): String {
+ repeat(8) {
+ val candidate = tokenFactory()
+ if (!sessions.containsKey(candidate)) return candidate
+ }
+ throw IllegalStateException("Unable to allocate media proxy session")
+ }
+
+ private fun uniqueResourceIdLocked(session: Session): String {
+ repeat(8) {
+ val candidate = tokenFactory()
+ if (!session.resources.containsKey(candidate)) return candidate
+ }
+ throw IllegalStateException("Unable to allocate media proxy resource")
+ }
+
+ companion object {
+ private const val DEFAULT_IDLE_TTL_MS = 30L * 60L * 1_000L
+ private const val DEFAULT_MAX_SESSIONS = 512
+ private const val DEFAULT_MAX_RESOURCES = 4_096
+ private val secureRandom = SecureRandom()
+
+ private fun randomToken(): String {
+ val bytes = ByteArray(18)
+ secureRandom.nextBytes(bytes)
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
+ }
+
+ private fun constantTimeEquals(expected: String, supplied: String): Boolean {
+ return MessageDigest.isEqual(
+ expected.toByteArray(Charsets.UTF_8),
+ supplied.toByteArray(Charsets.UTF_8),
+ )
+ }
+ }
+}
diff --git a/app/android/app/src/main/java/com/movix/app/update/UpdateModule.kt b/app/android/app/src/main/java/com/movix/app/update/UpdateModule.kt
index 4f0a714..bcb03fe 100644
--- a/app/android/app/src/main/java/com/movix/app/update/UpdateModule.kt
+++ b/app/android/app/src/main/java/com/movix/app/update/UpdateModule.kt
@@ -1,8 +1,10 @@
package com.movix.app.update
import android.app.DownloadManager
+import android.content.ClipData
import android.content.Context
import android.content.Intent
+import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.os.Build
@@ -99,10 +101,18 @@ class UpdateModule(private val reactContext: ReactApplicationContext) :
val dm = reactContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val dir = reactContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
+ ?.canonicalFile
?: throw IllegalStateException("External files dir unavailable")
if (!dir.exists()) dir.mkdirs()
- val target = File(dir, fileName)
+ val target = File(dir, fileName).canonicalFile
+ if (
+ target.parentFile != dir ||
+ !target.extension.equals("apk", ignoreCase = true)
+ ) {
+ promise.reject("INVALID_APK_PATH", "Invalid APK destination")
+ return
+ }
if (target.exists()) target.delete() // avoid stale leftovers of same name
val request = DownloadManager.Request(parsed)
@@ -192,7 +202,7 @@ class UpdateModule(private val reactContext: ReactApplicationContext) :
try {
val file = File(filePath)
if (!file.exists()) {
- promise.reject("FILE_NOT_FOUND", "File does not exist: $filePath")
+ promise.reject("FILE_NOT_FOUND", "File does not exist")
return
}
val md = MessageDigest.getInstance("SHA-256")
@@ -216,21 +226,55 @@ class UpdateModule(private val reactContext: ReactApplicationContext) :
@ReactMethod
fun installApk(filePath: String, promise: Promise) {
try {
- val file = File(filePath)
- if (!file.exists()) {
- promise.reject("FILE_NOT_FOUND", "APK not found: $filePath")
+ val updateDir = reactContext
+ .getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
+ ?.canonicalFile
+ ?: throw IllegalStateException("External files dir unavailable")
+ val file = File(filePath).canonicalFile
+ if (!file.exists() || !file.isFile) {
+ promise.reject("FILE_NOT_FOUND", "APK file not found")
+ return
+ }
+ if (
+ file.parentFile != updateDir ||
+ !file.extension.equals("apk", ignoreCase = true)
+ ) {
+ promise.reject("INVALID_APK_PATH", "Invalid APK path")
return
}
val authority = "${reactContext.packageName}.updateprovider"
val uri: Uri = FileProvider.getUriForFile(reactContext, authority, file)
- val intent = Intent(Intent.ACTION_VIEW)
- .setDataAndType(uri, "application/vnd.android.package-archive")
- .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, "application/vnd.android.package-archive")
+ clipData = ClipData.newRawUri("Movix update", uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ val installers = reactContext.packageManager.queryIntentActivities(
+ intent,
+ PackageManager.MATCH_DEFAULT_ONLY,
+ )
+ if (installers.isEmpty()) {
+ promise.reject("NO_INSTALLER", "No package installer available")
+ return
+ }
+ installers.forEach {
+ reactContext.grantUriPermission(
+ it.activityInfo.packageName,
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION,
+ )
+ }
- reactContext.startActivity(intent)
+ val activity = currentActivity
+ if (activity != null) {
+ activity.startActivity(intent)
+ } else {
+ reactContext.startActivity(
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ }
promise.resolve(null)
} catch (e: Exception) {
promise.reject("INSTALL_ERROR", e.message ?: "unknown", e)
diff --git a/app/android/app/src/test/java/com/movix/app/proxy/MediaProxyPolicyTest.kt b/app/android/app/src/test/java/com/movix/app/proxy/MediaProxyPolicyTest.kt
new file mode 100644
index 0000000..5202da7
--- /dev/null
+++ b/app/android/app/src/test/java/com/movix/app/proxy/MediaProxyPolicyTest.kt
@@ -0,0 +1,117 @@
+package com.movix.app.proxy
+
+import java.net.InetAddress
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MediaProxyPolicyTest {
+ @Test
+ fun rewritesRelativeAbsoluteAndQuotedPlaylistUris() {
+ val input = """
+ #EXTM3U
+ video/720.m3u8
+ https://media.example/absolute.ts
+ #EXT-X-KEY:METHOD=AES-128,URI="key.bin"
+ #EXT-X-MEDIA:TYPE=SUBTITLES,URI='subs/fr.vtt'
+ #EXT-X-MAP:URI="data:application/octet-stream;base64,AA=="
+ """.trimIndent()
+
+ val output = MediaProxyPolicy.rewritePlaylist(
+ input,
+ "https://cdn.example/root/master.m3u8",
+ ) { "LOCAL:$it" }
+
+ assertTrue(output.contains("LOCAL:https://cdn.example/root/video/720.m3u8"))
+ assertTrue(output.contains("LOCAL:https://media.example/absolute.ts"))
+ assertTrue(output.contains("URI=\"LOCAL:https://cdn.example/root/key.bin\""))
+ assertTrue(output.contains("URI='LOCAL:https://cdn.example/root/subs/fr.vtt'"))
+ assertTrue(output.contains("URI=\"data:application/octet-stream;base64,AA==\""))
+ }
+
+ @Test
+ fun validatesOnlyPublicHttpsDestinations() {
+ val publicResolver = { _: String ->
+ listOf(InetAddress.getByAddress(byteArrayOf(93, 184.toByte(), 216.toByte(), 34)))
+ }
+
+ val accepted = MediaProxyPolicy.validatePublicHttpsUrl(
+ "https://cdn.example/video/master.m3u8",
+ publicResolver,
+ )
+ assertEquals("https", accepted.scheme)
+ assertEquals("cdn.example", accepted.host)
+
+ for (url in listOf(
+ "http://cdn.example/video.ts",
+ "https://127.0.0.1/video.ts",
+ "https://10.0.0.8/video.ts",
+ "https://user:pass@cdn.example/video.ts",
+ "https://cdn.example:8443/video.ts",
+ )) {
+ assertThrows(IllegalArgumentException::class.java) {
+ MediaProxyPolicy.validatePublicHttpsUrl(url, publicResolver)
+ }
+ }
+ }
+
+ @Test
+ fun rejectsPrivateDnsAnswers() {
+ val privateResolver = { _: String ->
+ listOf(InetAddress.getByAddress(byteArrayOf(192.toByte(), 168.toByte(), 1, 25)))
+ }
+
+ assertThrows(IllegalArgumentException::class.java) {
+ MediaProxyPolicy.validatePublicHttpsUrl(
+ "https://cdn.example/video.ts",
+ privateResolver,
+ )
+ }
+ }
+
+ @Test
+ fun sanitizesRequestHeadersWithAnAllowlist() {
+ val sanitized = MediaProxyPolicy.sanitizeRequestHeaders(
+ mapOf(
+ "Origin" to "https://vidzy.org",
+ "referer" to "https://vidzy.org/",
+ "Range" to "bytes=0-1023",
+ "Accept" to "*/*",
+ "User-Agent" to "Movix",
+ "Host" to "attacker.invalid",
+ "Connection" to "keep-alive",
+ "Cookie" to "secret=value",
+ "Authorization" to "Bearer secret",
+ "X-Injected" to "bad\r\nHeader: value",
+ ),
+ )
+
+ assertEquals("https://vidzy.org", sanitized["Origin"])
+ assertEquals("https://vidzy.org/", sanitized["Referer"])
+ assertEquals("bytes=0-1023", sanitized["Range"])
+ assertFalse(sanitized.containsKey("Host"))
+ assertFalse(sanitized.containsKey("Connection"))
+ assertFalse(sanitized.containsKey("Cookie"))
+ assertFalse(sanitized.containsKey("Authorization"))
+ assertFalse(sanitized.containsKey("X-Injected"))
+ }
+
+ @Test
+ fun buildsOpaqueLoopbackUrls() {
+ val localUrl = MediaProxyPolicy.buildLoopbackUrl(
+ port = 28123,
+ processSecret = "process-secret",
+ sessionId = "session-id",
+ resourceId = "resource-id",
+ )
+
+ assertEquals(
+ "http://127.0.0.1:28123/p/process-secret/session-id/resource-id",
+ localUrl,
+ )
+ assertFalse(localUrl.contains("vidzy"))
+ assertFalse(localUrl.contains("m3u8"))
+ }
+}
diff --git a/app/android/app/src/test/java/com/movix/app/proxy/MediaProxyServerTest.kt b/app/android/app/src/test/java/com/movix/app/proxy/MediaProxyServerTest.kt
new file mode 100644
index 0000000..0cf84c5
--- /dev/null
+++ b/app/android/app/src/test/java/com/movix/app/proxy/MediaProxyServerTest.kt
@@ -0,0 +1,97 @@
+package com.movix.app.proxy
+
+import java.io.ByteArrayInputStream
+import java.net.URI
+import java.net.URL
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MediaProxyServerTest {
+ @Test
+ fun rewritesPlaylistsAndStreamsNestedMediaLocally() {
+ val requests = mutableListOf>>()
+ val upstream = object : MediaProxyUpstream {
+ override fun execute(
+ target: MediaProxyTarget,
+ localRequestHeaders: Map,
+ ): MediaProxyUpstreamResponse {
+ requests += target to localRequestHeaders
+ return if (target.upstreamUrl.endsWith("master.m3u8")) {
+ val playlist = "#EXTM3U\nsegment-001.ts\n"
+ MediaProxyUpstreamResponse(
+ statusCode = 200,
+ statusMessage = "OK",
+ headers = mapOf(
+ "Content-Type" to "application/vnd.apple.mpegurl",
+ "Content-Length" to playlist.toByteArray().size.toString(),
+ ),
+ body = ByteArrayInputStream(playlist.toByteArray()),
+ finalUrl = target.upstreamUrl,
+ )
+ } else {
+ val bytes = byteArrayOf(10, 20, 30, 40)
+ MediaProxyUpstreamResponse(
+ statusCode = 206,
+ statusMessage = "Partial Content",
+ headers = mapOf(
+ "Content-Type" to "video/mp2t",
+ "Content-Length" to bytes.size.toString(),
+ "Content-Range" to "bytes 0-3/4",
+ ),
+ body = ByteArrayInputStream(bytes),
+ finalUrl = target.upstreamUrl,
+ )
+ }
+ }
+ }
+ val server = MediaProxyServer(
+ upstream = upstream,
+ validateUrl = { URI(it) },
+ )
+
+ try {
+ val localMaster = server.open(
+ upstreamUrl = "https://media.example/root/master.m3u8",
+ method = "GET",
+ headers = mapOf(
+ "Origin" to "https://vidzy.org",
+ "Referer" to "https://vidzy.org/",
+ ),
+ )
+ val playlistConnection = URL(localMaster).openConnection()
+ val playlist = playlistConnection.getInputStream().bufferedReader().readText()
+
+ assertEquals("*", playlistConnection.getHeaderField("Access-Control-Allow-Origin"))
+ assertTrue(playlist.contains("http://127.0.0.1:"))
+ assertFalse(playlist.contains("media.example"))
+
+ val localSegment = playlist.lineSequence()
+ .first { it.isNotBlank() && !it.startsWith("#") }
+ val segmentConnection = URL(localSegment).openConnection()
+ segmentConnection.setRequestProperty("Origin", "https://movix.app")
+ segmentConnection.setRequestProperty("Referer", "https://movix.app/")
+ segmentConnection.setRequestProperty("Range", "bytes=0-3")
+ val bytes = segmentConnection.getInputStream().readBytes()
+
+ assertArrayEquals(byteArrayOf(10, 20, 30, 40), bytes)
+ assertEquals("bytes 0-3/4", segmentConnection.getHeaderField("Content-Range"))
+ assertEquals(2, requests.size)
+ assertEquals(
+ "https://vidzy.org/",
+ requests[1].first.headers["Referer"],
+ )
+ assertEquals(
+ "https://vidzy.org",
+ requests[1].first.headers["Origin"],
+ )
+ assertEquals("bytes=0-3", requests[1].second["Range"])
+ assertFalse(requests[1].second.containsKey("Origin"))
+ assertFalse(requests[1].second.containsKey("Referer"))
+ } finally {
+ server.close()
+ }
+ }
+}
diff --git a/app/android/app/src/test/java/com/movix/app/proxy/MediaProxySessionStoreTest.kt b/app/android/app/src/test/java/com/movix/app/proxy/MediaProxySessionStoreTest.kt
new file mode 100644
index 0000000..8462dea
--- /dev/null
+++ b/app/android/app/src/test/java/com/movix/app/proxy/MediaProxySessionStoreTest.kt
@@ -0,0 +1,87 @@
+package com.movix.app.proxy
+
+import java.net.URI
+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 MediaProxySessionStoreTest {
+ @Test
+ fun createsOpaqueUrlsAndResolvesRegisteredResources() {
+ val tokens = ArrayDeque(
+ listOf(
+ "session_token_0001",
+ "resource_token_001",
+ "resource_token_002",
+ ),
+ )
+ val store = MediaProxySessionStore(
+ processSecret = "process_secret_01",
+ now = { 1_000L },
+ tokenFactory = { tokens.removeFirst() },
+ )
+
+ val rootLocalUrl = store.create(
+ upstreamUrl = "https://u14.vidzy.cc/movie/master.m3u8?token=secret",
+ method = "GET",
+ headers = mapOf("Referer" to "https://vidzy.org/"),
+ port = 28_123,
+ )
+
+ assertTrue(rootLocalUrl.startsWith("http://127.0.0.1:28123/p/"))
+ assertFalse(rootLocalUrl.contains("vidzy"))
+ assertFalse(rootLocalUrl.contains("token=secret"))
+
+ val rootPath = URI(rootLocalUrl).path.split('/').filter(String::isNotEmpty)
+ val root = store.resolve(
+ suppliedSecret = rootPath[1],
+ sessionId = rootPath[2],
+ resourceId = rootPath[3],
+ )
+ assertEquals(
+ "https://u14.vidzy.cc/movie/master.m3u8?token=secret",
+ root?.upstreamUrl,
+ )
+ assertEquals("GET", root?.method)
+ assertEquals("https://vidzy.org/", root?.headers?.get("Referer"))
+
+ val nestedLocalUrl = store.register(
+ sessionId = rootPath[2],
+ upstreamUrl = "https://u14.vidzy.cc/movie/segment.ts",
+ port = 28_123,
+ )
+ val nestedPath = URI(nestedLocalUrl).path.split('/').filter(String::isNotEmpty)
+ val nested = store.resolve(
+ suppliedSecret = nestedPath[1],
+ sessionId = nestedPath[2],
+ resourceId = nestedPath[3],
+ )
+ assertEquals("https://u14.vidzy.cc/movie/segment.ts", nested?.upstreamUrl)
+ assertEquals("GET", nested?.method)
+ }
+
+ @Test
+ fun expiresIdleSessionsAndRejectsWrongSecrets() {
+ var now = 5_000L
+ val tokens = ArrayDeque(listOf("session_token_0002", "resource_token_003"))
+ val store = MediaProxySessionStore(
+ processSecret = "process_secret_02",
+ now = { now },
+ tokenFactory = { tokens.removeFirst() },
+ idleTtlMs = 1_000L,
+ )
+ val localUrl = store.create(
+ upstreamUrl = "https://r1.fsvid.lol/movie/master.m3u8",
+ method = "GET",
+ headers = emptyMap(),
+ port = 28_124,
+ )
+ val path = URI(localUrl).path.split('/').filter(String::isNotEmpty)
+
+ assertNull(store.resolve("wrong_secret_000", path[2], path[3]))
+ now += 1_001L
+ assertNull(store.resolve(path[1], path[2], path[3]))
+ }
+}
diff --git a/app/app.json b/app/app.json
index aba5832..afb0ac2 100644
--- a/app/app.json
+++ b/app/app.json
@@ -1,6 +1,6 @@
{
"name": "Movix",
"displayName": "Movix",
- "version": "2.0.0",
- "buildNumber": "1"
+ "version": "2.5.4",
+ "buildNumber": "13"
}
diff --git a/app/movix-android.apk b/app/movix-android.apk
index 833da9f..479119b 100644
Binary files a/app/movix-android.apk and b/app/movix-android.apk differ
diff --git a/app/src/components/WebViewBrowser.tsx b/app/src/components/WebViewBrowser.tsx
index 2355641..7ac4d44 100644
--- a/app/src/components/WebViewBrowser.tsx
+++ b/app/src/components/WebViewBrowser.tsx
@@ -4,12 +4,11 @@ import React, {
useImperativeHandle,
useRef,
} from 'react';
-import { Linking, Platform } from 'react-native';
+import { Platform } from 'react-native';
import { WebView, type WebViewNavigation } from 'react-native-webview';
import type {
WebViewErrorEvent,
WebViewMessageEvent,
- ShouldStartLoadRequest,
} from 'react-native-webview/lib/WebViewTypes';
import { handleBridgeMessage } from '../services/bridge';
import { buildInjectedJavaScript } from '../injection/inject';
@@ -27,13 +26,12 @@ interface WebViewBrowserProps {
url: string;
onNavigationStateChange?: (state: WebViewNavigation) => void;
onError?: (error: string) => void;
- onLoadEnd?: () => void;
}
const injectedJS = buildInjectedJavaScript();
const WebViewBrowser = forwardRef(
- ({ url, onNavigationStateChange, onError, onLoadEnd }, ref) => {
+ ({ url, onNavigationStateChange, onError }, ref) => {
const webViewRef = useRef(null);
useImperativeHandle(ref, () => ({
@@ -63,27 +61,6 @@ const WebViewBrowser = forwardRef(
[onError],
);
- const onShouldStartLoadWithRequest = useCallback(
- (request: ShouldStartLoadRequest) => {
- const { url, navigationType } = request;
- if (
- url.startsWith('https://') ||
- url.startsWith('http://') ||
- url.startsWith('about:') ||
- url.startsWith('blob:')
- ) {
- return true;
- }
- // Ouvre uniquement les deep links déclenchés par un vrai clic utilisateur.
- // Les redirections automatiques (pubs, iframes) sont silencieusement bloquées.
- if (navigationType === 'click') {
- Linking.openURL(url).catch(() => {});
- }
- return false;
- },
- [],
- );
-
const onWebViewError = useCallback(
(event: WebViewErrorEvent) => {
onError?.(event.nativeEvent.description);
@@ -106,12 +83,10 @@ const WebViewBrowser = forwardRef(
// Bridge messages
onMessage={onMessage}
// Navigation
- onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
onNavigationStateChange={onNavigationStateChange}
// Errors
onError={onWebViewError}
onHttpError={onHttpError}
- onLoadEnd={onLoadEnd}
// Config
userAgent={userAgent}
javaScriptEnabled={true}
@@ -121,7 +96,7 @@ const WebViewBrowser = forwardRef(
allowsFullscreenVideo={true}
allowsBackForwardNavigationGestures={true}
// Sécurité
- originWhitelist={['https://*', 'http://*', 'about:*', 'blob:*']}
+ originWhitelist={['https://*', 'http://*']}
mixedContentMode="compatibility"
// Cache
cacheEnabled={true}
diff --git a/app/src/config/index.ts b/app/src/config/index.ts
index c7cb98e..2847133 100644
--- a/app/src/config/index.ts
+++ b/app/src/config/index.ts
@@ -1,5 +1,5 @@
export const CONFIG = {
- SITE_URL: 'https://movix.cash',
+ SITE_URL: 'https://movix.tax',
DNS_PRIMARY: '1.1.1.1',
DNS_SECONDARY: '1.0.0.1',
DNS_DOH_URL: 'https://cloudflare-dns.com/dns-query',
@@ -19,7 +19,7 @@ export const UPDATE_CHECK = {
};
export const FALLBACK_CONFIG = {
- PRIMARY_URL: 'https://movix.cash',
- GITHUB_URL: 'https://github.com/movixcorp/MovixOpenSource',
+ PRIMARY_URL: 'https://movix.tax',
+ GITHUB_URL: 'https://github.com/Movix-STMG/MovixOpenSource',
TELEGRAM_URL: 'https://t.me/movix_site',
};
diff --git a/app/src/hooks/updateResume.ts b/app/src/hooks/updateResume.ts
new file mode 100644
index 0000000..325da38
--- /dev/null
+++ b/app/src/hooks/updateResume.ts
@@ -0,0 +1,53 @@
+export type UpdateForegroundAction =
+ | 'none'
+ | 'continue_after_permission'
+ | 'installed'
+ | 'install_not_completed';
+
+type UpdateForegroundInput = {
+ stage: string;
+ installPermissionGranted: boolean;
+ localBuildNumber: number;
+ targetBuildNumber: number;
+};
+
+type PendingApkCandidate = {
+ targetBuildNumber: number;
+ targetSha256: string;
+ apkFilePath: string;
+};
+
+export function decideUpdateForegroundAction({
+ stage,
+ installPermissionGranted,
+ localBuildNumber,
+ targetBuildNumber,
+}: UpdateForegroundInput): UpdateForegroundAction {
+ if (stage === 'need_permission') {
+ return installPermissionGranted && localBuildNumber < targetBuildNumber
+ ? 'continue_after_permission'
+ : 'none';
+ }
+
+ if (stage === 'installing') {
+ return localBuildNumber >= targetBuildNumber
+ ? 'installed'
+ : 'install_not_completed';
+ }
+
+ return 'none';
+}
+
+export function canReusePendingApk(
+ pending: PendingApkCandidate | null,
+ targetBuildNumber: number,
+): boolean {
+ if (!pending || pending.targetBuildNumber !== targetBuildNumber) {
+ return false;
+ }
+
+ return (
+ /^[a-f0-9]{64}$/i.test(pending.targetSha256) &&
+ /\.apk$/i.test(pending.apkFilePath.trim())
+ );
+}
diff --git a/app/src/hooks/useAppUpdate.ts b/app/src/hooks/useAppUpdate.ts
index 9134a6f..c3ef6f8 100644
--- a/app/src/hooks/useAppUpdate.ts
+++ b/app/src/hooks/useAppUpdate.ts
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
-import { Platform } from 'react-native';
+import { AppState, type AppStateStatus, Platform } from 'react-native';
import { UPDATE_CHECK } from '../config';
import {
@@ -18,6 +18,10 @@ import {
type DownloadProgress,
} from '../services/updateDownloader';
import { fetchLatestVersion, type Manifest } from '../services/versionCheck';
+import {
+ canReusePendingApk,
+ decideUpdateForegroundAction,
+} from './updateResume';
export type UpdateStage =
| 'idle'
@@ -67,6 +71,23 @@ function fileNameForBuild(buildNumber: number): string {
export function useAppUpdate(githubUrl: string | null) {
const [state, setState] = useState(initialState);
const cancelRef = useRef(false);
+ const stateRef = useRef(state);
+ const pendingRef = useRef(null);
+ const mountedRef = useRef(true);
+ const foregroundBusyRef = useRef(false);
+ const appStateRef = useRef(AppState.currentState);
+ const pollGenerationRef = useRef(0);
+
+ stateRef.current = state;
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ cancelRef.current = true;
+ pollGenerationRef.current += 1;
+ };
+ }, []);
// --- On mount: reconcile local / pending DL / fresh manifest -----------
// Cases handled:
@@ -78,6 +99,7 @@ export function useAppUpdate(githubUrl: string | null) {
useEffect(() => {
if (Platform.OS !== 'android') return;
let cancelled = false;
+ cancelRef.current = false;
(async () => {
try {
@@ -91,8 +113,10 @@ export function useAppUpdate(githubUrl: string | null) {
if (raw) {
try {
pending = JSON.parse(raw) as PendingDownload;
+ pendingRef.current = pending;
} catch {
await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
+ pendingRef.current = null;
}
}
@@ -103,6 +127,7 @@ export function useAppUpdate(githubUrl: string | null) {
} catch {}
await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
pending = null;
+ pendingRef.current = null;
}
if (!githubUrl) return; // wait until address config resolves
@@ -119,6 +144,7 @@ export function useAppUpdate(githubUrl: string | null) {
} catch {}
await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
pending = null;
+ pendingRef.current = null;
}
// Case C: pending for same target as manifest — query DL.
@@ -156,6 +182,7 @@ export function useAppUpdate(githubUrl: string | null) {
}
// DL failed / query errored / unknown → clear, fall through to offer fresh.
await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
+ pendingRef.current = null;
}
// No (valid) pending → offer the fresh manifest.
@@ -195,6 +222,7 @@ export function useAppUpdate(githubUrl: string | null) {
}
} catch {}
await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
+ pendingRef.current = null;
}
// Case E: idle, stay silent.
} catch (err) {
@@ -209,6 +237,22 @@ export function useAppUpdate(githubUrl: string | null) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [githubUrl]);
+ useEffect(() => {
+ if (Platform.OS !== 'android') return;
+
+ const subscription = AppState.addEventListener('change', nextState => {
+ const previousState = appStateRef.current;
+ appStateRef.current = nextState;
+ if (previousState !== 'active' && nextState === 'active') {
+ void handleForegroundResume();
+ }
+ });
+
+ return () => subscription.remove();
+ // The handler reads current data through refs, so this subscription stays stable.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
// --- Actions ---------------------------------------------------------
const dismiss = useCallback(() => {
@@ -231,8 +275,60 @@ export function useAppUpdate(githubUrl: string | null) {
return;
}
+ await beginDownload(manifest);
+ }, [state.manifest]);
+
+ const cancel = useCallback(async () => {
+ if (state.downloadId == null) return;
+ // Leave cancelRef.current = true until a new accept() starts a fresh DL.
+ // Any in-flight pollUntilDone promise will see the flag and bail out in its .then.
+ cancelRef.current = true;
+ pollGenerationRef.current += 1;
+ try {
+ await cancelDownload(state.downloadId);
+ } catch (err) {
+ console.warn('[useAppUpdate] cancel failed', err);
+ }
+ await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
+ pendingRef.current = null;
+ setState({ ...initialState });
+ }, [state.downloadId]);
+
+ const openSettings = useCallback(async () => {
+ try {
+ await openInstallSettings();
+ } catch (err) {
+ console.warn('[useAppUpdate] openInstallSettings failed', err);
+ }
+ }, []);
+
+ const retry = useCallback(() => {
+ const current = stateRef.current;
+ const pending = pendingRef.current;
+ if (
+ current.error === 'install_denied' &&
+ current.manifest &&
+ pending &&
+ canReusePendingApk(pending, current.manifest.buildNumber)
+ ) {
+ cancelRef.current = false;
+ void verifyAndInstall(pending);
+ return;
+ }
+
+ setState(s => ({
+ ...s,
+ stage: s.manifest ? 'offered' : 'idle',
+ error: null,
+ }));
+ }, []);
+
+ // --- Internal helpers -------------------------------------------------
+
+ async function beginDownload(manifest: Manifest) {
// Fresh download — release any cancel lock from a previous session.
cancelRef.current = false;
+ pollGenerationRef.current += 1;
try {
const fileName = fileNameForBuild(manifest.buildNumber);
@@ -251,11 +347,13 @@ export function useAppUpdate(githubUrl: string | null) {
apkFilePath: filePath,
startedAt: new Date().toISOString(),
};
+ pendingRef.current = pending;
await AsyncStorage.setItem(
UPDATE_CHECK.PENDING_DOWNLOAD_KEY,
JSON.stringify(pending),
);
+ if (!mountedRef.current) return;
setState({
stage: 'downloading',
manifest,
@@ -267,50 +365,35 @@ export function useAppUpdate(githubUrl: string | null) {
resumeProgressLoop(pending);
} catch (err) {
console.warn('[useAppUpdate] enqueue failed', err);
- setState(s => ({ ...s, stage: 'error', error: 'network' }));
+ if (mountedRef.current) {
+ setState(s => ({ ...s, stage: 'error', error: 'network' }));
+ }
}
- }, [state.manifest]);
-
- const cancel = useCallback(async () => {
- if (state.downloadId == null) return;
- // Leave cancelRef.current = true until a new accept() starts a fresh DL.
- // Any in-flight pollUntilDone promise will see the flag and bail out in its .then.
- cancelRef.current = true;
- try {
- await cancelDownload(state.downloadId);
- } catch (err) {
- console.warn('[useAppUpdate] cancel failed', err);
- }
- await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
- setState({ ...initialState });
- }, [state.downloadId]);
-
- const openSettings = useCallback(async () => {
- try {
- await openInstallSettings();
- } catch (err) {
- console.warn('[useAppUpdate] openInstallSettings failed', err);
- }
- }, []);
-
- const retry = useCallback(() => {
- setState(s => ({ ...s, stage: s.manifest ? 'offered' : 'idle', error: null }));
- }, []);
-
- // --- Internal helpers -------------------------------------------------
+ }
function resumeProgressLoop(pending: PendingDownload) {
+ const generation = ++pollGenerationRef.current;
pollUntilDone(
pending.downloadId,
progress => {
+ if (generation !== pollGenerationRef.current) return;
setState(s =>
s.stage === 'downloading' ? { ...s, progress } : s,
);
},
- () => !cancelRef.current,
+ () =>
+ mountedRef.current &&
+ !cancelRef.current &&
+ generation === pollGenerationRef.current,
)
.then(final => {
- if (cancelRef.current) return;
+ if (
+ cancelRef.current ||
+ !mountedRef.current ||
+ generation !== pollGenerationRef.current
+ ) {
+ return;
+ }
if (final.status === 'successful') {
verifyAndInstall(pending);
} else {
@@ -318,12 +401,19 @@ export function useAppUpdate(githubUrl: string | null) {
}
})
.catch(err => {
+ if (
+ !mountedRef.current ||
+ generation !== pollGenerationRef.current
+ ) {
+ return;
+ }
console.warn('[useAppUpdate] poll error', err);
setState(s => ({ ...s, stage: 'error', error: 'unknown' }));
});
}
async function verifyAndInstall(pending: PendingDownload) {
+ pendingRef.current = pending;
setState(s => ({ ...s, stage: 'verifying' }));
try {
const hash = await computeSha256(pending.apkFilePath);
@@ -333,6 +423,7 @@ export function useAppUpdate(githubUrl: string | null) {
want: pending.targetSha256,
});
await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
+ pendingRef.current = null;
setState(s => ({ ...s, stage: 'error', error: 'sha_mismatch' }));
return;
}
@@ -353,6 +444,66 @@ export function useAppUpdate(githubUrl: string | null) {
}
}
+ async function handleForegroundResume() {
+ if (foregroundBusyRef.current || !mountedRef.current) return;
+
+ const current = stateRef.current;
+ if (
+ !current.manifest ||
+ (current.stage !== 'need_permission' && current.stage !== 'installing')
+ ) {
+ return;
+ }
+
+ foregroundBusyRef.current = true;
+ try {
+ const [installPermissionGranted, localBuildNumber] = await Promise.all([
+ canInstallApks(),
+ getLocalVersionCode().catch(() => 0),
+ ]);
+ if (
+ !mountedRef.current ||
+ stateRef.current.stage !== current.stage
+ ) {
+ return;
+ }
+
+ const action = decideUpdateForegroundAction({
+ stage: current.stage,
+ installPermissionGranted,
+ localBuildNumber,
+ targetBuildNumber: current.manifest.buildNumber,
+ });
+
+ if (action === 'continue_after_permission') {
+ await beginDownload(current.manifest);
+ return;
+ }
+
+ if (action === 'installed') {
+ await AsyncStorage.removeItem(UPDATE_CHECK.PENDING_DOWNLOAD_KEY);
+ pendingRef.current = null;
+ setState({ ...initialState });
+ return;
+ }
+
+ if (action === 'install_not_completed') {
+ setState(s => ({
+ ...s,
+ stage: 'error',
+ error: 'install_denied',
+ }));
+ }
+ } catch (err) {
+ console.warn('[useAppUpdate] foreground reconciliation failed', err);
+ if (mountedRef.current) {
+ setState(s => ({ ...s, stage: 'error', error: 'unknown' }));
+ }
+ } finally {
+ foregroundBusyRef.current = false;
+ }
+ }
+
return {
state,
dismiss,
diff --git a/app/src/injection/bridge-runtime.ts b/app/src/injection/bridge-runtime.ts
index e1015e1..9d0f108 100644
--- a/app/src/injection/bridge-runtime.ts
+++ b/app/src/injection/bridge-runtime.ts
@@ -1,3 +1,5 @@
+import { MEDIA_ENTRY_PATH_SOURCE } from './mediaProxyRouting';
+
/**
* Runtime bridge injecté dans le WebView AVANT le chargement de la page.
*
@@ -18,6 +20,9 @@ export function buildBridgeRuntime(): string {
// --- Pending requests ---
var _pendingRequests = {};
var _requestCounter = 0;
+ var _nativeWindowFetch =
+ typeof window.fetch === 'function' ? window.fetch.bind(window) : null;
+ var _mediaEntryPath = new RegExp(${JSON.stringify(MEDIA_ENTRY_PATH_SOURCE)}, 'i');
function generateId() {
return 'req_' + (++_requestCounter) + '_' + Date.now();
@@ -67,8 +72,78 @@ export function buildBridgeRuntime(): string {
return bytes.buffer;
}
+ function isLocalMediaProxyCandidate(details) {
+ var method = String(details.method || 'GET').toUpperCase();
+ var url = String(details.url || '').trim();
+ if (method !== 'GET' && method !== 'HEAD') return false;
+ if (!/^https:\\/\\//i.test(url)) return false;
+ if (/^https:\\/\\/(?:127\\.0\\.0\\.1|localhost)(?::|\\/)/i.test(url)) {
+ return false;
+ }
+ if (!_mediaEntryPath.test(url)) return false;
+
+ var headers = details.headers || {};
+ return Object.keys(headers).some(function(key) {
+ return /^(?:origin|referer|range)$/i.test(key);
+ });
+ }
+
+ function responseHeadersToString(headers) {
+ var headersStr = '';
+ if (headers && typeof headers.forEach === 'function') {
+ headers.forEach(function(value, key) {
+ headersStr += key + ': ' + value + '\\r\\n';
+ });
+ }
+ return headersStr;
+ }
+
+ async function tryLocalMediaProxy(details) {
+ if (!_nativeWindowFetch) {
+ throw new Error('Native WebView fetch unavailable');
+ }
+
+ var openResponse = await bridgeRequest({
+ type: 'GM_OPEN_MEDIA_PROXY',
+ url: details.url,
+ method: (details.method || 'GET').toUpperCase(),
+ headers: details.headers || {}
+ });
+ if (!openResponse.success || typeof openResponse.value !== 'string') {
+ throw new Error(openResponse.error || 'Local media proxy unavailable');
+ }
+
+ var localHeaders = {};
+ var originalHeaders = details.headers || {};
+ for (var key in originalHeaders) {
+ if (/^(?:accept|range)$/i.test(key)) {
+ localHeaders[key] = originalHeaders[key];
+ }
+ }
+
+ var response = await _nativeWindowFetch(openResponse.value, {
+ method: (details.method || 'GET').toUpperCase(),
+ headers: localHeaders
+ });
+ var responseBody;
+ if (details.responseType === 'arraybuffer') {
+ responseBody = await response.arrayBuffer();
+ } else {
+ responseBody = await response.text();
+ }
+
+ return {
+ status: response.status || 0,
+ statusText: response.statusText || '',
+ responseHeaders: responseHeadersToString(response.headers),
+ response: responseBody,
+ responseText: typeof responseBody === 'string' ? responseBody : '',
+ finalUrl: details.url
+ };
+ }
+
// --- GM_xmlhttpRequest ---
- function GM_xmlhttpRequest(details) {
+ function sendBridgeRequest(details) {
var headers = details.headers || {};
var bodyStr = null;
@@ -135,6 +210,34 @@ export function buildBridgeRuntime(): string {
return { abort: function() {} };
}
+ function GM_xmlhttpRequest(details) {
+ if (!isLocalMediaProxyCandidate(details)) {
+ return sendBridgeRequest(details);
+ }
+
+ var cancelled = false;
+ Promise.resolve()
+ .then(function() {
+ return tryLocalMediaProxy(details);
+ })
+ .then(function(response) {
+ if (!cancelled && details.onload) {
+ details.onload(response);
+ }
+ })
+ .catch(function() {
+ if (!cancelled) {
+ sendBridgeRequest(details);
+ }
+ });
+
+ return {
+ abort: function() {
+ cancelled = true;
+ }
+ };
+ }
+
// --- GM_getValue / GM_setValue / GM_deleteValue ---
// Version synchrone avec cache local + sync async vers le natif
var _storageCache = {};
diff --git a/app/src/injection/mediaProxyRouting.ts b/app/src/injection/mediaProxyRouting.ts
new file mode 100644
index 0000000..fb6785d
--- /dev/null
+++ b/app/src/injection/mediaProxyRouting.ts
@@ -0,0 +1,29 @@
+export interface MediaProxyCandidate {
+ url?: string;
+ method?: string;
+ headers?: Record;
+ responseType?: string;
+}
+
+export const MEDIA_ENTRY_PATH_SOURCE =
+ String.raw`\.(?:m3u8|mp4|m4v|m4s|mpd|ts|aac|m4a|vtt|srt)(?:$|[?#])`;
+
+const MEDIA_ENTRY_PATH = new RegExp(MEDIA_ENTRY_PATH_SOURCE, 'i');
+
+export function isLocalMediaProxyCandidate(
+ details: MediaProxyCandidate,
+): boolean {
+ const method = String(details.method || 'GET').toUpperCase();
+ const url = String(details.url || '').trim();
+
+ if (method !== 'GET' && method !== 'HEAD') return false;
+ if (!/^https:\/\//i.test(url)) return false;
+ if (/^https:\/\/(?:127\.0\.0\.1|localhost)(?::|\/)/i.test(url)) {
+ return false;
+ }
+ if (!MEDIA_ENTRY_PATH.test(url)) return false;
+
+ return Object.keys(details.headers || {}).some(key =>
+ /^(?:origin|referer|range)$/i.test(key),
+ );
+}
diff --git a/app/src/screens/BrowserScreen.tsx b/app/src/screens/BrowserScreen.tsx
index 1290ff2..998279c 100644
--- a/app/src/screens/BrowserScreen.tsx
+++ b/app/src/screens/BrowserScreen.tsx
@@ -7,8 +7,7 @@ import {
Platform,
Modal,
TouchableOpacity,
- Image,
- Animated,
+ ActivityIndicator,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { WebViewNavigation } from 'react-native-webview';
@@ -45,8 +44,6 @@ export default function BrowserScreen() {
const [currentUrl, setCurrentUrl] = useState('');
const [dnsEnabled, setDnsEnabled] = useState(false);
const [settingsVisible, setSettingsVisible] = useState(false);
- const [webViewReady, setWebViewReady] = useState(false);
- const splashFade = useRef(new Animated.Value(1)).current;
const activeUrl = urlChain[mirrorIndex] ?? '';
@@ -98,15 +95,6 @@ export default function BrowserScreen() {
[activeUrl, mirrorIndex, urlChain.length],
);
- const onWebViewLoadEnd = useCallback(() => {
- if (webViewReady) return;
- Animated.timing(splashFade, {
- toValue: 0,
- duration: 400,
- useNativeDriver: true,
- }).start(() => setWebViewReady(true));
- }, [webViewReady, splashFade]);
-
const closeSettings = useCallback(() => {
setSettingsVisible(false);
AsyncStorage.getItem('dns_enabled').then(val => {
@@ -117,34 +105,36 @@ export default function BrowserScreen() {
const onRetry = useCallback(async () => {
setAllMirrorsFailed(false);
setMirrorIndex(0);
- setWebViewReady(false);
- splashFade.setValue(1);
await refresh();
- }, [refresh, splashFade]);
+ }, [refresh]);
- const showWebView = !isLoading && !!config && !allMirrorsFailed;
- const showSplash = (!webViewReady || isLoading || !config) && !allMirrorsFailed;
+ if (isLoading || !config) {
+ return (
+
+
+
+ );
+ }
+
+ if (allMirrorsFailed) {
+ return (
+
+ );
+ }
return (
- {showWebView && (
-
-
-
- )}
+
+
+
- {allMirrorsFailed && config && (
-
- )}
-
- {!toolbarHidden && showWebView && (
+ {!toolbarHidden && (
)}
- {showWebView && (
-
-
-
-
- Fermer
-
- Paramètres
-
-
-
+
+
+
+
+ Fermer
+
+ Paramètres
+
-
- )}
+
+
+
- {navBarHidden && showWebView && (
- setSettingsVisible(true)} />
- )}
-
- {showSplash && (
-
-
-
- )}
+ {navBarHidden && setSettingsVisible(true)} />}
);
}
@@ -205,6 +179,10 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: '#0a0a0a',
},
+ centered: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
webViewContainer: {
flex: 1,
},
@@ -235,13 +213,4 @@ const styles = StyleSheet.create({
fontSize: 15,
fontWeight: '500',
},
- splash: {
- backgroundColor: '#B5302C',
- justifyContent: 'center',
- alignItems: 'center',
- },
- splashLogo: {
- width: 150,
- height: 150,
- },
});
diff --git a/app/src/screens/SettingsScreen.tsx b/app/src/screens/SettingsScreen.tsx
index 1d60a07..829b4ea 100644
--- a/app/src/screens/SettingsScreen.tsx
+++ b/app/src/screens/SettingsScreen.tsx
@@ -20,7 +20,7 @@ import { getLocalVersionName } from '../services/apkInstaller';
const { DnsModule } = NativeModules;
const M3U8_KEYS = ['voe','fsvid','vidzy','vidmoly','sibnet','uqload','doodstream','seekstreaming'] as const;
-const LIVETV_KEYS = ['linkzy','wiflix','sosplay','livetv','matches'] as const;
+const LIVETV_KEYS = ['northlive','wiflix','sosplay','livetv','matches'] as const;
type ExtractionPrefs = {
version: 1;
diff --git a/app/src/screens/UpdateScreen.tsx b/app/src/screens/UpdateScreen.tsx
index 14e942b..48ac46c 100644
--- a/app/src/screens/UpdateScreen.tsx
+++ b/app/src/screens/UpdateScreen.tsx
@@ -48,7 +48,7 @@ function errorLabel(error: UpdateError | null): string {
case 'disk':
return 'Espace insuffisant sur l\'appareil';
case 'install_denied':
- return 'Installation refusée ou bloquée';
+ return 'Installation annulée ou bloquée. Tu peux réessayer sans retélécharger.';
case 'unknown':
default:
return 'Erreur inattendue';
diff --git a/app/src/services/bridge.ts b/app/src/services/bridge.ts
index dd7a1e6..02daf98 100644
--- a/app/src/services/bridge.ts
+++ b/app/src/services/bridge.ts
@@ -7,6 +7,7 @@
*/
import { type RefObject } from 'react';
+import { NativeModules } from 'react-native';
import type WebView from 'react-native-webview';
import {
getCurrentDeviceName,
@@ -177,7 +178,12 @@ async function handleCastShimMessage(
export interface BridgeRequest {
id: string;
- type: 'GM_FETCH' | 'GM_GET_VALUE' | 'GM_SET_VALUE' | 'GM_DELETE_VALUE';
+ type:
+ | 'GM_FETCH'
+ | 'GM_OPEN_MEDIA_PROXY'
+ | 'GM_GET_VALUE'
+ | 'GM_SET_VALUE'
+ | 'GM_DELETE_VALUE';
url?: string;
method?: string;
headers?: Record;
@@ -204,12 +210,78 @@ const storage = new Map();
function parseResponseHeaders(headers: Headers): Record {
const result: Record = {};
- headers.forEach((value, key) => {
+ headers.forEach((value: string, key: string) => {
result[key.toLowerCase()] = value;
});
return result;
}
+interface MediaProxyNativeModule {
+ open: (
+ url: string,
+ method: string,
+ headers: Record,
+ ) => Promise;
+}
+
+async function handleGMOpenMediaProxy(
+ req: BridgeRequest,
+): Promise {
+ const method = String(req.method || 'GET').toUpperCase();
+ if (
+ !req.url ||
+ !/^https:\/\//i.test(req.url) ||
+ (method !== 'GET' && method !== 'HEAD')
+ ) {
+ return {
+ id: req.id,
+ success: false,
+ error: 'Invalid local media proxy request',
+ };
+ }
+
+ const headers: Record = {};
+ for (const [key, value] of Object.entries(req.headers || {}).slice(0, 32)) {
+ if (
+ key.length <= 128 &&
+ value.length <= 8192 &&
+ !/[\r\n]/.test(key) &&
+ !/[\r\n]/.test(value)
+ ) {
+ headers[key] = value;
+ }
+ }
+
+ const mediaProxy = NativeModules.MediaProxy as
+ | MediaProxyNativeModule
+ | undefined;
+ if (!mediaProxy?.open) {
+ return {
+ id: req.id,
+ success: false,
+ error: 'Local media proxy unavailable',
+ };
+ }
+
+ try {
+ const localUrl = await mediaProxy.open(req.url, method, headers);
+ if (!/^http:\/\/127\.0\.0\.1:\d+\/p\//i.test(localUrl)) {
+ throw new Error('Invalid loopback response');
+ }
+ return {
+ id: req.id,
+ success: true,
+ value: localUrl,
+ };
+ } catch {
+ return {
+ id: req.id,
+ success: false,
+ error: 'Local media proxy unavailable',
+ };
+ }
+}
+
const HEADER_RULES: Array<{ match: RegExp; headers: Record }> = [
{
match: /fsvid\.lol/i,
@@ -381,6 +453,9 @@ export async function handleBridgeMessage(
let response: BridgeResponse;
switch (req.type) {
+ case 'GM_OPEN_MEDIA_PROXY':
+ response = await handleGMOpenMediaProxy(req);
+ break;
case 'GM_FETCH':
response = await handleGMFetch(req);
break;
diff --git a/app/src/services/cast.ts b/app/src/services/cast.ts
index cc0f04f..d135afd 100644
--- a/app/src/services/cast.ts
+++ b/app/src/services/cast.ts
@@ -1,4 +1,4 @@
-import { DeviceEventEmitter, NativeModules, Platform } from 'react-native';
+import { DeviceEventEmitter, NativeModules } from 'react-native';
export type CastSessionState = 'idle' | 'starting' | 'connected' | 'ending';
@@ -29,7 +29,6 @@ function ensureModule(): CastModuleType {
}
export async function isCastSupported(): Promise {
- if (Platform.OS !== 'android') return false;
try {
return await ensureModule().isSupported();
} catch (err) {
diff --git a/app/tests/mediaProxyBridgeContract.test.mjs b/app/tests/mediaProxyBridgeContract.test.mjs
new file mode 100644
index 0000000..978a022
--- /dev/null
+++ b/app/tests/mediaProxyBridgeContract.test.mjs
@@ -0,0 +1,36 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { test } from 'node:test';
+
+const root = new URL('../', import.meta.url);
+
+async function read(relativePath) {
+ return readFile(new URL(relativePath, root), 'utf8');
+}
+
+test('TypeScript bridge exposes the GM_OPEN_MEDIA_PROXY native contract', async () => {
+ const bridge = await read('src/services/bridge.ts');
+
+ assert.match(bridge, /GM_OPEN_MEDIA_PROXY/);
+ assert.match(bridge, /NativeModules\.MediaProxy/);
+ assert.match(bridge, /\.open\s*\(\s*req\.url/);
+ assert.match(bridge, /case\s+['"]GM_FETCH['"]/);
+});
+
+test('Android registers the MediaProxy native package', async () => {
+ const application = await read(
+ 'android/app/src/main/java/com/movix/app/MainApplication.kt',
+ );
+ const packageSource = await read(
+ 'android/app/src/main/java/com/movix/app/proxy/MediaProxyPackage.kt',
+ );
+ const moduleSource = await read(
+ 'android/app/src/main/java/com/movix/app/proxy/MediaProxyModule.kt',
+ );
+
+ assert.match(application, /add\(MediaProxyPackage\(\)\)/);
+ assert.match(packageSource, /MediaProxyModule\(reactContext\)/);
+ assert.match(moduleSource, /override fun getName\(\)\s*=\s*"MediaProxy"/);
+ assert.match(moduleSource, /fun open\(/);
+ assert.match(moduleSource, /server\.open\(/);
+});
diff --git a/app/tests/mediaProxyRouting.test.mjs b/app/tests/mediaProxyRouting.test.mjs
new file mode 100644
index 0000000..a7cc48f
--- /dev/null
+++ b/app/tests/mediaProxyRouting.test.mjs
@@ -0,0 +1,88 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { test } from 'node:test';
+import ts from 'typescript';
+
+async function loadRoutingModule() {
+ const sourceUrl = new URL('../src/injection/mediaProxyRouting.ts', import.meta.url);
+ const source = await readFile(sourceUrl, 'utf8');
+ const transpiled = ts.transpileModule(source, {
+ compilerOptions: {
+ module: ts.ModuleKind.ESNext,
+ target: ts.ScriptTarget.ES2022,
+ },
+ fileName: sourceUrl.pathname,
+ });
+ const dataUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString('base64')}`;
+ return import(dataUrl);
+}
+
+test('routes protected HLS and direct media entry URLs to the local proxy', async () => {
+ const { isLocalMediaProxyCandidate } = await loadRoutingModule();
+
+ for (const url of [
+ 'https://u14.vidzy.cc/hls/movie/master.m3u8?token=abc',
+ 'https://r1.fsvid.lol/hls/movie/segment.ts',
+ 'https://cdn.example/video/v.mp4?token=abc',
+ ]) {
+ assert.equal(isLocalMediaProxyCandidate({
+ url,
+ method: 'GET',
+ responseType: 'arraybuffer',
+ headers: {
+ Referer: 'https://vidzy.org/',
+ Origin: 'https://vidzy.org',
+ },
+ }), true, url);
+ }
+});
+
+test('matches protected headers case-insensitively', async () => {
+ const { isLocalMediaProxyCandidate } = await loadRoutingModule();
+
+ assert.equal(isLocalMediaProxyCandidate({
+ url: 'https://free.finepulfe.xyz/movie/master.m3u8',
+ method: 'get',
+ responseType: 'arraybuffer',
+ headers: {
+ referer: 'https://purstream.mx/',
+ origin: 'https://purstream.mx',
+ },
+ }), true);
+});
+
+test('keeps extraction pages, APIs, posts, and unprotected media on GM_FETCH', async () => {
+ const { isLocalMediaProxyCandidate } = await loadRoutingModule();
+
+ const requests = [
+ {
+ url: 'https://vidzy.org/embed-id.html',
+ method: 'GET',
+ headers: { Referer: 'https://vidzy.org/' },
+ },
+ {
+ url: 'https://api.movix.show/api/purstream/movie/550/stream',
+ method: 'GET',
+ headers: { Origin: 'https://movix.show' },
+ },
+ {
+ url: 'https://u14.vidzy.cc/hls/movie/master.m3u8',
+ method: 'POST',
+ headers: { Referer: 'https://vidzy.org/' },
+ },
+ {
+ url: 'https://cdn.example/movie/master.m3u8',
+ method: 'GET',
+ headers: { Accept: '*/*' },
+ },
+ {
+ url: 'http://127.0.0.1:28123/p/session',
+ method: 'GET',
+ headers: { Referer: 'https://vidzy.org/' },
+ },
+ ];
+
+ for (const request of requests) {
+ assert.equal(isLocalMediaProxyCandidate(request), false, request.url);
+ }
+});
diff --git a/app/tests/mediaProxyRuntime.test.mjs b/app/tests/mediaProxyRuntime.test.mjs
new file mode 100644
index 0000000..89312c6
--- /dev/null
+++ b/app/tests/mediaProxyRuntime.test.mjs
@@ -0,0 +1,163 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { test } from 'node:test';
+import vm from 'node:vm';
+import ts from 'typescript';
+
+async function loadBridgeRuntimeBuilder() {
+ const sourceUrl = new URL('../src/injection/bridge-runtime.ts', import.meta.url);
+ let source = await readFile(sourceUrl, 'utf8');
+ source = source.replace(
+ /import\s+\{\s*MEDIA_ENTRY_PATH_SOURCE\s*\}\s+from\s+['"]\.\/mediaProxyRouting['"];\s*/,
+ `const MEDIA_ENTRY_PATH_SOURCE = ${JSON.stringify(String.raw`\.(?:m3u8|mp4|m4v|m4s|mpd|ts|aac|m4a|vtt|srt)(?:$|[?#])`)};\n`,
+ );
+ const transpiled = ts.transpileModule(source, {
+ compilerOptions: {
+ module: ts.ModuleKind.ESNext,
+ target: ts.ScriptTarget.ES2022,
+ },
+ fileName: sourceUrl.pathname,
+ });
+ const dataUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString('base64')}`;
+ return import(dataUrl);
+}
+
+function createRuntimeHarness(buildBridgeRuntime, { rejectOpen = false } = {}) {
+ const posted = [];
+ const nativeFetches = [];
+ const listeners = new Map();
+
+ class CustomEvent {
+ constructor(type, init = {}) {
+ this.type = type;
+ this.detail = init.detail;
+ }
+ }
+
+ const window = {
+ __MOVIX_BRIDGE_READY: false,
+ addEventListener(type, handler) {
+ if (!listeners.has(type)) listeners.set(type, new Set());
+ listeners.get(type).add(handler);
+ },
+ dispatchEvent(event) {
+ for (const handler of listeners.get(event.type) || []) handler(event);
+ },
+ fetch: async url => {
+ nativeFetches.push(String(url));
+ return {
+ status: 206,
+ statusText: 'Partial Content',
+ url: String(url),
+ headers: new Map([
+ ['content-type', 'application/vnd.apple.mpegurl'],
+ ['content-range', 'bytes 0-2/3'],
+ ]),
+ arrayBuffer: async () => Uint8Array.from([1, 2, 3]).buffer,
+ text: async () => 'LOCAL',
+ };
+ },
+ };
+
+ window.ReactNativeWebView = {
+ postMessage(raw) {
+ const message = JSON.parse(raw);
+ posted.push(message);
+ queueMicrotask(() => {
+ if (message.type === 'GM_OPEN_MEDIA_PROXY') {
+ window.dispatchEvent(new CustomEvent('__MOVIX_BRIDGE_RESPONSE', {
+ detail: rejectOpen
+ ? { id: message.id, success: false, error: 'unavailable' }
+ : {
+ id: message.id,
+ success: true,
+ value: 'http://127.0.0.1:28123/p/opaque-session',
+ },
+ }));
+ return;
+ }
+
+ window.dispatchEvent(new CustomEvent('__MOVIX_BRIDGE_RESPONSE', {
+ detail: {
+ id: message.id,
+ success: true,
+ status: 200,
+ statusText: 'OK',
+ headers: { 'content-type': 'application/octet-stream' },
+ body: 'BAUG',
+ finalUrl: message.url,
+ },
+ }));
+ });
+ },
+ };
+
+ const context = vm.createContext({
+ window,
+ CustomEvent,
+ Uint8Array,
+ ArrayBuffer,
+ URLSearchParams,
+ Promise,
+ console,
+ atob,
+ btoa,
+ setTimeout: () => 1,
+ clearTimeout: () => {},
+ });
+ vm.runInContext(buildBridgeRuntime(), context);
+ return { window, posted, nativeFetches };
+}
+
+function gmRequest(window, details) {
+ return new Promise((resolve, reject) => {
+ window.GM_xmlhttpRequest({
+ responseType: 'arraybuffer',
+ ...details,
+ onload: resolve,
+ onerror: reject,
+ });
+ });
+}
+
+test('protected media uses the native loopback proxy without a Base64 GM_FETCH', async () => {
+ const { buildBridgeRuntime } = await loadBridgeRuntimeBuilder();
+ const harness = createRuntimeHarness(buildBridgeRuntime);
+
+ const response = await gmRequest(harness.window, {
+ method: 'GET',
+ url: 'https://u14.vidzy.cc/movie/master.m3u8?token=abc',
+ headers: {
+ Origin: 'https://vidzy.org',
+ Referer: 'https://vidzy.org/',
+ },
+ });
+
+ assert.deepEqual(harness.posted.map(entry => entry.type), [
+ 'GM_OPEN_MEDIA_PROXY',
+ ]);
+ assert.deepEqual(harness.nativeFetches, [
+ 'http://127.0.0.1:28123/p/opaque-session',
+ ]);
+ assert.deepEqual([...new Uint8Array(response.response)], [1, 2, 3]);
+});
+
+test('falls back to GM_FETCH when the native proxy is unavailable', async () => {
+ const { buildBridgeRuntime } = await loadBridgeRuntimeBuilder();
+ const harness = createRuntimeHarness(buildBridgeRuntime, { rejectOpen: true });
+
+ const response = await gmRequest(harness.window, {
+ method: 'GET',
+ url: 'https://r1.fsvid.lol/movie/master.m3u8',
+ headers: {
+ Origin: 'https://fsvid.lol',
+ Referer: 'https://fsvid.lol/',
+ },
+ });
+
+ assert.deepEqual(harness.posted.map(entry => entry.type), [
+ 'GM_OPEN_MEDIA_PROXY',
+ 'GM_FETCH',
+ ]);
+ assert.deepEqual([...new Uint8Array(response.response)], [4, 5, 6]);
+});
diff --git a/app/tests/releaseMetadata.test.mjs b/app/tests/releaseMetadata.test.mjs
new file mode 100644
index 0000000..473c192
--- /dev/null
+++ b/app/tests/releaseMetadata.test.mjs
@@ -0,0 +1,63 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+const testsDirectory = path.dirname(fileURLToPath(import.meta.url));
+const appDirectory = path.resolve(testsDirectory, "..");
+const release = JSON.parse(
+ fs.readFileSync(path.join(appDirectory, "version.json"), "utf8"),
+);
+const appConfig = JSON.parse(
+ fs.readFileSync(path.join(appDirectory, "app.json"), "utf8"),
+);
+const gradle = fs.readFileSync(
+ path.join(appDirectory, "android", "app", "build.gradle"),
+ "utf8",
+);
+
+const normalized = (value) =>
+ value
+ .normalize("NFD")
+ .replace(/\p{Diacritic}/gu, "")
+ .toLowerCase();
+
+test("release metadata targets the mandatory 2.5.4 build 13 update", () => {
+ assert.equal(release.version, "2.5.4");
+ assert.equal(release.buildNumber, 13);
+ assert.equal(release.mandatory, true);
+ assert.equal(
+ release.apkUrl,
+ "https://raw.githubusercontent.com/movixcorp/MovixOpenSource/main/app/movix-android.apk",
+ );
+
+ assert.equal(appConfig.version, "2.5.4");
+ assert.equal(appConfig.buildNumber, "13");
+ assert.match(gradle, /\bversionCode\s+13\b/);
+ assert.match(gradle, /\bversionName\s+"2\.5\.4"/);
+});
+
+test("French release notes aggregate the previous and current fixes", () => {
+ const notes = normalized(release.releaseNotes.fr);
+
+ for (const expected of [
+ "nexus",
+ "bravo",
+ "uqload",
+ "mise a jour automatique",
+ "ecran",
+ "vidzy",
+ "fsvid",
+ "proxy",
+ ]) {
+ assert.ok(notes.includes(expected), `releaseNotes.fr doit contenir "${expected}"`);
+ }
+});
+
+test("APK integrity metadata remains publishable", () => {
+ assert.ok(Number.isSafeInteger(release.apkSizeBytes));
+ assert.ok(release.apkSizeBytes > 0);
+ assert.match(release.apkSha256, /^[a-f0-9]{64}$/);
+ assert.match(release.releasedAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
+});
diff --git a/app/tests/updateResume.test.mjs b/app/tests/updateResume.test.mjs
new file mode 100644
index 0000000..be0c5d0
--- /dev/null
+++ b/app/tests/updateResume.test.mjs
@@ -0,0 +1,75 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ canReusePendingApk,
+ decideUpdateForegroundAction,
+} from '../src/hooks/updateResume.ts';
+
+test('continues automatically after the install permission is granted', () => {
+ assert.equal(
+ decideUpdateForegroundAction({
+ stage: 'need_permission',
+ installPermissionGranted: true,
+ localBuildNumber: 10,
+ targetBuildNumber: 11,
+ }),
+ 'continue_after_permission',
+ );
+ assert.equal(
+ decideUpdateForegroundAction({
+ stage: 'need_permission',
+ installPermissionGranted: false,
+ localBuildNumber: 10,
+ targetBuildNumber: 11,
+ }),
+ 'none',
+ );
+});
+
+test('distinguishes a completed install from an installer cancellation', () => {
+ assert.equal(
+ decideUpdateForegroundAction({
+ stage: 'installing',
+ installPermissionGranted: true,
+ localBuildNumber: 11,
+ targetBuildNumber: 11,
+ }),
+ 'installed',
+ );
+ assert.equal(
+ decideUpdateForegroundAction({
+ stage: 'installing',
+ installPermissionGranted: true,
+ localBuildNumber: 10,
+ targetBuildNumber: 11,
+ }),
+ 'install_not_completed',
+ );
+});
+
+test('only reuses a matching, verified APK download', () => {
+ const validPending = {
+ downloadId: 42,
+ targetBuildNumber: 11,
+ targetVersion: '2.5.2',
+ targetSha256: 'A'.repeat(64),
+ apkFilePath: 'C:\\downloads\\movix-android-11.apk',
+ startedAt: '2026-07-27T12:00:00.000Z',
+ };
+
+ assert.equal(canReusePendingApk(validPending, 11), true);
+ assert.equal(canReusePendingApk(validPending, 12), false);
+ assert.equal(
+ canReusePendingApk({ ...validPending, apkFilePath: '' }, 11),
+ false,
+ );
+ assert.equal(
+ canReusePendingApk({ ...validPending, apkFilePath: 'movix.zip' }, 11),
+ false,
+ );
+ assert.equal(
+ canReusePendingApk({ ...validPending, targetSha256: 'bad' }, 11),
+ false,
+ );
+});
diff --git a/app/version.json b/app/version.json
index c90c185..c51fd50 100644
--- a/app/version.json
+++ b/app/version.json
@@ -1,13 +1,13 @@
{
- "version": "2.5.3",
- "buildNumber": 12,
+ "version": "2.5.4",
+ "buildNumber": 13,
"apkUrl": "https://raw.githubusercontent.com/movixcorp/MovixOpenSource/main/app/movix-android.apk",
- "apkSizeBytes": 72023883,
- "apkSha256": "6e63593a1c47cee9cf60414219e4e05ef11c7a72e77256ebaffb65af1cc8e977",
- "mandatory": false,
- "releasedAt": "2026-05-10T09:47:30.589Z",
+ "apkSizeBytes": 72100426,
+ "apkSha256": "c87f2580fd55e136859f3171bdc6b4cd7bb1f5fffeecf8b60eef791665c8d3ff",
+ "mandatory": true,
+ "releasedAt": "2026-07-27T12:41:10.712Z",
"releaseNotes": {
- "fr": "Correction extraction du lecteur Uqload\nL'écran se s'etteint plus lors de l'utilisation de l'app",
- "en": ""
+ "fr": "Correction des lecteurs Nexus et Bravo.\nCorrection de l'extraction Uqload.\nCorrection de la mise à jour automatique.\nL'écran ne s'éteint plus pendant l'utilisation de l'application.\nCorrection des proxys locaux Vidzy, Fsvid, Bravo et des autres sources protégées.",
+ "en": "Fixed Nexus and Bravo players.\nFixed Uqload extraction.\nFixed automatic update installation.\nThe screen now stays awake while the app is in use.\nFixed local proxies for Vidzy, Fsvid, Bravo, and other protected media sources."
}
}
diff --git a/extension/Chrome/extractors.js b/extension/Chrome/extractors.js
index 15c6bfe..542da8f 100644
--- a/extension/Chrome/extractors.js
+++ b/extension/Chrome/extractors.js
@@ -56,6 +56,63 @@ const caches = {
// Dean Edwards packer signature — split to avoid Chrome Web Store code scanner false positives
const PACKER_MARKER = 'ev' + 'al(func' + 'tion(p,a,c,k,e,';
+const PACKER_SIGNATURE_PATTERN = new RegExp(
+ 'ev' + 'al\\s*\\(\\s*function\\s*\\(\\s*p\\s*,\\s*a\\s*,\\s*c\\s*,\\s*k\\s*,\\s*e\\s*,\\s*d\\s*\\)'
+);
+
+const UQLOAD_ROOT_DOMAINS = Object.freeze([
+ 'uqload.is',
+ 'uqload.bz',
+ 'uqload.cx',
+ 'uqload.com',
+ 'uqload.net',
+ 'uqload.org',
+ 'uqload.to',
+ 'uqload.io',
+ 'uqload.co',
+]);
+
+function getUqloadRootDomain(hostname) {
+ const host = String(hostname || '').toLowerCase().replace(/\.$/, '');
+ return UQLOAD_ROOT_DOMAINS.find(
+ root => host === root || host.endsWith(`.${root}`)
+ ) || null;
+}
+
+function parseAllowedUqloadUrl(rawUrl) {
+ let parsed;
+ try {
+ parsed = new URL(String(rawUrl || '').trim());
+ } catch {
+ throw new Error('Invalid Uqload URL');
+ }
+
+ if (
+ parsed.protocol !== 'https:' ||
+ parsed.username ||
+ parsed.password ||
+ (parsed.port && parsed.port !== '443') ||
+ !getUqloadRootDomain(parsed.hostname)
+ ) {
+ throw new Error('Invalid Uqload URL');
+ }
+ return parsed;
+}
+
+function normalizeUqloadEmbedUrl(rawUrl) {
+ const parsed = parseAllowedUqloadUrl(rawUrl);
+ const lastPart = parsed.pathname.split('/').filter(Boolean).pop() || '';
+ const videoId = lastPart.replace(/^embed-/i, '').replace(/\.html$/i, '');
+ if (!/^[a-z0-9_-]+$/i.test(videoId)) {
+ throw new Error('Invalid Uqload URL');
+ }
+ return `${parsed.origin}/embed-${videoId}.html`;
+}
+
+function getUqloadSiteOrigin(rawUrl) {
+ const parsed = parseAllowedUqloadUrl(rawUrl);
+ return `https://${getUqloadRootDomain(parsed.hostname)}`;
+}
function md5Hash(str) {
// Simple hash for cache keys (not cryptographic, just for dedup)
@@ -199,10 +256,10 @@ function decodeDeanEdwardsPacker(packedScript, radix, keywordCount, keywords) {
* packed block was found
*/
function decodePackedScriptFromHtml(html) {
- // Step 1: Locate the packed script marker in the HTML
- const packerMarker = PACKER_MARKER;
- const markerIndex = html.indexOf(packerMarker);
- if (markerIndex === -1) return null;
+ // Step 1: Locate the packed script marker while tolerating formatter whitespace.
+ const markerMatch = PACKER_SIGNATURE_PATTERN.exec(html);
+ if (!markerMatch) return null;
+ const markerIndex = markerMatch.index;
// Step 2: Find the .split('|') call that marks the end of the keyword
// list — this tells us where the packed block ends
@@ -227,11 +284,47 @@ function decodePackedScriptFromHtml(html) {
const radix = parseInt(match[2]);
const keywordCount = parseInt(match[3]);
const keywords = match[4].split('|');
+ if (
+ radix < 2 ||
+ radix > 62 ||
+ keywordCount < 0 ||
+ keywordCount > 10000 ||
+ keywordCount > keywords.length
+ ) {
+ return null;
+ }
// Step 6: Decode and return the original script
return decodeDeanEdwardsPacker(packedTemplate, radix, keywordCount, keywords);
}
+function extractUqloadMediaUrl(html) {
+ const candidates = [];
+ const collect = value => {
+ const normalized = String(value || '').replace(/\\\//g, '/');
+ for (const match of normalized.matchAll(/https:\/\/[^\s"'\\<>]+/gi)) {
+ const candidate = match[0].replace(/[),;]+$/, '');
+ try {
+ parseAllowedUqloadUrl(candidate);
+ candidates.push(candidate);
+ } catch {
+ // Ignore URLs outside the Uqload domain allowlist.
+ }
+ }
+ };
+
+ collect(html);
+ const decoded = decodePackedScriptFromHtml(html);
+ if (decoded) collect(decoded);
+
+ return (
+ candidates.find(url => /\/master\.m3u8(?:[?#]|$)/i.test(url)) ||
+ candidates.find(url => /\.m3u8(?:[?#]|$)/i.test(url)) ||
+ candidates.find(url => /\/v\.mp4(?:[?#]|$)/i.test(url)) ||
+ null
+ );
+}
+
/**
* Extract JSON from VOE HTML
*/
@@ -704,7 +797,7 @@ async function extractSibnet(sibnetUrl) {
}
/**
- * Extract MP4 from Uqload embed
+ * Extract HLS or MP4 from Uqload embed
*/
async function extractUqload(uqloadUrl) {
console.log(`[EXT-UQLOAD] Extracting from: ${uqloadUrl}`);
@@ -713,29 +806,21 @@ async function extractUqload(uqloadUrl) {
const cached = caches.uqload.get(cacheKey);
if (cached) return { ...cached, fromCache: true };
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 5000);
+
try {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), 3000);
-
- // Normalize URL
- let normalized = uqloadUrl.replace(/uqload\.(cx|com|net|co)/gi, 'uqload.bz');
-
- // Validate and format
- const parts = normalized.split('/');
- const base = parts.slice(0, -1).join('/') || 'https://uqload.bz';
- let videoId = parts[parts.length - 1];
-
- if (!videoId.includes('.html')) videoId += '.html';
- if (!videoId.includes('embed-')) videoId = 'embed-' + videoId;
- const fullUrl = `${base}/${videoId}`;
-
+ const fullUrl = normalizeUqloadEmbedUrl(uqloadUrl);
+ const siteOrigin = getUqloadSiteOrigin(fullUrl);
const headers = {
'User-Agent': 'Mozilla/5.0 Chrome/91.0.0.0',
- 'Accept': 'text/html,*/*'
+ 'Accept': 'text/html,*/*',
+ 'Referer': `${siteOrigin}/`,
+ 'Origin': siteOrigin,
};
- // Try embed and non-embed versions
- const urls = [fullUrl, fullUrl.replace('embed-', '')];
+ // Try embed and non-embed versions without leaving the validated host.
+ const urls = [fullUrl, fullUrl.replace('/embed-', '/')];
let html = null;
for (const url of urls) {
@@ -745,31 +830,25 @@ async function extractUqload(uqloadUrl) {
html = await resp.text();
break;
}
- } catch { continue; }
+ } catch {
+ continue;
+ }
}
- clearTimeout(timer);
if (!html) return { success: false, error: 'Uqload: Could not fetch page' };
if (html.includes('File was deleted')) return { success: false, error: 'Uqload: File was deleted' };
- // Préférer le HLS master.m3u8 (multi-bitrate) au mp4 single-quality
- const m3u8Matches = html.match(/https?:\/\/[^"'\s]+\/master\.m3u8/g) || html.match(/https?:\/\/[^"'\s]+\.m3u8/g);
- let videoUrl = m3u8Matches?.[0];
-
- if (!videoUrl) {
- const mp4Matches = html.match(/https?:\/\/.+\/v\.mp4/g);
- videoUrl = mp4Matches?.[0];
- }
-
+ const videoUrl = extractUqloadMediaUrl(html);
if (!videoUrl) return { success: false, error: 'Uqload: video URL not found' };
const result = { m3u8Url: videoUrl, success: true, source: 'uqload' };
caches.uqload.set(cacheKey, result);
return result;
-
} catch (e) {
console.error('[EXT-UQLOAD] Error:', e);
return { success: false, error: e.message || 'Uqload extraction failed' };
+ } finally {
+ clearTimeout(timer);
}
}
@@ -942,7 +1021,7 @@ const EMBED_PATTERNS = {
vidzy: url => url.toLowerCase().includes('vidzy'),
vidmoly: url => url.toLowerCase().includes('vidmoly'),
sibnet: url => url.toLowerCase().includes('sibnet.ru'),
- uqload: url => /uqload\.(cx|com|bz|net|org|to|io|co)/i.test(url),
+ uqload: url => /uqload\.(is|cx|com|bz|net|org|to|io|co)/i.test(url),
doodstream: url => {
const lower = url.toLowerCase();
return lower.includes('d0000d.com') || lower.includes('doodstream.com') || lower.includes('dood.')
@@ -1062,6 +1141,7 @@ async function setupHeadersForService(type, url, referer) {
// - Embed page (fsvid.lol/embed-xxx) → fs12.lol (required by fsvid to serve content)
// - CDN/M3U8 (s1.fsvid.lol, s2.fsvid.lol, etc.) → fsvid.lol (required by CDN)
let fsvidHeaders;
+ let uqloadHeaders;
if (type === 'fsvid' && url) {
try {
const hostname = new URL(url).hostname;
@@ -1076,6 +1156,14 @@ async function setupHeadersForService(type, url, referer) {
fsvidHeaders = { 'Referer': 'https://fsvid.lol/', 'Origin': 'https://fsvid.lol' };
}
}
+ if (type === 'uqload' && url) {
+ try {
+ const origin = getUqloadSiteOrigin(url);
+ uqloadHeaders = { 'Referer': `${origin}/`, 'Origin': origin };
+ } catch {
+ return null;
+ }
+ }
const headerMap = {
voe: { 'Referer': 'https://voe.sx/', 'Origin': 'https://voe.sx' },
@@ -1083,7 +1171,7 @@ async function setupHeadersForService(type, url, referer) {
vidzy: { 'Referer': 'https://vidzy.org/', 'Origin': 'https://vidzy.org' },
vidmoly: { 'Referer': 'https://voirdrama.to/', 'Origin': 'https://voirdrama.to' },
sibnet: { 'Referer': 'https://video.sibnet.ru/', 'Origin': 'https://video.sibnet.ru' },
- uqload: { 'Referer': 'https://uqload.bz/', 'Origin': 'https://uqload.bz' },
+ uqload: uqloadHeaders,
doodstream: { 'Referer': referer || 'https://d0000d.com/', 'Origin': referer ? new URL(referer).origin : 'https://d0000d.com' },
seekstreaming: { 'Referer': referer || 'https://lpayer.embed4me.com/', 'Origin': referer ? new URL(referer).origin : 'https://lpayer.embed4me.com' },
cinep: { 'Referer': 'https://purstream.mx/', 'Origin': 'https://purstream.mx' },
diff --git a/extension/Chrome/manifest.json b/extension/Chrome/manifest.json
index 834eefc..92c743a 100644
--- a/extension/Chrome/manifest.json
+++ b/extension/Chrome/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Movix Proxy Extension",
- "version": "1.3.9",
+ "version": "1.3.10",
"description": "Extension proxy pour Live TV Movix - Contourne CORS, injecte les headers et extrait les sources Nexus",
"icons": {
"16": "movix.png",
diff --git a/extension/Firefox/extractors.js b/extension/Firefox/extractors.js
index cac3d16..5e767dd 100644
--- a/extension/Firefox/extractors.js
+++ b/extension/Firefox/extractors.js
@@ -54,6 +54,64 @@ const caches = {
// ===== Utility Functions =====
+const PACKER_SIGNATURE_PATTERN = new RegExp(
+ 'ev' + 'al\\s*\\(\\s*function\\s*\\(\\s*p\\s*,\\s*a\\s*,\\s*c\\s*,\\s*k\\s*,\\s*e\\s*,\\s*d\\s*\\)'
+);
+
+const UQLOAD_ROOT_DOMAINS = Object.freeze([
+ 'uqload.is',
+ 'uqload.bz',
+ 'uqload.cx',
+ 'uqload.com',
+ 'uqload.net',
+ 'uqload.org',
+ 'uqload.to',
+ 'uqload.io',
+ 'uqload.co',
+]);
+
+function getUqloadRootDomain(hostname) {
+ const host = String(hostname || '').toLowerCase().replace(/\.$/, '');
+ return UQLOAD_ROOT_DOMAINS.find(
+ root => host === root || host.endsWith(`.${root}`)
+ ) || null;
+}
+
+function parseAllowedUqloadUrl(rawUrl) {
+ let parsed;
+ try {
+ parsed = new URL(String(rawUrl || '').trim());
+ } catch {
+ throw new Error('Invalid Uqload URL');
+ }
+
+ if (
+ parsed.protocol !== 'https:' ||
+ parsed.username ||
+ parsed.password ||
+ (parsed.port && parsed.port !== '443') ||
+ !getUqloadRootDomain(parsed.hostname)
+ ) {
+ throw new Error('Invalid Uqload URL');
+ }
+ return parsed;
+}
+
+function normalizeUqloadEmbedUrl(rawUrl) {
+ const parsed = parseAllowedUqloadUrl(rawUrl);
+ const lastPart = parsed.pathname.split('/').filter(Boolean).pop() || '';
+ const videoId = lastPart.replace(/^embed-/i, '').replace(/\.html$/i, '');
+ if (!/^[a-z0-9_-]+$/i.test(videoId)) {
+ throw new Error('Invalid Uqload URL');
+ }
+ return `${parsed.origin}/embed-${videoId}.html`;
+}
+
+function getUqloadSiteOrigin(rawUrl) {
+ const parsed = parseAllowedUqloadUrl(rawUrl);
+ return `https://${getUqloadRootDomain(parsed.hostname)}`;
+}
+
function md5Hash(str) {
// Simple hash for cache keys (not cryptographic, just for dedup)
let hash = 0;
@@ -154,9 +212,10 @@ function unpackPacker(p, a, c, k, e, d) {
* Properly handles escaped quotes inside the payload string
*/
function deobfuscatePackedScript(html) {
- // Find the eval(function(p,a,c,k pattern
- const evalIdx = html.indexOf('eval(function(p,a,c,k,e,');
- if (evalIdx === -1) return null;
+ // Find the packer signature while tolerating formatter whitespace.
+ const markerMatch = PACKER_SIGNATURE_PATTERN.exec(html);
+ if (!markerMatch) return null;
+ const evalIdx = markerMatch.index;
// Find .split('|') after eval to locate end of packer call
let splitPos = html.indexOf(".split('|')", evalIdx);
@@ -176,6 +235,9 @@ function deobfuscatePackedScript(html) {
const radix = parseInt(match[2]);
const count = parseInt(match[3]);
const keywords = match[4].split('|');
+ if (radix < 2 || radix > 62 || count < 0 || count > 10000 || count > keywords.length) {
+ return null;
+ }
return unpackPacker(payload, radix, count, keywords, null, {});
}
@@ -188,12 +250,42 @@ function deobfuscatePackedScript(html) {
const radix = parseInt(match2[2]);
const count = parseInt(match2[3]);
const keywords = match2[4].split('|');
+ if (radix < 2 || radix > 62 || count < 0 || count > 10000 || count > keywords.length) {
+ return null;
+ }
return unpackPacker(payload, radix, count, keywords, null, {});
}
return null;
}
+function extractUqloadMediaUrl(html) {
+ const candidates = [];
+ const collect = value => {
+ const normalized = String(value || '').replace(/\\\//g, '/');
+ for (const match of normalized.matchAll(/https:\/\/[^\s"'\\<>]+/gi)) {
+ const candidate = match[0].replace(/[),;]+$/, '');
+ try {
+ parseAllowedUqloadUrl(candidate);
+ candidates.push(candidate);
+ } catch {
+ // Ignore URLs outside the Uqload domain allowlist.
+ }
+ }
+ };
+
+ collect(html);
+ const decoded = deobfuscatePackedScript(html);
+ if (decoded) collect(decoded);
+
+ return (
+ candidates.find(url => /\/master\.m3u8(?:[?#]|$)/i.test(url)) ||
+ candidates.find(url => /\.m3u8(?:[?#]|$)/i.test(url)) ||
+ candidates.find(url => /\/v\.mp4(?:[?#]|$)/i.test(url)) ||
+ null
+ );
+}
+
/**
* Extract JSON from VOE HTML
*/
@@ -666,7 +758,7 @@ async function extractSibnet(sibnetUrl) {
}
/**
- * Extract MP4 from Uqload embed
+ * Extract HLS or MP4 from Uqload embed
*/
async function extractUqload(uqloadUrl) {
console.log(`[EXT-UQLOAD] Extracting from: ${uqloadUrl}`);
@@ -675,29 +767,21 @@ async function extractUqload(uqloadUrl) {
const cached = caches.uqload.get(cacheKey);
if (cached) return { ...cached, fromCache: true };
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 5000);
+
try {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), 3000);
-
- // Normalize URL
- let normalized = uqloadUrl.replace(/uqload\.(cx|com|net|co)/gi, 'uqload.bz');
-
- // Validate and format
- const parts = normalized.split('/');
- const base = parts.slice(0, -1).join('/') || 'https://uqload.bz';
- let videoId = parts[parts.length - 1];
-
- if (!videoId.includes('.html')) videoId += '.html';
- if (!videoId.includes('embed-')) videoId = 'embed-' + videoId;
- const fullUrl = `${base}/${videoId}`;
-
+ const fullUrl = normalizeUqloadEmbedUrl(uqloadUrl);
+ const siteOrigin = getUqloadSiteOrigin(fullUrl);
const headers = {
'User-Agent': 'Mozilla/5.0 Chrome/91.0.0.0',
- 'Accept': 'text/html,*/*'
+ 'Accept': 'text/html,*/*',
+ 'Referer': `${siteOrigin}/`,
+ 'Origin': siteOrigin,
};
- // Try embed and non-embed versions
- const urls = [fullUrl, fullUrl.replace('embed-', '')];
+ // Try embed and non-embed versions without leaving the validated host.
+ const urls = [fullUrl, fullUrl.replace('/embed-', '/')];
let html = null;
for (const url of urls) {
@@ -707,31 +791,25 @@ async function extractUqload(uqloadUrl) {
html = await resp.text();
break;
}
- } catch { continue; }
+ } catch {
+ continue;
+ }
}
- clearTimeout(timer);
if (!html) return { success: false, error: 'Uqload: Could not fetch page' };
if (html.includes('File was deleted')) return { success: false, error: 'Uqload: File was deleted' };
- // Préférer le HLS master.m3u8 (multi-bitrate) au mp4 single-quality
- const m3u8Matches = html.match(/https?:\/\/[^"'\s]+\/master\.m3u8/g) || html.match(/https?:\/\/[^"'\s]+\.m3u8/g);
- let videoUrl = m3u8Matches?.[0];
-
- if (!videoUrl) {
- const mp4Matches = html.match(/https?:\/\/.+\/v\.mp4/g);
- videoUrl = mp4Matches?.[0];
- }
-
+ const videoUrl = extractUqloadMediaUrl(html);
if (!videoUrl) return { success: false, error: 'Uqload: video URL not found' };
const result = { m3u8Url: videoUrl, success: true, source: 'uqload' };
caches.uqload.set(cacheKey, result);
return result;
-
} catch (e) {
console.error('[EXT-UQLOAD] Error:', e);
return { success: false, error: e.message || 'Uqload extraction failed' };
+ } finally {
+ clearTimeout(timer);
}
}
@@ -904,7 +982,7 @@ const EMBED_PATTERNS = {
vidzy: url => url.toLowerCase().includes('vidzy'),
vidmoly: url => url.toLowerCase().includes('vidmoly'),
sibnet: url => url.toLowerCase().includes('sibnet.ru'),
- uqload: url => /uqload\.(cx|com|bz|net|org|to|io|co)/i.test(url),
+ uqload: url => /uqload\.(is|cx|com|bz|net|org|to|io|co)/i.test(url),
doodstream: url => {
const lower = url.toLowerCase();
return lower.includes('d0000d.com') || lower.includes('doodstream.com') || lower.includes('dood.')
@@ -1024,6 +1102,7 @@ async function setupHeadersForService(type, url, referer) {
// - Embed page (fsvid.lol/embed-xxx) → fs12.lol (required by fsvid to serve content)
// - CDN/M3U8 (s1.fsvid.lol, s2.fsvid.lol, etc.) → fsvid.lol (required by CDN)
let fsvidHeaders;
+ let uqloadHeaders;
if (type === 'fsvid' && url) {
try {
const hostname = new URL(url).hostname;
@@ -1038,6 +1117,14 @@ async function setupHeadersForService(type, url, referer) {
fsvidHeaders = { 'Referer': 'https://fsvid.lol/', 'Origin': 'https://fsvid.lol' };
}
}
+ if (type === 'uqload' && url) {
+ try {
+ const origin = getUqloadSiteOrigin(url);
+ uqloadHeaders = { 'Referer': `${origin}/`, 'Origin': origin };
+ } catch {
+ return null;
+ }
+ }
const headerMap = {
voe: { 'Referer': 'https://voe.sx/', 'Origin': 'https://voe.sx' },
@@ -1045,7 +1132,7 @@ async function setupHeadersForService(type, url, referer) {
vidzy: { 'Referer': 'https://vidzy.org/', 'Origin': 'https://vidzy.org' },
vidmoly: { 'Referer': 'https://voirdrama.to/', 'Origin': 'https://voirdrama.to' },
sibnet: { 'Referer': 'https://video.sibnet.ru/', 'Origin': 'https://video.sibnet.ru' },
- uqload: { 'Referer': 'https://uqload.bz/', 'Origin': 'https://uqload.bz' },
+ uqload: uqloadHeaders,
doodstream: { 'Referer': referer || 'https://d0000d.com/', 'Origin': referer ? new URL(referer).origin : 'https://d0000d.com' },
seekstreaming: { 'Referer': referer || 'https://lpayer.embed4me.com/', 'Origin': referer ? new URL(referer).origin : 'https://lpayer.embed4me.com' },
cinep: { 'Referer': 'https://purstream.mx/', 'Origin': 'https://purstream.mx' },
diff --git a/extension/Firefox/manifest.json b/extension/Firefox/manifest.json
index 20a355b..b97c398 100644
--- a/extension/Firefox/manifest.json
+++ b/extension/Firefox/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Movix Proxy Extension",
- "version": "1.5.6",
+ "version": "1.5.7",
"description": "Extension proxy pour Live TV Movix - Contourne CORS, injecte les headers et extrait les sources Nexus",
"browser_specific_settings": {
"gecko": {
diff --git a/userscript/movix.user.js b/userscript/movix.user.js
index c2d7172..3558b96 100644
--- a/userscript/movix.user.js
+++ b/userscript/movix.user.js
@@ -1,7 +1,7 @@
// ==UserScript==
// @name Movix Proxy Extension (Tampermonkey)
// @namespace https://movix.cash
-// @version 1.4.8
+// @version 1.4.9
// @description Extension proxy pour Live TV Movix - Contourne CORS, injecte les headers et extrait les sources Nexus - version userscript Tampermonkey
// @author Movix
// @updateURL https://github.com/movixcorp/MovixOpenSource/raw/refs/heads/main/userscript/movix.user.js
@@ -43,7 +43,7 @@
const USERSCRIPT_MANIFEST = {
name: "Movix Proxy Extension",
- version: "1.4.8",
+ version: "1.4.9",
description:
"Extension proxy pour Live TV Movix - Contourne CORS, injecte les headers et extrait les sources Nexus",
};
@@ -1093,6 +1093,70 @@
// Dean Edwards packer signature — split to avoid Chrome Web Store code scanner false positives
const PACKER_MARKER = "ev" + "al(func" + "tion(p,a,c,k,e,";
+ const PACKER_SIGNATURE_PATTERN = new RegExp(
+ "ev" +
+ "al\\s*\\(\\s*function\\s*\\(\\s*p\\s*,\\s*a\\s*,\\s*c\\s*,\\s*k\\s*,\\s*e\\s*,\\s*d\\s*\\)",
+ );
+
+ const UQLOAD_ROOT_DOMAINS = Object.freeze([
+ "uqload.is",
+ "uqload.bz",
+ "uqload.cx",
+ "uqload.com",
+ "uqload.net",
+ "uqload.org",
+ "uqload.to",
+ "uqload.io",
+ "uqload.co",
+ ]);
+
+ function getUqloadRootDomain(hostname) {
+ const host = String(hostname || "")
+ .toLowerCase()
+ .replace(/\.$/, "");
+ return (
+ UQLOAD_ROOT_DOMAINS.find(
+ (root) => host === root || host.endsWith(`.${root}`),
+ ) || null
+ );
+ }
+
+ function parseAllowedUqloadUrl(rawUrl) {
+ let parsed;
+ try {
+ parsed = new URL(String(rawUrl || "").trim());
+ } catch {
+ throw new Error("Invalid Uqload URL");
+ }
+
+ if (
+ parsed.protocol !== "https:" ||
+ parsed.username ||
+ parsed.password ||
+ (parsed.port && parsed.port !== "443") ||
+ !getUqloadRootDomain(parsed.hostname)
+ ) {
+ throw new Error("Invalid Uqload URL");
+ }
+ return parsed;
+ }
+
+ function normalizeUqloadEmbedUrl(rawUrl) {
+ const parsed = parseAllowedUqloadUrl(rawUrl);
+ const lastPart = parsed.pathname.split("/").filter(Boolean).pop() || "";
+ const videoId = lastPart
+ .replace(/^embed-/i, "")
+ .replace(/\.html$/i, "");
+ if (!/^[a-z0-9_-]+$/i.test(videoId)) {
+ throw new Error("Invalid Uqload URL");
+ }
+ return `${parsed.origin}/embed-${videoId}.html`;
+ }
+
+ function getUqloadSiteOrigin(rawUrl) {
+ const parsed = parseAllowedUqloadUrl(rawUrl);
+ return `https://${getUqloadRootDomain(parsed.hostname)}`;
+ }
function md5Hash(str) {
// Simple hash for cache keys (not cryptographic, just for dedup)
@@ -1256,10 +1320,10 @@
* packed block was found
*/
function decodePackedScriptFromHtml(html) {
- // Step 1: Locate the packed script marker in the HTML
- const packerMarker = PACKER_MARKER;
- const markerIndex = html.indexOf(packerMarker);
- if (markerIndex === -1) return null;
+ // Step 1: Locate the packed script marker while tolerating formatter whitespace.
+ const markerMatch = PACKER_SIGNATURE_PATTERN.exec(html);
+ if (!markerMatch) return null;
+ const markerIndex = markerMatch.index;
// Step 2: Find the .split('|') call that marks the end of the keyword
// list — this tells us where the packed block ends
@@ -1289,6 +1353,15 @@
const radix = parseInt(match[2]);
const keywordCount = parseInt(match[3]);
const keywords = match[4].split("|");
+ if (
+ radix < 2 ||
+ radix > 62 ||
+ keywordCount < 0 ||
+ keywordCount > 10000 ||
+ keywordCount > keywords.length
+ ) {
+ return null;
+ }
// Step 6: Decode and return the original script
return decodeDeanEdwardsPacker(
@@ -1299,6 +1372,33 @@
);
}
+ function extractUqloadMediaUrl(html) {
+ const candidates = [];
+ const collect = (value) => {
+ const normalized = String(value || "").replace(/\\\//g, "/");
+ for (const match of normalized.matchAll(/https:\/\/[^\s"'\\<>]+/gi)) {
+ const candidate = match[0].replace(/[),;]+$/, "");
+ try {
+ parseAllowedUqloadUrl(candidate);
+ candidates.push(candidate);
+ } catch {
+ // Ignore URLs outside the Uqload domain allowlist.
+ }
+ }
+ };
+
+ collect(html);
+ const decoded = decodePackedScriptFromHtml(html);
+ if (decoded) collect(decoded);
+
+ return (
+ candidates.find((url) => /\/master\.m3u8(?:[?#]|$)/i.test(url)) ||
+ candidates.find((url) => /\.m3u8(?:[?#]|$)/i.test(url)) ||
+ candidates.find((url) => /\/v\.mp4(?:[?#]|$)/i.test(url)) ||
+ null
+ );
+ }
+
/**
* Extract JSON from VOE HTML
*/
@@ -1847,7 +1947,7 @@
}
/**
- * Extract MP4 from Uqload embed
+ * Extract HLS or MP4 from Uqload embed
*/
async function extractUqload(uqloadUrl) {
console.log(`[EXT-UQLOAD] Extracting from: ${uqloadUrl}`);
@@ -1856,32 +1956,21 @@
const cached = caches.uqload.get(cacheKey);
if (cached) return { ...cached, fromCache: true };
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 5000);
+
try {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), 3000);
-
- // Normalize URL
- let normalized = uqloadUrl.replace(
- /uqload\.(cx|com|net|co)/gi,
- "uqload.bz",
- );
-
- // Validate and format
- const parts = normalized.split("/");
- const base = parts.slice(0, -1).join("/") || "https://uqload.bz";
- let videoId = parts[parts.length - 1];
-
- if (!videoId.includes(".html")) videoId += ".html";
- if (!videoId.includes("embed-")) videoId = "embed-" + videoId;
- const fullUrl = `${base}/${videoId}`;
-
+ const fullUrl = normalizeUqloadEmbedUrl(uqloadUrl);
+ const siteOrigin = getUqloadSiteOrigin(fullUrl);
const headers = {
"User-Agent": "Mozilla/5.0 Chrome/91.0.0.0",
Accept: "text/html,*/*",
+ Referer: `${siteOrigin}/`,
+ Origin: siteOrigin,
};
- // Try embed and non-embed versions
- const urls = [fullUrl, fullUrl.replace("embed-", "")];
+ // Try embed and non-embed versions without leaving the validated host.
+ const urls = [fullUrl, fullUrl.replace("/embed-", "/")];
let html = null;
for (const url of urls) {
@@ -1896,23 +1985,12 @@
}
}
- clearTimeout(timer);
if (!html)
return { success: false, error: "Uqload: Could not fetch page" };
if (html.includes("File was deleted"))
return { success: false, error: "Uqload: File was deleted" };
- // Préférer le HLS master.m3u8 (multi-bitrate) au mp4 single-quality
- const m3u8Matches =
- html.match(/https?:\/\/[^"'\s]+\/master\.m3u8/g) ||
- html.match(/https?:\/\/[^"'\s]+\.m3u8/g);
- let videoUrl = m3u8Matches?.[0];
-
- if (!videoUrl) {
- const mp4Matches = html.match(/https?:\/\/.+\/v\.mp4/g);
- videoUrl = mp4Matches?.[0];
- }
-
+ const videoUrl = extractUqloadMediaUrl(html);
if (!videoUrl)
return { success: false, error: "Uqload: video URL not found" };
@@ -1922,6 +2000,8 @@
} catch (e) {
console.error("[EXT-UQLOAD] Error:", e);
return { success: false, error: e.message || "Uqload extraction failed" };
+ } finally {
+ clearTimeout(timer);
}
}
@@ -2136,7 +2216,7 @@
vidzy: (url) => url.toLowerCase().includes("vidzy"),
vidmoly: (url) => url.toLowerCase().includes("vidmoly"),
sibnet: (url) => url.toLowerCase().includes("sibnet.ru"),
- uqload: (url) => /uqload\.(cx|com|bz|net|org|to|io|co)/i.test(url),
+ uqload: (url) => /uqload\.(is|cx|com|bz|net|org|to|io|co)/i.test(url),
doodstream: (url) => {
const lower = url.toLowerCase();
return (
@@ -2282,6 +2362,7 @@
// - Embed page (fsvid.lol/embed-xxx) → fs13.lol (required by fsvid to serve content)
// - CDN/M3U8 (s1.fsvid.lol, s2.fsvid.lol, etc.) → fsvid.lol (required by CDN)
let fsvidHeaders;
+ let uqloadHeaders;
if (type === "fsvid" && url) {
try {
const hostname = new URL(url).hostname;
@@ -2305,6 +2386,14 @@
};
}
}
+ if (type === "uqload" && url) {
+ try {
+ const origin = getUqloadSiteOrigin(url);
+ uqloadHeaders = { Referer: `${origin}/`, Origin: origin };
+ } catch {
+ return null;
+ }
+ }
const headerMap = {
voe: { Referer: "https://voe.sx/", Origin: "https://voe.sx" },
@@ -2321,7 +2410,7 @@
Referer: "https://video.sibnet.ru/",
Origin: "https://video.sibnet.ru",
},
- uqload: { Referer: "https://uqload.bz/", Origin: "https://uqload.bz" },
+ uqload: uqloadHeaders,
doodstream: {
Referer: referer || "https://d0000d.com/",
Origin: referer ? new URL(referer).origin : "https://d0000d.com",