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
19 changes: 17 additions & 2 deletions app/src/main/java/com/runcode/app/runtime/StaticWebEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,11 @@ class StaticWebEngine(

/**
* Maps a request path onto a file inside [sourceDir], falling back to the entrypoint so
* single-page apps keep working. Returns null when the path escapes the source directory.
* single-page apps keep working. Returns null when the path escapes the source directory,
* and a file that does not exist when the request names a missing file, which the caller
* answers with 404.
*/
private fun resolveTarget(sourceDir: File, rawPath: String, entryPoint: String): File? {
internal fun resolveTarget(sourceDir: File, rawPath: String, entryPoint: String): File? {
val decoded = try {
URLDecoder.decode(rawPath, "UTF-8")
} catch (_: Exception) {
Expand All @@ -246,10 +248,23 @@ class StaticWebEngine(
if (!isInside(candidate, sourceDir)) return null
if (candidate.isFile) return candidate

// A single-page app routes /dashboard through index.html, so an unknown route falls
// back to the entrypoint. A request that names a file does not: answering /app.css
// with the HTML page, and a 200 at that, broke pages quietly and hid typos in asset
// paths. Missing files get a 404 instead.
if (namesAFile(requested)) return candidate

val fallback = File(sourceDir, entryPoint).canonicalFile
return if (isInside(fallback, sourceDir)) fallback else null
}

/** True when the last path segment carries an extension, e.g. `app.css` but not `dashboard`. */
private fun namesAFile(path: String): Boolean {
val name = path.trimEnd('/').substringAfterLast('/')
val dot = name.lastIndexOf('.')
return dot > 0 && dot < name.length - 1
}

private fun isInside(candidate: File, root: File): Boolean {
return candidate.path == root.path || candidate.path.startsWith(root.path + File.separator)
}
Expand Down
65 changes: 65 additions & 0 deletions app/src/test/java/com/runcode/app/runtime/StaticWebRoutingTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.runcode.app.runtime

import android.app.Application
import com.runcode.app.network.PortManager
import com.runcode.app.system.ProcessMonitor
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import java.nio.file.Files

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class StaticWebRoutingTest {

private lateinit var engine: StaticWebEngine
private lateinit var sourceDir: File

@Before fun setup() {
engine = StaticWebEngine(PortManager(), ProcessMonitor())
sourceDir = Files.createTempDirectory("static-web").toFile()
File(sourceDir, "index.html").writeText("<html></html>")
File(sourceDir, "style.css").writeText("body{}")
File(sourceDir, "assets").mkdirs()
File(sourceDir, "assets/app.js").writeText("// app")
}

private fun resolve(path: String) = engine.resolveTarget(sourceDir, path, "index.html")

@Test fun `root serves the entrypoint`() {
assertEquals(File(sourceDir, "index.html").canonicalFile, resolve(""))
}

@Test fun `existing files are served`() {
assertEquals(File(sourceDir, "style.css").canonicalFile, resolve("style.css"))
assertEquals(File(sourceDir, "assets/app.js").canonicalFile, resolve("assets/app.js"))
}

@Test fun `a route with no extension falls back to the entrypoint`() {
assertEquals(File(sourceDir, "index.html").canonicalFile, resolve("dashboard"))
assertEquals(File(sourceDir, "index.html").canonicalFile, resolve("users/42/"))
}

@Test fun `a missing file is not answered with the entrypoint`() {
// It used to return index.html with a 200, so a typo in an asset path broke the page
// quietly. The caller turns a file that does not exist into a 404.
val missing = resolve("missing.css")
assertEquals(File(sourceDir, "missing.css").canonicalFile, missing)
assertFalse(missing!!.isFile)

val missingNested = resolve("assets/nope.js")
assertEquals(File(sourceDir, "assets/nope.js").canonicalFile, missingNested)
assertFalse(missingNested!!.isFile)
}

@Test fun `paths that escape the source directory are refused`() {
assertNull(resolve("../secrets.txt"))
assertNull(resolve("assets/../../secrets.txt"))
}
}
Loading