Add config

This commit is contained in:
Lars Westermann 2019-05-29 12:44:33 +02:00
parent 32596228fe
commit 997f374fe4
Signed by: lars.westermann
GPG key ID: 9D417FA5BB9D5E1D
18 changed files with 287 additions and 120 deletions

1
.gitignore vendored
View file

@ -3,6 +3,7 @@
build/
web/
.sessions/
data/
*.swp
*.swo

View file

@ -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]) {

6
portal.toml Normal file
View file

@ -0,0 +1,6 @@
[server]
host = "localhost"
port = 8080
[schedule]
reference = "2019-06-12"

View file

@ -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(

View file

@ -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<PostChangeEvent> {
sortOverviewPosts(main)
}
}

View file

@ -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)

View file

@ -967,12 +967,6 @@ form {
min-width: 20%;
}
.overview-shortcuts {
a {
display: block;
}
}
.overview-twitter {
height: 20rem;
background-color: #b3e6f9;

View file

@ -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

View file

@ -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<T>(val item: Item<T>) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
return config[item]
}
}
fun <T> c(item: Item<T>) = ConfigDelegate(item)
private object ServerSpec : ConfigSpec("server") {
val host by required<String>()
val port by required<Int>()
}
object Server {
val host by c(ServerSpec.host)
val port by c(ServerSpec.port)
}
private object PathSpec : ConfigSpec("path") {
val web by required<String>()
val sessions by required<String>()
val uploads by required<String>()
val database by required<String>()
}
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<String>()
}
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<String>("session_name")
val signKey by required<String>("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()
}
}

View file

@ -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<String>) {
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)
}

View file

@ -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<String, Any?>())
@ -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()
}
}

View file

@ -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<PortalSession>(
"SESSION",
directorySessionStorage(File(".sessions"), cached = false)
Configuration.Security.sessionName,
directorySessionStorage(Configuration.Path.sessionsPath.toFile(), cached = false)
) {
cookie.path = "/"
transform(SessionTransportTransformerMessageAuthentication(signKey))

View file

@ -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 {

View file

@ -68,4 +68,5 @@ object DbPost : Table() {
val content = text("content")
val url = varchar("url", 64).uniqueIndex()
val pinned = bool("pinned")
}

View file

@ -22,8 +22,9 @@ object PostRepository : Repository<Post> {
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<Post> {
}
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<Post> {
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<Post> {
override suspend fun all(): List<Post> {
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<Post> {
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<Post> {
return dbQuery {
val result = DbPost.select { DbPost.pinned eq true }
result.map(this::rowToModel)
}
}
fun registerPushService() {
onCreate {
runBlocking {

View file

@ -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)

View file

@ -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",

View file

@ -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"