Add cover loader and refactoring

This commit is contained in:
Minecon724 2025-10-04 13:55:23 +02:00
commit c4275e4c81
Signed by untrusted user who does not match committer: m724
GPG key ID: A02E6E67AB961189
15 changed files with 262 additions and 95 deletions

View file

@ -16,11 +16,13 @@ repositories {
dependencies { dependencies {
//implementation("org.eclipse.jgit:org.eclipse.jgit:7.3.0.202506031305-r") //implementation("org.eclipse.jgit:org.eclipse.jgit:7.3.0.202506031305-r")
/// implementation("io.pebbletemplates:pebble:3.2.4") /// implementation("io.pebbletemplates:pebble:3.2.4")
implementation(libs.clikt) // command line argument parsing implementation(libs.clikt)
implementation(libs.kaml) // YAML configuration parsing implementation(libs.kaml)
implementation(libs.logging)
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
testImplementation(libs.mockk) testImplementation(libs.mockk)
testImplementation(libs.junit.jupiter.params)
} }
tasks.test { tasks.test {

View file

@ -5,11 +5,15 @@ shadow = "9.2.2"
clikt = "5.0.3" clikt = "5.0.3"
kaml = "0.97.0" kaml = "0.97.0"
mockk = "1.14.5" mockk = "1.14.5"
junit = "5.11.0"
logging = "7.0.3"
[libraries] [libraries]
clikt = { group = "com.github.ajalt.clikt", name = "clikt", version.ref = "clikt" } clikt = { group = "com.github.ajalt.clikt", name = "clikt", version.ref = "clikt" }
kaml = { group = "com.charleskorn.kaml", name = "kaml", version.ref = "kaml" } kaml = { group = "com.charleskorn.kaml", name = "kaml", version.ref = "kaml" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
junit-jupiter-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junit" }
logging = { group = "io.github.oshai", name = "kotlin-logging-jvm", version.ref = "logging" }
[plugins] [plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

View file

@ -1,10 +0,0 @@
package eu.m724.bsk2.builder
import java.nio.file.Path
// TODO needs a test or two
fun Path.isParentOf(child: Path): Boolean =
child.toFile().canonicalPath.startsWith(this.toFile().canonicalPath)
fun Path.contains(other: Path): Boolean =
isParentOf(other)

View file

@ -1,7 +1,8 @@
package eu.m724.bsk2.builder package eu.m724.bsk2.builder.fs
import java.nio.file.Path import java.nio.file.Path
interface FileSystem { interface FileSystem {
fun readText(path: Path): String fun readText(path: Path): String
fun exists(path: Path): Boolean
} }

View file

@ -0,0 +1,11 @@
package eu.m724.bsk2.builder.fs
import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.io.path.readText
class LocalFileSystem : FileSystem {
override fun readText(path: Path): String = path.readText()
override fun exists(path: Path): Boolean = path.exists()
}

View file

@ -0,0 +1,38 @@
package eu.m724.bsk2.builder.fs
import java.nio.file.Path
// TODO needs a test or two
fun Path.isParentOf(child: Path): Boolean =
child.toFile().canonicalPath.startsWith(this.toFile().canonicalPath)
fun Path.contains(other: Path): Boolean =
isParentOf(other)
/**
* Resolve the given path against this path, ensuring the resulting path is a child of the given path.
*
* @param other the path to resolve against this path
* @return the resulting path
* @throws IllegalArgumentException if the resolve results in a traversal
*/
fun Path.resolveSafe(other: Path): Path {
val path = this.resolve(other)
if (!this.isParentOf(path)) {
throw IllegalArgumentException("Path traversal ($path not a child of $this)")
}
return path
}
/**
* Resolve the given path against this path, ensuring the resulting path is a child of the given path.
*
* @param other the path to resolve against this path
* @return the resulting path
* @throws IllegalArgumentException if the resolve results in a traversal
*/
fun Path.resolveSafe(other: String): Path {
return this.resolveSafe(fileSystem.getPath(other))
}

View file

@ -2,13 +2,13 @@ package eu.m724.bsk2.builder.post.loader
import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.Yaml
import com.charleskorn.kaml.YamlConfiguration import com.charleskorn.kaml.YamlConfiguration
import eu.m724.bsk2.builder.FileSystem import eu.m724.bsk2.builder.fs.FileSystem
import eu.m724.bsk2.builder.isParentOf import eu.m724.bsk2.builder.fs.LocalFileSystem
import eu.m724.bsk2.builder.fs.resolveSafe
import eu.m724.bsk2.builder.post.data.PostMeta import eu.m724.bsk2.builder.post.data.PostMeta
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.div import kotlin.io.path.div
import kotlin.io.path.readText
/** /**
* A post loader that loads from a file system * A post loader that loads from a file system
@ -18,25 +18,41 @@ import kotlin.io.path.readText
*/ */
class FileSystemPostLoader ( class FileSystemPostLoader (
private val postRoot: Path, private val postRoot: Path,
private val fileSystem: FileSystem = object : FileSystem { private val fileSystem: FileSystem = LocalFileSystem(),
override fun readText(path: Path): String = path.readText()
},
private val yaml: Yaml = Yaml( private val yaml: Yaml = Yaml(
configuration = YamlConfiguration(strictMode = false) configuration = YamlConfiguration(strictMode = false)
) )
) : PostLoader { ) : PostLoader {
private val cache = mutableMapOf<String, PostMeta>() private companion object {
val COVER_EXTENSIONS = listOf("webp", "png")
}
override fun getMeta(name: String): PostMeta { override fun readMeta(name: String): PostMeta {
val path = postRoot / name / "meta.yml" val path = postRoot.resolveSafe(name) / "meta.yml"
if (!postRoot.isParentOf(path)) { val text = fileSystem.readText(path)
throw IllegalArgumentException("Path traversal ($path not a child of $postRoot)") return yaml.decodeFromString<PostMeta>(text)
}
override fun readContent(name: String): String {
val path = postRoot.resolveSafe(name) / "content.html"
return fileSystem.readText(path)
}
override fun findCover(name: String): Path {
val postPath = postRoot.resolveSafe(name)
val found = COVER_EXTENSIONS
.map { ext -> postPath / "cover.$ext" }
.filter { path -> fileSystem.exists(path) }
if (found.isEmpty()) {
throw NoSuchFileException((postPath / "cover.$COVER_EXTENSIONS[0]").toFile(), reason = "No valid cover file found")
} else if (found.size > 1) {
throw IllegalStateException("Don't know which of ${found.size} cover files to pick: ${found.joinToString(", ")}")
} }
return cache.getOrPut(name) { return found.first()
val text = fileSystem.readText(path)
yaml.decodeFromString<PostMeta>(text)
}
} }
} }

View file

@ -1,7 +1,10 @@
package eu.m724.bsk2.builder.post.loader package eu.m724.bsk2.builder.post.loader
import eu.m724.bsk2.builder.post.data.PostMeta import eu.m724.bsk2.builder.post.data.PostMeta
import java.nio.file.Path
interface PostLoader { interface PostLoader {
fun getMeta(name: String): PostMeta fun readMeta(name: String): PostMeta
fun readContent(name: String): String
fun findCover(name: String): Path
} }

View file

@ -1,10 +1,10 @@
package eu.m724.bsk2.builder.template.loader package eu.m724.bsk2.builder.template.loader
import eu.m724.bsk2.builder.FileSystem import eu.m724.bsk2.builder.fs.FileSystem
import eu.m724.bsk2.builder.isParentOf import eu.m724.bsk2.builder.fs.LocalFileSystem
import eu.m724.bsk2.builder.fs.resolveSafe
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.div import kotlin.io.path.div
import kotlin.io.path.readText
/** /**
* A template loader that loads from a file system * A template loader that loads from a file system
@ -14,22 +14,13 @@ import kotlin.io.path.readText
*/ */
class FileSystemTemplateLoader ( class FileSystemTemplateLoader (
templateRoot: Path, templateRoot: Path,
private val fileSystem: FileSystem = object : FileSystem { private val fileSystem: FileSystem = LocalFileSystem()
override fun readText(path: Path): String = path.readText()
}
) : TemplateLoader { ) : TemplateLoader {
private val cache = mutableMapOf<String, String>()
private val htmlRoot = templateRoot / "html" private val htmlRoot = templateRoot / "html"
override fun getHtml(name: String): String { override fun readHtml(name: String): String {
val path = htmlRoot / "$name.html" val path = htmlRoot.resolveSafe("$name.html")
if (!htmlRoot.isParentOf(path)) { return fileSystem.readText(path)
throw IllegalArgumentException("Path traversal ($path not a child of $htmlRoot)")
}
return cache.getOrPut(name) {
fileSystem.readText(path)
}
} }
} }

View file

@ -1,5 +1,5 @@
package eu.m724.bsk2.builder.template.loader package eu.m724.bsk2.builder.template.loader
interface TemplateLoader { interface TemplateLoader {
fun getHtml(name: String): String fun readHtml(name: String): String
} }

View file

@ -0,0 +1,64 @@
package eu.m724.bsk2.builder.post.loader
import eu.m724.bsk2.builder.fs.FileSystem
import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.div
import kotlin.test.Test
import kotlin.test.assertEquals
class PostLoaderContentTest {
private companion object {
val ROOT_PATH: Path = Paths.get("workspace", "posts")
fun String.toContentHtmlPath() = ROOT_PATH / this / "content.html"
}
private lateinit var fileSystem: FileSystem
private lateinit var loader: PostLoader
@BeforeEach
fun setupTest() {
fileSystem = mockk<FileSystem>()
loader = FileSystemPostLoader(
postRoot = ROOT_PATH,
fileSystem = fileSystem
)
}
@AfterEach
fun cleanupTest() {
checkUnnecessaryStub(fileSystem)
unmockkAll()
}
@Test
fun `readContent returns post content on success`() {
everyContentHtmlRead("first") returns "Hello, world!"
assertEquals("Hello, world!", loader.readContent("first"))
}
@Test
fun `readContent should throw NoSuchFileException`() {
everyContentHtmlRead("non existent") throws NoSuchFileException("non existent".toContentHtmlPath().toFile())
assertThrows(NoSuchFileException::class.java) { loader.readContent("non existent") }
}
@Test
fun `readMeta should throw IllegalArgumentException for path traversal attempts`() {
assertThrows(IllegalArgumentException::class.java) { loader.readContent("../traversed") }
// while not required, here for explicitness
verify(exactly = 0) { fileSystem.readText(any()) }
}
private fun everyContentHtmlRead(name: String) =
every { fileSystem.readText(name.toContentHtmlPath()) }
}

View file

@ -0,0 +1,74 @@
package eu.m724.bsk2.builder.post.loader
import eu.m724.bsk2.builder.fs.FileSystem
import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.div
import kotlin.test.assertEquals
class PostLoaderCoverTest{
private companion object {
val ROOT_PATH: Path = Paths.get("workspace", "posts")
fun String.toCoverPath(extension: String = "webp") = ROOT_PATH / this / "cover.$extension"
}
private lateinit var fileSystem: FileSystem
private lateinit var loader: PostLoader
@BeforeEach
fun setupTest() {
fileSystem = mockk<FileSystem>()
loader = FileSystemPostLoader(
postRoot = ROOT_PATH,
fileSystem = fileSystem
)
}
@AfterEach
fun cleanupTest() {
checkUnnecessaryStub(fileSystem)
unmockkAll()
}
@ParameterizedTest
@ValueSource(strings = ["webp", "png"])
fun `findCover should find the path to cover`(extension: String) {
every { fileSystem.exists(any()) } returns false
every { fileSystem.exists("first".toCoverPath(extension)) } returns true
assertEquals("first".toCoverPath(extension), loader.findCover("first"))
}
@Test
fun `findCover should throw IllegalStateException when there are multiple matches`() {
//every { fileSystem.exists(any()) } returns false
every { fileSystem.exists("first".toCoverPath("webp")) } returns true
every { fileSystem.exists("first".toCoverPath("png")) } returns true
assertThrows(IllegalStateException::class.java) { loader.findCover("first") }
}
@Test
fun `findCover should throw NoSuchFileException`() {
every { fileSystem.exists(any()) } returns false
assertThrows(NoSuchFileException::class.java) { loader.findCover("first") }
}
@Test
fun `findCover should throw IllegalArgumentException for path traversal attempts`() {
assertThrows(IllegalArgumentException::class.java) { loader.findCover("../traversed") }
// while not required, here for explicitness
verify(exactly = 0) { fileSystem.exists(any()) }
}
}

View file

@ -1,7 +1,7 @@
package eu.m724.bsk2.builder.post.loader package eu.m724.bsk2.builder.post.loader
import com.charleskorn.kaml.YamlException import com.charleskorn.kaml.YamlException
import eu.m724.bsk2.builder.FileSystem import eu.m724.bsk2.builder.fs.FileSystem
import eu.m724.bsk2.builder.post.data.PostMeta import eu.m724.bsk2.builder.post.data.PostMeta
import io.mockk.* import io.mockk.*
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
@ -13,7 +13,7 @@ import kotlin.io.path.div
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class PostLoaderTest { class PostLoaderMetaTest {
private companion object { private companion object {
val ROOT_PATH: Path = Paths.get("workspace", "posts") val ROOT_PATH: Path = Paths.get("workspace", "posts")
@ -40,7 +40,7 @@ class PostLoaderTest {
} }
@Test @Test
fun `getPost should cache the result on success`() { fun `readMeta should return the post meta on success`() {
everyMetaYmlRead("first") returns """ everyMetaYmlRead("first") returns """
title: Hello, world! title: Hello, world!
summary: My first post summary: My first post
@ -66,39 +66,32 @@ class PostLoaderTest {
summary = "My second post" summary = "My second post"
) )
repeat(3) { assertEquals(expectedFirst, loader.getMeta("first")) } assertEquals(expectedFirst, loader.readMeta("first"))
repeat(3) { assertEquals(expectedSecond, loader.getMeta("second")) } assertEquals(expectedSecond, loader.readMeta("second"))
// ensure that cached
verify(exactly = 1) { fileSystem.readText("first".toMetaYmlPath()) } verify(exactly = 1) { fileSystem.readText("first".toMetaYmlPath()) }
verify(exactly = 1) { fileSystem.readText("second".toMetaYmlPath()) } verify(exactly = 1) { fileSystem.readText("second".toMetaYmlPath()) }
} }
@Test @Test
fun `getPost should throw YamlException on invalid meta YML file and not cache the failure`() { fun `readMeta should throw YamlException on invalid meta YML file`() {
everyMetaYmlRead("invalid") returns "Totally invalid" everyMetaYmlRead("invalid") returns "Totally invalid"
repeat(3) { assertThrows(YamlException::class.java) { loader.getMeta("invalid") } } assertThrows(YamlException::class.java) { loader.readMeta("invalid") }
// ensure not cached
verify(exactly = 3) { fileSystem.readText("invalid".toMetaYmlPath()) }
} }
@Test @Test
fun `getPost should throw NoSuchFileException and not cache the failure`() { fun `readMeta should throw NoSuchFileException`() {
everyMetaYmlRead("non existent") throws NoSuchFileException("non existent".toMetaYmlPath().toFile()) everyMetaYmlRead("non existent") throws NoSuchFileException("non existent".toMetaYmlPath().toFile())
repeat(3) { assertThrows(NoSuchFileException::class.java) { loader.getMeta("non existent") } } assertThrows(NoSuchFileException::class.java) { loader.readMeta("non existent") }
// ensure not cached
verify(exactly = 3) { fileSystem.readText("non existent".toMetaYmlPath()) }
} }
@Test @Test
fun `getPost should throw IllegalArgumentException for path traversal attempts`() { fun `readMeta should throw IllegalArgumentException for path traversal attempts`() {
assertThrows(IllegalArgumentException::class.java) { loader.getMeta("../traversed") } assertThrows(IllegalArgumentException::class.java) { loader.readMeta("../traversed") }
// while not necessary, here for explicitness // while not required, here for explicitness
verify(exactly = 0) { fileSystem.readText(any()) } verify(exactly = 0) { fileSystem.readText(any()) }
} }

View file

@ -1,6 +1,6 @@
package eu.m724.bsk2.builder.template.loader package eu.m724.bsk2.builder.template.loader
import eu.m724.bsk2.builder.FileSystem import eu.m724.bsk2.builder.fs.FileSystem
import io.mockk.* import io.mockk.*
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
@ -14,8 +14,6 @@ import kotlin.test.assertEquals
class TemplateLoaderTest { class TemplateLoaderTest {
private companion object { private companion object {
val ROOT_PATH: Path = Paths.get("workspace", "template") val ROOT_PATH: Path = Paths.get("workspace", "template")
const val POST_TEMPLATE_TEXT = "This is the post template"
const val INDEX_TEMPLATE_TEXT = "This is the index template"
fun String.toHtmlTemplatePath() = (ROOT_PATH / "html" / "$this.html") fun String.toHtmlTemplatePath() = (ROOT_PATH / "html" / "$this.html")
} }
@ -40,43 +38,25 @@ class TemplateLoaderTest {
} }
@Test @Test
fun `getHtml should return file content on success`() { fun `readHtml should return file content on success`() {
everyHtmlTemplateRead("post") returns POST_TEMPLATE_TEXT everyHtmlTemplateRead("post") returns "This is the post template"
everyHtmlTemplateRead("index") returns INDEX_TEMPLATE_TEXT everyHtmlTemplateRead("index") returns "This is the index template"
assertEquals(POST_TEMPLATE_TEXT, loader.getHtml("post")) assertEquals("This is the post template", loader.readHtml("post"))
assertEquals(INDEX_TEMPLATE_TEXT, loader.getHtml("index")) assertEquals("This is the index template", loader.readHtml("index"))
} }
@Test @Test
fun `getHtml should cache the result on success`() { fun `readHtml should throw NoSuchFileException`() {
everyHtmlTemplateRead("post") returns POST_TEMPLATE_TEXT
everyHtmlTemplateRead("index") returns INDEX_TEMPLATE_TEXT
repeat(3) { assertEquals(POST_TEMPLATE_TEXT, loader.getHtml("post")) }
repeat(3) { assertEquals(INDEX_TEMPLATE_TEXT, loader.getHtml("index")) }
// ensure the reads are cached
verify(exactly = 1) { fileSystem.readText("post".toHtmlTemplatePath()) }
verify(exactly = 1) { fileSystem.readText("index".toHtmlTemplatePath()) }
}
@Test
fun `getHtml should throw NoSuchFileException and not cache the failure`() {
everyHtmlTemplateRead("non existent") throws NoSuchFileException("non existent".toHtmlTemplatePath().toFile()) everyHtmlTemplateRead("non existent") throws NoSuchFileException("non existent".toHtmlTemplatePath().toFile())
assertThrows(NoSuchFileException::class.java) { loader.getHtml("non existent") } assertThrows(NoSuchFileException::class.java) { loader.readHtml("non existent") }
// ensure that the result is not cached
assertThrows(NoSuchFileException::class.java) { loader.getHtml("non existent") }
verify(exactly = 2) { fileSystem.readText("non existent".toHtmlTemplatePath()) }
} }
@Test @Test
fun `getHtml should throw IllegalArgumentException for path traversal attempts`() { fun `readHtml should throw IllegalArgumentException for path traversal attempts`() {
assertThrows(IllegalArgumentException::class.java) { assertThrows(IllegalArgumentException::class.java) {
loader.getHtml("../traversed") loader.readHtml("../traversed")
} }
// while not necessary, here for explicitness // while not necessary, here for explicitness