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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ package org.readium.r2.navigator.epub

import android.app.Application
import android.os.PatternMatcher
import android.webkit.MimeTypeMap
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import androidx.webkit.WebViewAssetLoader
Expand All @@ -28,9 +29,11 @@ import org.readium.r2.shared.util.data.ReadError
import org.readium.r2.shared.util.data.asInputStream
import org.readium.r2.shared.util.http.HttpHeaders
import org.readium.r2.shared.util.http.HttpRange
import org.readium.r2.shared.util.mediatype.MediaType
import org.readium.r2.shared.util.resource.Resource
import org.readium.r2.shared.util.resource.StringResource
import org.readium.r2.shared.util.resource.fallback
import org.readium.r2.shared.util.toUrl

/**
* Serves the publication resources and application assets in the EPUB navigator web views.
Expand All @@ -44,8 +47,11 @@ internal class WebViewServer(
private val onResourceLoadFailed: (Url, ReadError) -> Unit,
) {
companion object {
val publicationBaseHref = AbsoluteUrl("https://readium/publication/")!!
val assetsBaseHref = AbsoluteUrl("https://readium/assets/")!!
const val READIUM_PACKAGE_HOSTNAME = "readium_package"
const val ASSETS_HOSTNAME = "readium_assets"

val publicationBaseHref = AbsoluteUrl("https://$READIUM_PACKAGE_HOSTNAME/")!!
val assetsBaseHref = AbsoluteUrl("https://$ASSETS_HOSTNAME/")!!

fun assetUrl(path: String): Url? =
Url.fromDecodedPath(path)?.let { assetsBaseHref.resolve(it) }
Expand All @@ -54,28 +60,64 @@ internal class WebViewServer(
/**
* Serves the requests of the navigator web views.
*
* https://readium/publication/ serves the publication resources through its fetcher.
* https://readium/assets/ serves the application assets.
* https://readium_package/ serves the publication resources through its fetcher.
* https://readium_assets/ serves the application assets.
*/
fun shouldInterceptRequest(request: WebResourceRequest, css: ReadiumCss): WebResourceResponse? {
if (request.url.host != "readium") return null
val path = request.url.path ?: return null
val hostname = request.url.host ?: return null
val requestUrl = request.url.toUrl() ?: return null
val range = HttpHeaders(request.requestHeaders).range

return when {
path.startsWith("/publication/") -> {
val href = Url.fromDecodedPath(path.removePrefix("/publication/"))
?: return null
return when (hostname) {
READIUM_PACKAGE_HOSTNAME -> {
// Request is for a packaged resource.
val href = Url.fromDecodedPath(path.removePrefix("/"))
?: return serveErrorResponse()

servePublicationResource(
href = href,
range = HttpHeaders(request.requestHeaders).range,
range = range,
css = css
)
}
path.startsWith("/assets/") && isServedAsset(path.removePrefix("/assets/")) -> {

ASSETS_HOSTNAME if isServedAsset(path.removePrefix("/")) -> {
// Request is for a known asset.
assetsLoader.shouldInterceptRequest(request.url)
}
else -> null

ASSETS_HOSTNAME -> {
val error = ReadError.Decoding(
"Attempted to load an unknown asset from $requestUrl"
)
onResourceLoadFailed(requestUrl, error)
serveErrorResponse() // Request is for an unknown asset.
}

else -> {
// Request is for streaming a resource, if the baseUrl an AbsoluteUrl and hostname
// is not ASSETS_HOSTNAME or READIUM_PACKAGE_HOSTNAME
val baseUrl = publication.baseUrl as? AbsoluteUrl ?: run {
val error = ReadError.Decoding(
"baseUrl is not an AbsoluteUrl, cannot load remote resource from $requestUrl"
)
onResourceLoadFailed(requestUrl, error)
return serveErrorResponse()
}
Comment on lines +101 to +107
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need baseUrl if href is absolute. It could be safer to keep going in that case.


val href =
// Look up the link in the publication to make sure we have the right resource.
publicationLinkFromHref(baseUrl.resolve(requestUrl))?.href?.resolve()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you resolve requestUrl? I don't think it can be relative.

?: publicationLinkFromHref(baseUrl.relativize(requestUrl))?.href?.resolve()
?: requestUrl

servePublicationResource(
href = href,
range = range,
css = css
)
}
}
}

Expand All @@ -84,15 +126,17 @@ internal class WebViewServer(
*
* If the [Resource] is an HTML document, injects the required JavaScript and CSS files.
*/
private fun servePublicationResource(href: Url, range: HttpRange?, css: ReadiumCss): WebResourceResponse {
val link = publication.linkWithHref(href)
// Query parameters must be kept as they might be relevant for the fetcher.
?.copy(href = Href(href))
?: Link(href = href)
private fun servePublicationResource(
href: Url,
range: HttpRange?,
css: ReadiumCss
): WebResourceResponse {
val link = publicationLinkFromHref(href)
// Link not found, create a Link from the href and guess the MediaType
?: Link(href = href, mediaType = mediaTypeFromUrl(href))

// Drop anchor because it is meant to be interpreted by the client.
val urlWithoutAnchor = href.removeFragment()

var resource = publication
.get(urlWithoutAnchor)
?.fallback {
Expand All @@ -106,41 +150,52 @@ internal class WebViewServer(
errorResource()
}

link.mediaType
?.takeIf { it.isHtml }
?.let {
resource = resource.injectHtml(
publication,
mediaType = it,
css,
baseHref = assetsBaseHref,
disableSelectionWhenProtected = disableSelectionWhenProtected
)
}
// Only inject html when the profile is EPUB
if (publication.conformsTo(Publication.Profile.EPUB)) {
link.mediaType
?.takeIf { it.isHtml }
?.let {
resource = resource.injectHtml(
publication,
mediaType = it,
css,
baseHref = assetsBaseHref,
disableSelectionWhenProtected = disableSelectionWhenProtected
)
}
}

return serveResource(resource, range, link.mediaType)
}

private fun serveResource(
resource: Resource,
range: HttpRange?,
mediaType: MediaType?,
): WebResourceResponse {
val headers = mutableMapOf(
"Accept-Ranges" to "bytes"
)

val stream = resource.asInputStream()
if (range == null) {
return WebResourceResponse(
link.mediaType?.toString(),
mediaType?.toString(),
null,
200,
"OK",
headers,
resource.asInputStream()
stream
)
} else { // Byte range request
val stream = resource.asInputStream()
val length = stream.available()
val longRange = range.toLongRange(length.toLong())
headers["Content-Range"] = "bytes ${longRange.first}-${longRange.last}/$length"
// Content-Length will automatically be filled by the WebView using the Content-Range header.
// headers["Content-Length"] = (longRange.last - longRange.first + 1).toString()
// Weirdly, the WebView will call itself stream.skip to skip to the requested range.
return WebResourceResponse(
link.mediaType?.toString(),
mediaType?.toString(),
null,
206,
"Partial Content",
Expand All @@ -149,6 +204,26 @@ internal class WebViewServer(
)
}
}

/**
* Resolve the [MediaType] from an [Url].
*/
private fun mediaTypeFromUrl(href: Url): MediaType? {
val ext = MimeTypeMap.getFileExtensionFromUrl(href.normalize().toString()) ?: return null
val mimetype = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return null

return MediaType.invoke(mimetype)
}

/**
* Get link in publication from a [Url] and replace href to preserve request query parameters.
*/
private fun publicationLinkFromHref(href: Url): Link? {
return publication.linkWithHref(href)
// Query parameters must be kept as they might be relevant for the fetcher.
?.copy(href = Href(href))
}

private fun errorResource(): Resource =
StringResource {
withContext(Dispatchers.IO) {
Expand All @@ -161,6 +236,10 @@ internal class WebViewServer(
}
}

private fun serveErrorResponse(): WebResourceResponse {
return serveResource(errorResource(), null, MediaType.XHTML)
}

private fun isServedAsset(path: String): Boolean =
servedAssetPatterns.any { it.match(path) }

Expand All @@ -169,7 +248,7 @@ internal class WebViewServer(

private val assetsLoader =
WebViewAssetLoader.Builder()
.setDomain("readium")
.addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(application))
.setDomain(ASSETS_HOSTNAME)
.addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(application))
.build()
}
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,11 @@ public class AbsoluteUrl private constructor(override val uri: Uri) : Url() {
public val isContent: Boolean get() =
scheme.isContent

/**
* Hostname of the URL.
*/
public val host: String? get() = uri.host

/**
* Converts the URL to a [File], if it's a file URL.
*/
Expand Down