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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ import com.ichi2.anki.model.CardStateFilter
import com.ichi2.anki.noteeditor.EditCardDestination
import com.ichi2.anki.noteeditor.toIntent
import com.ichi2.anki.pages.AnkiServer
import com.ichi2.anki.pages.AnkiServer.Companion.LOCALHOST
import com.ichi2.anki.pages.CongratsPage
import com.ichi2.anki.pages.PostRequestHandler
import com.ichi2.anki.preferences.sharedPrefs
Expand Down Expand Up @@ -232,9 +231,8 @@ abstract class AbstractFlashcardViewer :
@get:VisibleForTesting
var cardContent: String? = null
private set
private val baseUrl get() = server.baseUrl()
private val webviewDomain
get() = "$LOCALHOST:${server.listeningPort}"
private val baseUrl
get() = getMediaBaseUrl(CollectionHelper.getMediaDirectory(this).path)

private var viewerUrl: String? = null
private val fadeDuration = 300
Expand Down Expand Up @@ -994,7 +992,6 @@ abstract class AbstractFlashcardViewer :
}

protected open fun createWebView(): WebView {
val resourceHandler = ViewerResourceHandler(this, webviewDomain)
val webView: WebView = MyWebView(this).apply {
scrollBarStyle = View.SCROLLBARS_OUTSIDE_OVERLAY
with(settings) {
Expand All @@ -1012,7 +1009,7 @@ abstract class AbstractFlashcardViewer :
isScrollbarFadingEnabled = true
// Set transparent color to prevent flashing white when night mode enabled
setBackgroundColor(Color.argb(1, 0, 0, 0))
CardViewerWebClient(resourceHandler, this@AbstractFlashcardViewer).apply {
CardViewerWebClient(this@AbstractFlashcardViewer).apply {
webViewClient = this
this@AbstractFlashcardViewer.webViewClient = this
}
Expand Down Expand Up @@ -2235,7 +2232,6 @@ abstract class AbstractFlashcardViewer :
}

inner class CardViewerWebClient internal constructor(
private val loader: ViewerResourceHandler?,
private val onPageFinishedCallback: OnPageFinishedCallback? = null
) : WebViewClient(), JavascriptEvaluator {
private var pageFinishedFired = true
Expand All @@ -2256,14 +2252,16 @@ abstract class AbstractFlashcardViewer :
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
pageRenderStopwatch.reset()
pageFinishedFired = false
val script = "globalThis.ankidroid = globalThis.ankidroid || {};" +
"ankidroid.postBaseUrl = `${server.baseUrl()}`"
view?.evaluateJavascript(script, null)
}

override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? {
val url = request.url
loader!!.shouldInterceptRequest(request)?.let { return it }
if (url.toString().startsWith("file://")) {
url.path?.let { path -> migrationService?.migrateFileImmediately(File(path)) }
}
Expand Down
109 changes: 0 additions & 109 deletions AnkiDroid/src/main/java/com/ichi2/anki/ViewerResourceHandler.kt

This file was deleted.

52 changes: 21 additions & 31 deletions AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,13 @@
package com.ichi2.anki.pages

import fi.iki.elonen.NanoHTTPD
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.io.ByteArrayInputStream

const val PORT = 0
// const val PORT = 40001

// local debugging:
// ~/Local/Android/Sdk/platform-tools/adb forward tcp:40001 tcp:40001

open class AnkiServer(
private val postHandler: PostRequestHandler
) : NanoHTTPD(LOCALHOST, PORT) {
) : NanoHTTPD(LOCALHOST, 0) {

fun baseUrl(): String {
return "http://$LOCALHOST:$listeningPort/"
Expand All @@ -47,26 +40,35 @@ open class AnkiServer(
val uri = session.uri
Timber.d("POST: Requested %s", uri)
val inputBytes = getSessionBytes(session)
buildResponse {
postHandler.handlePostRequest(uri, inputBytes)

try {
val data = runBlocking { postHandler.handlePostRequest(uri, inputBytes) }
buildResponse(data)
} catch (exception: Exception) {
Timber.w(exception, "buildResponse failure")
buildResponse(exception.localizedMessage?.encodeToByteArray(), status = Response.Status.INTERNAL_ERROR)
}
}
Method.GET -> newFixedLengthResponse(Response.Status.NOT_FOUND, null, null)
Method.OPTIONS -> buildResponse(null)
else -> newFixedLengthResponse(null)
}
}

private fun buildResponse(
block: suspend CoroutineScope.() -> ByteArray
data: ByteArray?,
mimeType: String = "application/binary",
status: Response.IStatus = Response.Status.OK
): Response {
return try {
val data = runBlocking {
block()
}
newChunkedResponse(data)
} catch (exc: Exception) {
Timber.w(exc, "buildResponse failure")
newChunkedResponse(exc.localizedMessage?.encodeToByteArray(), status = Response.Status.INTERNAL_ERROR)
return if (data == null) {
newFixedLengthResponse(null)
} else {
newChunkedResponse(status, mimeType, ByteArrayInputStream(data))
}.apply {
addHeader("Access-Control-Allow-Origin", "*")
Copy link
Member

Choose a reason for hiding this comment

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

[security] Is it feasible to limit this so other apps on-device can't reasonably access the server?

Copy link
Member Author

@BrayanDSO BrayanDSO Apr 20, 2024

Choose a reason for hiding this comment

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

Tried it once but it didn't work immediately. Since my timeblock was gone and this was the same of what 2.16 did, I left it as is.

https://github.com/ankidroid/Anki-Android/blob/release-2.16/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt#L586

Copy link
Member

Choose a reason for hiding this comment

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

Do you feel we should block this PR on this?

If we have MANAGE_EXTERNAL_STORAGE, we must not allow access to files outside collection.media, and I would block on this

Copy link
Member

Choose a reason for hiding this comment

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

I've extracted this to: #16230

Copy link
Member Author

@BrayanDSO BrayanDSO Apr 20, 2024

Choose a reason for hiding this comment

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

Took a look and since the Reviewer now uses file:/// as base url, origin is null so only * can be used as the value of Access-Control-Allow-Origin.

On a quick assessment, only POST requests are allowed. In the reviewer only getSchedulingStatesWithContext and setSchedulingStates are enabled. So, I don't see any danger right now. Card templates and deck options' custom scheduling already can use those methods but aren't considered a security vulnerability

addHeader("Access-Control-Allow-Headers", "Content-Type")
addHeader("Access-Control-Allow-Methods", "POST")
addHeader("Access-Control-Max-Age", "7200")
}
}

Expand All @@ -83,17 +85,5 @@ open class AnkiServer(
session.inputStream.read(bytes, 0, contentLength)
return bytes
}

fun newChunkedResponse(
data: ByteArray?,
mimeType: String = "application/binary",
status: Response.IStatus = Response.Status.OK
): Response {
return if (data == null) {
newFixedLengthResponse(null)
} else {
newChunkedResponse(status, mimeType, ByteArrayInputStream(data))
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
Expand All @@ -36,11 +35,10 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.ichi2.anki.CollectionHelper
import com.ichi2.anki.R
import com.ichi2.anki.ViewerResourceHandler
import com.ichi2.anki.dialogs.TtsVoicesDialogFragment
import com.ichi2.anki.localizedErrorMessage
import com.ichi2.anki.pages.AnkiServer
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.themes.Themes
import kotlinx.coroutines.flow.launchIn
Expand Down Expand Up @@ -85,8 +83,9 @@ abstract class CardViewerFragment(@LayoutRes layout: Int) : Fragment(layout) {
// allow videos to autoplay via our JavaScript eval
mediaPlaybackRequiresUserGesture = false
}
val baseUrl = CollectionHelper.getMediaDirectory(requireContext()).toURI().toString()
loadDataWithBaseURL(
"http://${AnkiServer.LOCALHOST}/",
baseUrl,
stdHtml(requireContext(), Themes.currentTheme.isNightMode),
"text/html",
null,
Expand Down Expand Up @@ -122,15 +121,7 @@ abstract class CardViewerFragment(@LayoutRes layout: Int) : Fragment(layout) {
}

private fun onCreateWebViewClient(savedInstanceState: Bundle?): WebViewClient {
val resourceHandler = ViewerResourceHandler(requireContext(), AnkiServer.LOCALHOST)
return object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest
): WebResourceResponse? {
return resourceHandler.shouldInterceptRequest(request)
}

override fun onPageFinished(view: WebView?, url: String?) {
viewModel.onPageFinished(isAfterRecreation = savedInstanceState != null)
}
Expand Down
36 changes: 36 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/libanki/Sound.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ package com.ichi2.libanki
import android.text.TextUtils
import anki.config.ConfigKey
import com.ichi2.anki.CollectionManager
import com.ichi2.compat.CompatHelper
import com.ichi2.libanki.TemplateManager.TemplateRenderContext.TemplateRenderOutput
import com.ichi2.libanki.utils.NotInLibAnki
import org.intellij.lang.annotations.Language
import org.jetbrains.annotations.VisibleForTesting
import java.io.File
import java.net.URI
import java.nio.file.Paths
import java.util.regex.Pattern

Expand Down Expand Up @@ -270,3 +273,36 @@ data class AvRef(val side: String, val index: Int) {
val REGEX = Regex("\\[anki:(play:(.):(\\d+))]")
}
}

/** Similar to [File.toURI], but doesn't use the absolute file to simplify testing */
@NotInLibAnki
@VisibleForTesting
fun getFileUri(path: String): URI {
var p = path
if (File.separatorChar != '/') p = p.replace(File.separatorChar, '/')
if (!p.startsWith("/")) p = "/$p"
if (!p.startsWith("//")) p = "//$p"
return URI("file", p, null)
}

/**
* Whether a video file only contains an audio stream
*
* @return `null` - file is not a video, or not found
*/
@NotInLibAnki
@VisibleForTesting
fun isAudioFileInVideoContainer(file: File): Boolean? {
if (file.extension !in Sound.VIDEO_ONLY_EXTENSIONS && file.extension !in Sound.AUDIO_OR_VIDEO_EXTENSIONS) {
return null
}

if (file.extension in Sound.VIDEO_ONLY_EXTENSIONS) return false

// file.extension is in AUDIO_OR_VIDEO_EXTENSIONS
if (!file.exists()) return null

// Also check that there is a video thumbnail, as some formats like mp4 can be audio only
val isVideo = CompatHelper.compat.hasVideoThumbnail(file.absolutePath) ?: return null
return !isVideo
}
Loading