diff --git a/.gitignore b/.gitignore index 43c1e65..58d7bc4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ build/ web/ .sessions/ +data/ *.swp *.swo diff --git a/build.gradle b/build.gradle index 28332da..b948856 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,7 @@ version "0.1.0" repositories { jcenter() + maven { url 'https://jitpack.io' } maven { url "https://dl.bintray.com/kotlin/ktor" } maven { url "https://dl.bintray.com/jetbrains/markdown" } maven { url "https://kotlin.bintray.com/kotlinx" } @@ -36,6 +37,7 @@ kotlin { jvm() { compilations.all { kotlinOptions { + jvmTarget = "1.8" freeCompilerArgs += [ "-Xuse-experimental=io.ktor.util.KtorExperimentalAPI" ] @@ -88,6 +90,7 @@ kotlin { implementation "de.westermann:KObserve-jvm:$observable_version" + implementation 'com.github.uchuhimo:konf:master-SNAPSHOT' implementation 'com.vladsch.flexmark:flexmark-all:0.42.10' api 'io.github.microutils:kotlin-logging:1.6.23' api 'ch.qos.logback:logback-classic:1.2.3' @@ -167,6 +170,7 @@ task run(type: JavaExec, dependsOn: [jvmMainClasses, jsJar]) { clean.doFirst { delete webFolder delete ".sessions" + delete "data" } task jar(type: ShadowJar, dependsOn: [jvmMainClasses, jsMainClasses, sass]) { diff --git a/portal.toml b/portal.toml new file mode 100644 index 0000000..834b94c --- /dev/null +++ b/portal.toml @@ -0,0 +1,6 @@ +[server] +host = "localhost" +port = 8080 + +[schedule] +reference = "2019-06-12" \ No newline at end of file diff --git a/src/commonMain/kotlin/de/kif/common/model/Post.kt b/src/commonMain/kotlin/de/kif/common/model/Post.kt index d739f61..c53cf31 100644 --- a/src/commonMain/kotlin/de/kif/common/model/Post.kt +++ b/src/commonMain/kotlin/de/kif/common/model/Post.kt @@ -9,7 +9,8 @@ data class Post( override val id: Long? = null, val name: String, val content: String, - val url: String + val url: String, + val pinned: Boolean = false ) : Model { override fun createSearch() = SearchElement( diff --git a/src/jsMain/kotlin/de/kif/frontend/views/overview/OverviewMain.kt b/src/jsMain/kotlin/de/kif/frontend/views/overview/OverviewMain.kt index 08f6f2f..f9ee2c8 100644 --- a/src/jsMain/kotlin/de/kif/frontend/views/overview/OverviewMain.kt +++ b/src/jsMain/kotlin/de/kif/frontend/views/overview/OverviewMain.kt @@ -3,11 +3,28 @@ package de.kif.frontend.views.overview import de.kif.frontend.iterator import de.kif.frontend.launch import de.kif.frontend.repository.PostRepository +import de.westermann.kobserve.event.subscribe import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLTextAreaElement import org.w3c.dom.events.EventListener import org.w3c.dom.get import kotlin.browser.document +import kotlin.dom.clear + +private fun sortOverviewPosts(container: HTMLElement) { + val list = container.children.iterator().asSequence().toList() + + val sorted = list.sortedWith(compareBy( + { if (it.dataset["pinned"]?.toBoolean() == true) 0 else 1 }, + { -(it.dataset["id"]?.toLong() ?: -1) } + )) + + container.clear() + + for (element in sorted) { + container.appendChild(element) + } +} fun initOverviewMain() { val main = document.getElementsByClassName("overview-main")[0] as HTMLElement @@ -15,24 +32,13 @@ fun initOverviewMain() { PostRepository.onCreate { val post = PostView.create(it) post.classList += "overview-post" + main.appendChild(post.html) - val first = main.firstElementChild as? HTMLElement + sortOverviewPosts(main) + } - if (first == null) { - main.appendChild(post.html) - return@onCreate - } - if (first.classList.contains("post")) { - main.insertBefore(post.html, first) - return@onCreate - } - - val next = first.nextElementSibling as? HTMLElement - if (next == null) { - main.appendChild(post.html) - } else { - main.insertBefore(post.html, next) - } + subscribe { + sortOverviewPosts(main) } } diff --git a/src/jsMain/kotlin/de/kif/frontend/views/overview/PostView.kt b/src/jsMain/kotlin/de/kif/frontend/views/overview/PostView.kt index 2861685..5fa8254 100644 --- a/src/jsMain/kotlin/de/kif/frontend/views/overview/PostView.kt +++ b/src/jsMain/kotlin/de/kif/frontend/views/overview/PostView.kt @@ -1,8 +1,8 @@ package de.kif.frontend.views.overview -import de.kif.common.model.Post import de.kif.frontend.launch import de.kif.frontend.repository.PostRepository +import de.westermann.kobserve.event.emit import de.westermann.kwebview.View import de.westermann.kwebview.components.Link import de.westermann.kwebview.createHtmlView @@ -16,7 +16,13 @@ class PostView( view: HTMLElement ) : View(view) { - private var postId = dataset["id"]?.toLongOrNull() ?: -1 + val postId = dataset["id"]?.toLongOrNull() ?: -1 + + var pinned: Boolean + get() = dataset["pinned"] == "true" + set(value) { + dataset["pinned"] = value.toString() + } private val nameView: Link private val contentView: View @@ -27,8 +33,11 @@ class PostView( nameView.text = p.name nameView.target = "/p/${p.url}" + pinned = p.pinned contentView.html.innerHTML = PostRepository.htmlByUrl(p.url) + + emit(PostChangeEvent(postId)) } } @@ -67,3 +76,5 @@ class PostView( } } } + +data class PostChangeEvent(val id: Long) diff --git a/src/jsMain/resources/style/style.scss b/src/jsMain/resources/style/style.scss index b4d3922..2b753e0 100644 --- a/src/jsMain/resources/style/style.scss +++ b/src/jsMain/resources/style/style.scss @@ -967,12 +967,6 @@ form { min-width: 20%; } -.overview-shortcuts { - a { - display: block; - } -} - .overview-twitter { height: 20rem; background-color: #b3e6f9; diff --git a/src/jvmMain/kotlin/de/kif/backend/Application.kt b/src/jvmMain/kotlin/de/kif/backend/Application.kt index f90ad8f..0ce3257 100644 --- a/src/jvmMain/kotlin/de/kif/backend/Application.kt +++ b/src/jvmMain/kotlin/de/kif/backend/Application.kt @@ -23,7 +23,7 @@ fun Application.main() { install(ContentNegotiation) { jackson { - enable(SerializationFeature.INDENT_OUTPUT) // Pretty Prints the JSON + enable(SerializationFeature.INDENT_OUTPUT) } } @@ -31,7 +31,7 @@ fun Application.main() { routing { static("/static") { - files(Resources.directory) + files(Configuration.Path.webPath.toFile()) } // UI routes diff --git a/src/jvmMain/kotlin/de/kif/backend/Configuration.kt b/src/jvmMain/kotlin/de/kif/backend/Configuration.kt new file mode 100644 index 0000000..b7fc82e --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/Configuration.kt @@ -0,0 +1,89 @@ +package de.kif.backend + +import com.uchuhimo.konf.Config +import com.uchuhimo.konf.ConfigSpec +import com.uchuhimo.konf.Item +import java.io.FileNotFoundException +import java.nio.file.Paths +import java.text.SimpleDateFormat +import java.util.* +import kotlin.reflect.KProperty + +object Configuration { + + private val config: Config + + class ConfigDelegate(val item: Item) { + operator fun getValue(thisRef: Any?, property: KProperty<*>): T { + return config[item] + } + } + + fun c(item: Item) = ConfigDelegate(item) + + private object ServerSpec : ConfigSpec("server") { + val host by required() + val port by required() + } + + object Server { + val host by c(ServerSpec.host) + val port by c(ServerSpec.port) + } + + private object PathSpec : ConfigSpec("path") { + val web by required() + val sessions by required() + val uploads by required() + val database by required() + } + + object Path { + val web by c(PathSpec.web) + val webPath: java.nio.file.Path by lazy { Paths.get(web).toAbsolutePath() } + + val sessions by c(PathSpec.sessions) + val sessionsPath: java.nio.file.Path by lazy { Paths.get(sessions).toAbsolutePath() } + + val uploads by c(PathSpec.uploads) + val uploadsPath: java.nio.file.Path by lazy { Paths.get(uploads).toAbsolutePath() } + + val database by c(PathSpec.database) + val databasePath: java.nio.file.Path by lazy { Paths.get(database).toAbsolutePath() } + } + + private object ScheduleSpec : ConfigSpec("schedule") { + val reference by required() + } + + object Schedule { + val reference by c(ScheduleSpec.reference) + val referenceDate: Date by lazy { SimpleDateFormat("yyyy-MM-dd").parse(reference) } + } + + private object SecuritySpec : ConfigSpec("security") { + val sessionName by required("session_name") + val signKey by required("sign_key") + } + + object Security { + val sessionName by c(SecuritySpec.sessionName) + val signKey by c(SecuritySpec.signKey) + } + + init { + var config = Config { + addSpec(ServerSpec) + addSpec(PathSpec) + addSpec(ScheduleSpec) + addSpec(SecuritySpec) + }.from.toml.resource("portal.toml") + + try { + config = config.from.toml.file("portal.toml") + } catch (_: FileNotFoundException) { } + + this.config = config.from.env() + .from.systemProperties() + } +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/de/kif/backend/Main.kt b/src/jvmMain/kotlin/de/kif/backend/Main.kt index 08abe04..2618002 100644 --- a/src/jvmMain/kotlin/de/kif/backend/Main.kt +++ b/src/jvmMain/kotlin/de/kif/backend/Main.kt @@ -8,11 +8,14 @@ import io.ktor.application.Application import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty import kotlinx.coroutines.runBlocking +import java.nio.file.Files object Main { @Suppress("UnusedMainParameter") @JvmStatic fun main(args: Array) { + Resources.init() + Connection.init() runBlocking { @@ -47,8 +50,8 @@ object Main { embeddedServer( factory = Netty, - port = 8080, - host = "0.0.0.0", + port = Configuration.Server.port, + host = Configuration.Server.host, module = Application::main ).start(wait = true) } diff --git a/src/jvmMain/kotlin/de/kif/backend/Resources.kt b/src/jvmMain/kotlin/de/kif/backend/Resources.kt index ca720f8..492850c 100644 --- a/src/jvmMain/kotlin/de/kif/backend/Resources.kt +++ b/src/jvmMain/kotlin/de/kif/backend/Resources.kt @@ -17,11 +17,17 @@ object Resources { * * @return File that points the extracted web directory. */ - private fun extractWebFolder(): File { - val destination: Path = Files.createTempDirectory("web") + private fun extractWeb() { + val destination: Path = Configuration.Path.webPath val classPath = "web" - val uri: URI = this::class.java.classLoader.getResource(classPath).toURI() + val uri = this::class.java.classLoader.getResource(classPath)?.toURI() + + if (uri == null) { + logger.warn { "Cannot extract web content" } + return + } + val fileSystem: FileSystem? val src: Path = if (uri.scheme == "jar") { fileSystem = FileSystems.newFileSystem(uri, mutableMapOf()) @@ -43,31 +49,33 @@ object Resources { } } - return destination.toFile() - } - - /** - * File that points the web directory. - */ - val directory = File(".").absoluteFile.canonicalFile.let { currentDir -> - listOf( - "web", - "../web" - ).map { - File(currentDir, it) - }.firstOrNull { it.isDirectory }?.absoluteFile?.canonicalFile ?: extractWebFolder() - }.also { - logger.info { "Web directory: $it" } + logger.info { "Successfully extract web content" } } /** * List of js modules to be included. */ - val jsModules = directory.list().toList().filter { - it.endsWith(".js") && - !it.endsWith(".meta.js") && - !it.endsWith("-test.js") && - it != "require.min.js" - }.joinToString(", ") { "'${it.take(it.length - 3)}'" } + val jsModules by lazy { + Configuration.Path.webPath.toFile().list().toList().filter { + it.endsWith(".js") && + !it.endsWith(".meta.js") && + !it.endsWith("-test.js") && + it != "require.min.js" + }.joinToString(", ") { "'${it.take(it.length - 3)}'" } + } + fun init() { + Files.createDirectories(Configuration.Path.databasePath.parent) + Files.createDirectories(Configuration.Path.sessionsPath) + Files.createDirectories(Configuration.Path.uploadsPath) + Files.createDirectories(Configuration.Path.webPath) + + logger.info { "Database path: ${Configuration.Path.databasePath}" } + logger.info { "Sessions path: ${Configuration.Path.sessionsPath}" } + logger.info { "Uploads path: ${Configuration.Path.uploadsPath}" } + logger.info { "Web path: ${Configuration.Path.webPath}" } + + logger.info { "Extract web content..." } + extractWeb() + } } diff --git a/src/jvmMain/kotlin/de/kif/backend/Security.kt b/src/jvmMain/kotlin/de/kif/backend/Security.kt index 92222a7..c7382b5 100644 --- a/src/jvmMain/kotlin/de/kif/backend/Security.kt +++ b/src/jvmMain/kotlin/de/kif/backend/Security.kt @@ -14,7 +14,6 @@ import io.ktor.sessions.* import io.ktor.util.hex import io.ktor.util.pipeline.PipelineContext import org.mindrot.jbcrypt.BCrypt -import java.io.File interface ErrorContext { suspend infix fun onFailure(block: suspend () -> Unit) @@ -103,20 +102,12 @@ fun Application.security() { } } - val encryptionKey = - hex("80 51 b8 13 b4 73 a9 69 c7 b0 10 ad 08 06 11 e3".replace(" ", "")) - val signKey = - hex( - "d1 20 23 8c 01 f8 f0 0d 9d 7c ff 68 21 97 75 31 38 3f fb 91 20 3a 8d 86 d4 e9 d8 50 f8 71 f1 dc".replace( - " ", - "" - ) - ) + val signKey = hex(Configuration.Security.signKey.replace(" ", "")) install(Sessions) { cookie( - "SESSION", - directorySessionStorage(File(".sessions"), cached = false) + Configuration.Security.sessionName, + directorySessionStorage(Configuration.Path.sessionsPath.toFile(), cached = false) ) { cookie.path = "/" transform(SessionTransportTransformerMessageAuthentication(signKey)) diff --git a/src/jvmMain/kotlin/de/kif/backend/database/Connection.kt b/src/jvmMain/kotlin/de/kif/backend/database/Connection.kt index 89c8f7c..c040b38 100644 --- a/src/jvmMain/kotlin/de/kif/backend/database/Connection.kt +++ b/src/jvmMain/kotlin/de/kif/backend/database/Connection.kt @@ -1,5 +1,6 @@ package de.kif.backend.database +import de.kif.backend.Configuration import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.exposed.sql.Database @@ -11,7 +12,8 @@ import java.sql.Connection.TRANSACTION_SERIALIZABLE object Connection { fun init() { - Database.connect("jdbc:sqlite:portal.db", "org.sqlite.JDBC") + val dbPath = Configuration.Path.databasePath.toString() + Database.connect("jdbc:sqlite:$dbPath", "org.sqlite.JDBC") TransactionManager.manager.defaultIsolationLevel = TRANSACTION_SERIALIZABLE transaction { diff --git a/src/jvmMain/kotlin/de/kif/backend/database/Schema.kt b/src/jvmMain/kotlin/de/kif/backend/database/Schema.kt index 7849ad5..cfb6b7c 100644 --- a/src/jvmMain/kotlin/de/kif/backend/database/Schema.kt +++ b/src/jvmMain/kotlin/de/kif/backend/database/Schema.kt @@ -68,4 +68,5 @@ object DbPost : Table() { val content = text("content") val url = varchar("url", 64).uniqueIndex() + val pinned = bool("pinned") } diff --git a/src/jvmMain/kotlin/de/kif/backend/repository/PostRepository.kt b/src/jvmMain/kotlin/de/kif/backend/repository/PostRepository.kt index e7ea704..16b6c42 100644 --- a/src/jvmMain/kotlin/de/kif/backend/repository/PostRepository.kt +++ b/src/jvmMain/kotlin/de/kif/backend/repository/PostRepository.kt @@ -22,8 +22,9 @@ object PostRepository : Repository { val name = row[DbPost.name] val content = row[DbPost.content] val url = row[DbPost.url] + val pinned = row[DbPost.pinned] - return Post(id, name, content, url) + return Post(id, name, content, url, pinned) } override suspend fun get(id: Long): Post? { @@ -33,11 +34,24 @@ object PostRepository : Repository { } override suspend fun create(model: Post): Long { + if (model.pinned) { + for (it in getPinned()) { + update(it.copy(pinned = false)) + } + } + return dbQuery { + if (model.pinned) { + DbPost.update({ DbPost.pinned eq true }) { + it[pinned] = false + } + } + val id = DbPost.insert { it[name] = model.name it[content] = model.content it[url] = model.url + it[pinned] = model.pinned }[DbPost.id] ?: throw IllegalStateException("Cannot create model!") onCreate.emit(id) @@ -48,11 +62,21 @@ object PostRepository : Repository { override suspend fun update(model: Post) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") + + if (model.pinned) { + for (it in getPinned()) { + if (it.id != model.id) { + update(it.copy(pinned = false)) + } + } + } + dbQuery { DbPost.update({ DbPost.id eq model.id }) { it[name] = model.name it[content] = model.content it[url] = model.url + it[pinned] = model.pinned } onUpdate.emit(model.id) @@ -69,7 +93,10 @@ object PostRepository : Repository { override suspend fun all(): List { return dbQuery { - val result = DbPost.selectAll() + val result = DbPost.selectAll().orderBy( + DbPost.pinned to SortOrder.ASC, + DbPost.id to SortOrder.ASC + ) result.map(this::rowToModel) } @@ -79,14 +106,20 @@ object PostRepository : Repository { return dbQuery { val result = DbPost.select { DbPost.url eq url } - runBlocking { - result.firstOrNull()?.let { - rowToModel(it) - } + result.firstOrNull()?.let { + rowToModel(it) } } } + suspend fun getPinned(): List { + return dbQuery { + val result = DbPost.select { DbPost.pinned eq true } + + result.map(this::rowToModel) + } + } + fun registerPushService() { onCreate { runBlocking { diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Overview.kt b/src/jvmMain/kotlin/de/kif/backend/route/Overview.kt index 47cafe7..a60cfe7 100644 --- a/src/jvmMain/kotlin/de/kif/backend/route/Overview.kt +++ b/src/jvmMain/kotlin/de/kif/backend/route/Overview.kt @@ -27,6 +27,8 @@ fun DIV.createPost(post: Post, editable: Boolean = false, additionalClasses: Str } div(classes) { attributes["data-id"] = post.id.toString() + attributes["data-pinned"] = post.pinned.toString() + a("/p/${post.url}", classes = "post-name") { +post.name } @@ -58,37 +60,16 @@ fun Route.overview() { content { div("overview") { div("overview-main") { - if (editable) { - div("overview-new") { - a("post/new", classes = "form-btn") { - +"New" - } - } - } - for (post in postList) { createPost(post, editable, "overview-post") } } div("overview-side") { - div("overview-shortcuts") { - a { - +"Wiki" - } - a { - +"Wiki" - } - a { - +"Wiki" - } - a { - +"Wiki" - } - a { - +"Wiki" - } - a { - +"Wiki" + if (editable) { + div("overview-new") { + a("post/new", classes = "form-btn") { + +"New" + } } } div("overview-twitter") { @@ -124,7 +105,6 @@ fun Route.overview() { } } - get("/post/{id}") { authenticateOrRedirect(Permission.POST) { user -> val postId = call.parameters["id"]?.toLongOrNull() ?: return@get @@ -153,6 +133,7 @@ fun Route.overview() { value = editPost.name } } + div("form-group") { label { htmlFor = "url" @@ -162,12 +143,29 @@ fun Route.overview() { name = "url", classes = "form-control" ) { - id = "places" - placeholder = "Places" + id = "url" + placeholder = "Url" value = editPost.url } } + div("form-switch-group") { + div("form-group form-switch") { + input( + name = "pinned", + classes = "form-control", + type = InputType.checkBox + ) { + id = "pinned" + checked = editPost.pinned + } + label { + htmlFor = "pinned" + +"Pinned" + } + } + } + div("form-group") { label { htmlFor = "content" @@ -218,6 +216,7 @@ fun Route.overview() { params["name"]?.let { post = post.copy(name = it) } params["url"]?.let { post = post.copy(url = it) } params["content"]?.let { post = post.copy(content = it) } + params["pinned"]?.let { post = post.copy(pinned = it == "on") } PostRepository.update(post) @@ -260,12 +259,29 @@ fun Route.overview() { name = "url", classes = "form-control" ) { - id = "places" - placeholder = "Places" + id = "url" + placeholder = "Url" value = Post.generateUrl() } } + div("form-switch-group") { + div("form-group form-switch") { + input( + name = "pinned", + classes = "form-control", + type = InputType.checkBox + ) { + id = "pinned" + checked = false + } + label { + htmlFor = "pinned" + +"Pinned" + } + } + } + div("form-group") { label { htmlFor = "content" @@ -309,8 +325,9 @@ fun Route.overview() { val name = params["name"] ?: return@post val content = params["content"] ?: return@post val url = params["url"] ?: return@post + val pinned = params["pinned"] == "on" - val post = Post(null, name, content, url) + val post = Post(null, name, content, url, pinned) PostRepository.create(post) diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Room.kt b/src/jvmMain/kotlin/de/kif/backend/route/Room.kt index c923121..84044bc 100644 --- a/src/jvmMain/kotlin/de/kif/backend/route/Room.kt +++ b/src/jvmMain/kotlin/de/kif/backend/route/Room.kt @@ -166,9 +166,7 @@ fun Route.room() { +"Projector" } } - } - div("form-switch-group") { div("form-group form-switch") { input( name = "internet", @@ -183,9 +181,7 @@ fun Route.room() { +"Internet" } } - } - div("form-switch-group") { div("form-group form-switch") { input( name = "whiteboard", @@ -200,9 +196,7 @@ fun Route.room() { +"Whiteboard" } } - } - div("form-switch-group") { div("form-group form-switch") { input( name = "blackboard", @@ -217,9 +211,7 @@ fun Route.room() { +"Blackboard" } } - } - div("form-switch-group") { div("form-group form-switch") { input( name = "accessible", @@ -337,9 +329,7 @@ fun Route.room() { +"Projector" } } - } - div("form-switch-group") { div("form-group form-switch") { input( name = "internet", @@ -354,9 +344,7 @@ fun Route.room() { +"Internet" } } - } - div("form-switch-group") { div("form-group form-switch") { input( name = "whiteboard", @@ -371,9 +359,7 @@ fun Route.room() { +"Whiteboard" } } - } - div("form-switch-group") { div("form-group form-switch") { input( name = "blackboard", @@ -388,9 +374,7 @@ fun Route.room() { +"Blackboard" } } - } - div("form-switch-group") { div("form-group form-switch") { input( name = "accessible", diff --git a/src/jvmMain/resources/portal.toml b/src/jvmMain/resources/portal.toml new file mode 100644 index 0000000..c2d6147 --- /dev/null +++ b/src/jvmMain/resources/portal.toml @@ -0,0 +1,16 @@ +[server] +host = "localhost" +port = 8080 + +[path] +web = "web" +sessions = "data/sessions" +uploads = "data/uploads" +database = "data/portal.db" + +[schedule] +reference = "2019-03-27" + +[security] +session_name = "SESSION" +sign_key = "d1 20 23 8c 01 f8 f0 0d 9d 7c ff 68 21 97 75 31 38 3f fb 91 20 3a 8d 86 d4 e9 d8 50 f8 71 f1 dc"