From 984b91beafc67d7e87965a79a34a1b5f00e23625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Ku=C4=8Dera?= Date: Sat, 11 Nov 2023 21:19:20 +0100 Subject: [PATCH] refactor: rearrange code, add CLI, gson -> jackson :) --- README.md | 2 +- app/build.gradle.kts | 14 +- .../imagery/app/config/JavaConfigNotUsed.java | 9 - .../main/kotlin/dev/mizule/imagery/app/App.kt | 213 +++++++----------- .../dev/mizule/imagery/app/config/Config.kt | 12 +- .../mizule/imagery/app/launcher/Launcher.kt | 47 +++- .../imagery/app/model/ImageLookupResult.kt | 6 + 7 files changed, 144 insertions(+), 159 deletions(-) delete mode 100644 app/src/main/java/dev/mizule/imagery/app/config/JavaConfigNotUsed.java create mode 100644 app/src/main/kotlin/dev/mizule/imagery/app/model/ImageLookupResult.kt diff --git a/README.md b/README.md index f29b0ff..f000a28 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # imagery -A WIPShareX file server \ No newline at end of file +A WIP ShareX file server. \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5e84ce0..ee62d9d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,8 +1,8 @@ plugins { id("org.jetbrains.kotlin.jvm") version "1.9.20" - `java-library` id("com.github.johnrengelman.shadow") version "8.1.1" id("net.kyori.indra") version "3.1.3" + `java-library` application } @@ -15,12 +15,13 @@ repositories { dependencies { implementation(kotlin("stdlib")) implementation(kotlin("reflect")) - implementation("com.google.guava:guava:32.1.3-jre") - implementation("com.google.code.gson:gson:2.10.1") + implementation("io.github.oshai:kotlin-logging-jvm:5.1.0") + implementation("org.jetbrains.kotlinx:kotlinx-cli-jvm:0.3.6") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.3") implementation("org.slf4j:slf4j-simple:2.0.9") implementation("io.javalin:javalin:6.0.0-beta.2") implementation("com.github.ben-manes.caffeine:caffeine:3.1.8") - implementation("org.spongepowered:configurate-gson:4.2.0-SNAPSHOT") + implementation("org.spongepowered:configurate-jackson:4.2.0-SNAPSHOT") implementation("org.spongepowered:configurate-hocon:4.2.0-SNAPSHOT") implementation("org.spongepowered:configurate-extra-kotlin:4.2.0-SNAPSHOT") } @@ -43,9 +44,6 @@ tasks { } runShadow { - file("run").mkdirs() - workingDir = file("run") - systemProperty("terminal.jline", false) - systemProperty("terminal.ansi", true) + workingDir = file("run").also(File::mkdirs) } } \ No newline at end of file diff --git a/app/src/main/java/dev/mizule/imagery/app/config/JavaConfigNotUsed.java b/app/src/main/java/dev/mizule/imagery/app/config/JavaConfigNotUsed.java deleted file mode 100644 index 54a6ae4..0000000 --- a/app/src/main/java/dev/mizule/imagery/app/config/JavaConfigNotUsed.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.mizule.imagery.app.config; - -import org.spongepowered.configurate.objectmapping.ConfigSerializable; - -@ConfigSerializable -public class JavaConfigNotUsed { - - -} diff --git a/app/src/main/kotlin/dev/mizule/imagery/app/App.kt b/app/src/main/kotlin/dev/mizule/imagery/app/App.kt index cc602ee..1de527b 100644 --- a/app/src/main/kotlin/dev/mizule/imagery/app/App.kt +++ b/app/src/main/kotlin/dev/mizule/imagery/app/App.kt @@ -1,165 +1,116 @@ package dev.mizule.imagery.app +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.github.benmanes.caffeine.cache.Cache import com.github.benmanes.caffeine.cache.Caffeine -import com.google.gson.Gson -import com.google.gson.JsonObject import dev.mizule.imagery.app.config.Config +import dev.mizule.imagery.app.model.ImageLookupResult import dev.mizule.imagery.app.model.UploadedFile +import io.github.oshai.kotlinlogging.KotlinLogging import io.javalin.Javalin import io.javalin.http.ContentType +import io.javalin.http.Context import io.javalin.http.HttpStatus -import io.javalin.json.JavalinGson +import io.javalin.json.JavalinJackson import org.eclipse.jetty.http.MimeTypes -import org.spongepowered.configurate.BasicConfigurationNode -import org.spongepowered.configurate.CommentedConfigurationNode -import org.spongepowered.configurate.ConfigurateException -import org.spongepowered.configurate.gson.GsonConfigurationLoader -import org.spongepowered.configurate.hocon.HoconConfigurationLoader -import java.io.FileOutputStream -import java.nio.file.Files +import org.spongepowered.configurate.jackson.JacksonConfigurationLoader +import org.spongepowered.configurate.kotlin.objectMapperFactory import java.nio.file.Path -import java.time.Duration -import kotlin.io.path.Path -import kotlin.io.path.exists - -class App { - private val gson = Gson() - private lateinit var config: Config - private lateinit var configNode: CommentedConfigurationNode - private lateinit var configLoader: HoconConfigurationLoader - private lateinit var dataConfig: BasicConfigurationNode - private lateinit var dataLoader: GsonConfigurationLoader - private val javalin: Javalin by lazy { - Javalin.create { - it.jsonMapper(JavalinGson(gson)) - it.router.ignoreTrailingSlashes = true +import java.util.concurrent.TimeUnit +import kotlin.io.path.* + +private val logger = KotlinLogging.logger {} + +class App(private val config: Config) { + private val storageDir = Path(config.storagePath) + private val dataLoader = JacksonConfigurationLoader.builder() + .path(Path(config.indexPath)) + .defaultOptions { options -> + options.serializers { builder -> + builder.registerAnnotatedObjects(objectMapperFactory()) + } } - } - private val path = Path(".") - val storage: Path = path.resolve("storage") - val cache = Caffeine.newBuilder() - .expireAfterWrite(Duration.ofMinutes(15)) - .expireAfterAccess(Duration.ofMinutes(10)) - .build>() - - fun load() { - initializeStorage() - initializeConfigs() - - javalin.unsafeConfig().contextResolver.ip = { ctx -> + .build() + + private val dataNode = dataLoader.load() + private val cache: Cache = Caffeine.newBuilder() + .expireAfterWrite(15, TimeUnit.MINUTES) + .expireAfterAccess(10, TimeUnit.MINUTES) + .build() + + private val javalin = Javalin.create { + it.jsonMapper(JavalinJackson(MAPPER)) + it.showJavalinBanner = false + it.router.ignoreTrailingSlashes = true + it.contextResolver.ip = { ctx -> ctx.header("CF-Connecting-IP") ?: ctx.req().remoteAddr } - - configureEndpoints() } - private fun initializeStorage() { - if (!storage.exists()) { - Files.createDirectories(storage) - } - - // cache? - } + init { + storageDir.createDirectories() - private fun initializeConfigs() { - this.dataLoader = GsonConfigurationLoader.builder() - .path(path.resolve("files.json")) - .build() - this.configLoader = HoconConfigurationLoader.builder() - .path(path.resolve("config.conf")) - .defaultOptions { - it.shouldCopyDefaults(true) - } - .build() - - try { - this.dataConfig = dataLoader.load() - this.configNode = configLoader.load() - dataLoader.save(this.dataConfig) - configLoader.save(this.configNode) - this.config = configNode.get(Config::class.java) ?: return - } catch (e: ConfigurateException) { - throw RuntimeException(e) + javalin.beforeMatched { ctx -> + logger.info { "Received ${ctx.method()} request from: ${ctx.ip()}:${ctx.port()} for ${ctx.fullUrl()}" } } + javalin.get("/{id}", ::serveUploadedFile) + javalin.post("/upload", ::handleFileUpload) } - private fun configureEndpoints() { - javalin.beforeMatched { ctx -> - println( - String.format( - "Received %s request from: %s:%s for %s", - ctx.method(), - ctx.ip(), - ctx.port(), - ctx.fullUrl() - ) - ) - } - javalin.post("/upload") { ctx -> - handleFileUpload(ctx) + private fun handleFileUpload(ctx: Context) { + val file = ctx.uploadedFiles("file").firstOrNull() + if (file == null) { + ctx.status(HttpStatus.BAD_REQUEST) + return } - javalin.get("{id}") { ctx -> - serveUploadedFile(ctx) + val fileName = getRandomString() + file.extension() + val filePath = storageDir.resolve(fileName) + filePath.outputStream().use { + file.content().copyTo(it) } + + val uploadedFile = UploadedFile( + fileName, + "user", + System.currentTimeMillis(), + file.filename(), + file.extension(), + MimeTypes.getDefaultMimeByExtension(file.extension()) + ) + + dataNode.node(uploadedFile.id).set(uploadedFile) + dataLoader.save(dataNode) // TODO: probably shouldn't be done during the http request + + cache.put(fileName, FileCacheEntry(uploadedFile, filePath)) + ctx.json(mapOf("data" to ImageLookupResult("${config.baseUrl}/$fileName"))) } - private fun handleFileUpload(ctx: io.javalin.http.Context) { - ctx.uploadedFiles("file").first().also { file -> - val fileName = getRandomString() + file.extension() - val filePath = storage.resolve(fileName) - Files.createFile(filePath) - FileOutputStream(filePath.toFile()).use { - file.content().copyTo(it) + private fun serveUploadedFile(ctx: Context) { + cache.getIfPresent(ctx.pathParam("id")) + ?.also { (uploadedFile, path) -> + ctx.result(path.inputStream()) + .contentType(ContentType.getContentTypeByExtension(uploadedFile.extension) ?: ContentType.IMAGE_PNG) } - val uploadedFile = UploadedFile( - fileName, - "user", - System.currentTimeMillis(), - file.filename(), - file.extension(), - MimeTypes.getDefaultMimeByExtension(file.extension()) - ) - dataConfig.node(uploadedFile.id).set(uploadedFile) - try { - dataLoader.save(this.dataConfig) - } catch (e: ConfigurateException) { - throw RuntimeException(e) - } - cache.put(fileName, Pair(uploadedFile, filePath)) - ctx.json(JsonObject().apply { - add("data", JsonObject().apply { - addProperty("url", "https://i.mizule.dev/$fileName") - addProperty("delete", "https://i.mizule.dev/$fileName/del") - }) - }) - } + ?: ctx.status(HttpStatus.NOT_FOUND) } - private fun serveUploadedFile(ctx: io.javalin.http.Context) { - val path = cache.getIfPresent(ctx.pathParam("id")) - - if (path == null) { - ctx.status(HttpStatus.NOT_FOUND) - } else { - val file = path.second.toFile() - ctx.result(file.inputStream()) - .contentType(ContentType.getContentTypeByExtension(path.first.extension) ?: ContentType.IMAGE_PNG) - } + fun start() { + logger.info { "Starting HTTP server at port ${config.port}..." } + javalin.start(config.port) } - fun enable() { - javalin.start(this.config.port) + fun stop() { + logger.info { "Shutting down..." } } - fun disable() { - println("Shutting down...") - } + companion object { + private val MAPPER = jacksonObjectMapper() + private val ALLOWED_CHARS = ('A'..'Z') + ('a'..'z') + ('0'..'9') - private fun getRandomString(length: Int = 8): String { - val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') - return (1..length) - .map { allowedChars.random() } - .joinToString("") + private fun getRandomString(length: Int = 8): String = + generateSequence(ALLOWED_CHARS::random).take(length).joinToString("") } + + data class FileCacheEntry(val file: UploadedFile, val path: Path) } diff --git a/app/src/main/kotlin/dev/mizule/imagery/app/config/Config.kt b/app/src/main/kotlin/dev/mizule/imagery/app/config/Config.kt index 5338b30..68dead1 100644 --- a/app/src/main/kotlin/dev/mizule/imagery/app/config/Config.kt +++ b/app/src/main/kotlin/dev/mizule/imagery/app/config/Config.kt @@ -5,9 +5,15 @@ import org.spongepowered.configurate.objectmapping.meta.Comment @ConfigSerializable data class Config( - @Comment("The port to start the application on") + @Comment("The port to start the application on.") val port: Int = 8052, - @Comment("The domain that this will be on") - val domain: String = "https://example.com" + @Comment("The base URL that this will be on, without trailing slashes.") + val baseUrl: String = "https://i.mizule.dev", + + @Comment("The path to the file upload index.") + val indexPath: String = "./files.json", + + @Comment("The path to the uploaded file storage directory.") + val storagePath: String = "./storage" ) diff --git a/app/src/main/kotlin/dev/mizule/imagery/app/launcher/Launcher.kt b/app/src/main/kotlin/dev/mizule/imagery/app/launcher/Launcher.kt index c8cb5ff..f899af8 100644 --- a/app/src/main/kotlin/dev/mizule/imagery/app/launcher/Launcher.kt +++ b/app/src/main/kotlin/dev/mizule/imagery/app/launcher/Launcher.kt @@ -1,15 +1,48 @@ @file:JvmName("Launcher") + package dev.mizule.imagery.app.launcher import dev.mizule.imagery.app.App +import dev.mizule.imagery.app.config.Config +import kotlinx.cli.ArgParser +import kotlinx.cli.ArgType +import kotlinx.cli.default +import org.spongepowered.configurate.hocon.HoconConfigurationLoader +import org.spongepowered.configurate.kotlin.extensions.get +import org.spongepowered.configurate.kotlin.objectMapperFactory +import kotlin.io.path.Path +import kotlin.io.path.exists + +fun main(args: Array) { + val parser = ArgParser("imagery") + val path by parser.option(ArgType.String, shortName = "p", description = "The configuration file path, created if not present.") + .default("./config.conf") + + parser.parse(args) + + val configPath = Path(path) + val configLoader = HoconConfigurationLoader.builder() + .path(configPath) + .defaultOptions { options -> + options.shouldCopyDefaults(true) + options.serializers { builder -> + builder.registerAnnotatedObjects(objectMapperFactory()) + } + } + .build() + + val configNode = configLoader.load() + val config = requireNotNull(configNode.get()) { + "Could not read configuration" + } -fun main() { - val app = App() + if (!configPath.exists()) { + configNode.set(config) // update the backing node to add defaults + configLoader.save(configNode) + } - app.load() - app.enable() + val app = App(config) + Runtime.getRuntime().addShutdownHook(Thread(app::stop)) - Runtime.getRuntime().addShutdownHook(Thread { - app.disable() - }) + app.start() } diff --git a/app/src/main/kotlin/dev/mizule/imagery/app/model/ImageLookupResult.kt b/app/src/main/kotlin/dev/mizule/imagery/app/model/ImageLookupResult.kt new file mode 100644 index 0000000..d08a7fb --- /dev/null +++ b/app/src/main/kotlin/dev/mizule/imagery/app/model/ImageLookupResult.kt @@ -0,0 +1,6 @@ +package dev.mizule.imagery.app.model + +data class ImageLookupResult( + val url: String, + val delete: String = "$url/del" +)