diff --git a/build.gradle b/build.gradle index c5e9b34..d6a397d 100644 --- a/build.gradle +++ b/build.gradle @@ -83,7 +83,6 @@ kotlin { implementation "io.ktor:ktor-server-netty:$ktor_version" implementation "io.ktor:ktor-auth:$ktor_version" implementation "io.ktor:ktor-server-sessions:$ktor_version" - implementation "io.ktor:ktor-websockets:$ktor_version" implementation "io.ktor:ktor-jackson:$ktor_version" implementation 'org.jetbrains:kotlin-css-jvm:1.0.0-pre.70-kotlin-1.3.21' diff --git a/src/commonMain/kotlin/de/kif/common/ConstraintChecking.kt b/src/commonMain/kotlin/de/kif/common/ConstraintChecking.kt index 78e2ee8..8f3071c 100644 --- a/src/commonMain/kotlin/de/kif/common/ConstraintChecking.kt +++ b/src/commonMain/kotlin/de/kif/common/ConstraintChecking.kt @@ -48,8 +48,8 @@ fun checkConstraints( if ( schedule != s && leader in s.workGroup.leader && - start <= s.getAbsoluteEndTime() && - s.getAbsoluteStartTime() <= end + start < s.getAbsoluteEndTime() && + s.getAbsoluteStartTime() < end ) { errors += ConstraintError("Work group with leader $leader cannot be at same time with ${s.workGroup.name}!") } diff --git a/src/commonMain/kotlin/de/kif/common/Message.kt b/src/commonMain/kotlin/de/kif/common/Message.kt index e239280..e48a65b 100644 --- a/src/commonMain/kotlin/de/kif/common/Message.kt +++ b/src/commonMain/kotlin/de/kif/common/Message.kt @@ -1,35 +1,38 @@ package de.kif.common +import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationStrategy import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule +@Serializable +data class MessageBox( + val timestamp: Long, + val valid: Boolean, + val messages: List +) + @Serializable data class Message( val type: MessageType, val repository: RepositoryType, val id: Long -) { - - fun stringify(): String { - return json.stringify(serializer(), this) - } - - companion object { - private val jsonContext = SerializersModule {} - - val json = Json(context = jsonContext) - - fun parse(data: String): Message { - return json.parse(serializer(), data) - } - } -} +) enum class MessageType { CREATE, UPDATE, DELETE } enum class RepositoryType { - ROOM, SCHEDULE, TRACK, USER, WORK_GROUP, POST + ROOM, SCHEDULE, TRACK, USER, WORK_GROUP, POST, ANNOUNCEMENT +} + +object Serialization { + private val jsonContext = SerializersModule {} + + val json = Json(context = jsonContext) + + fun stringify(serializer: SerializationStrategy, obj: T) = json.stringify(serializer, obj) + fun parse(serializer: DeserializationStrategy, str: String) = json.parse(serializer, str) } diff --git a/src/commonMain/kotlin/de/kif/common/Search.kt b/src/commonMain/kotlin/de/kif/common/Search.kt index fadef97..184518d 100644 --- a/src/commonMain/kotlin/de/kif/common/Search.kt +++ b/src/commonMain/kotlin/de/kif/common/Search.kt @@ -10,12 +10,12 @@ data class SearchElement( ) { fun stringify(): String { - return Message.json.stringify(serializer(), this) + return Serialization.stringify(serializer(), this) } companion object { fun parse(data: String): SearchElement { - return Message.json.parse(serializer(), data) + return Serialization.parse(serializer(), data) } } } diff --git a/src/jsMain/kotlin/de/kif/frontend/WebSocketClient.kt b/src/jsMain/kotlin/de/kif/frontend/WebSocketClient.kt index 62464af..1a72f25 100644 --- a/src/jsMain/kotlin/de/kif/frontend/WebSocketClient.kt +++ b/src/jsMain/kotlin/de/kif/frontend/WebSocketClient.kt @@ -1,68 +1,92 @@ package de.kif.frontend -import de.kif.common.Message +import de.kif.common.MessageBox import de.kif.common.MessageType import de.kif.common.RepositoryType import de.kif.frontend.repository.* -import de.westermann.kwebview.async -import org.w3c.dom.MessageEvent -import org.w3c.dom.WebSocket -import org.w3c.dom.events.Event +import de.westermann.kwebview.clearInterval +import de.westermann.kwebview.createHtmlView +import de.westermann.kwebview.interval +import kotlinx.serialization.DynamicObjectParser +import org.w3c.dom.get +import org.w3c.xhr.XMLHttpRequest +import kotlin.browser.document import kotlin.browser.window -class WebSocketClient() { +class WebSocketClient { private val prefix = js("prefix") + private val url = "$prefix/api/updates" + private val body = document.body ?: createHtmlView() + private val parser = DynamicObjectParser() - private val useSsl = "https" in window.location.protocol - private val wsProtocol = if (useSsl) "wss" else "ws" - private val url = "$wsProtocol://${window.location.host}$prefix/websocket" + private var timestamp = body.dataset["timestamp"]?.toLongOrNull() ?: 0L + private var intervalId: Int? = null - private lateinit var ws: WebSocket - private var reconnect = false + private fun reload() { + val id = intervalId ?: return + clearInterval(id) + intervalId = null - @Suppress("UNUSED_PARAMETER") - private fun onOpen(event: Event) { - console.log("Connected!") - if (reconnect) { - window.location.reload() - } + window.location.reload() } - private fun onMessage(messageEvent: MessageEvent) { - val message = Message.parse(messageEvent.data?.toString() ?: "") + private fun onMessage(messageBox: MessageBox) { + body.classList.remove("offline") - for (handler in messageHandlers) { - if (handler.repository == message.repository) { - when (message.type) { - MessageType.CREATE -> handler.onCreate(message.id) - MessageType.UPDATE -> handler.onUpdate(message.id) - MessageType.DELETE -> handler.onDelete(message.id) + if (messageBox.valid) { + timestamp = messageBox.timestamp + + for (message in messageBox.messages) { + for (handler in messageHandlers) { + if (handler.repository == message.repository) { + when (message.type) { + MessageType.CREATE -> handler.onCreate(message.id) + MessageType.UPDATE -> handler.onUpdate(message.id) + MessageType.DELETE -> handler.onDelete(message.id) + } + } } } + } else { + reload() } } - @Suppress("UNUSED_PARAMETER") - private fun onClose(event: Event) { - console.log("Disconnected!") - reconnect = true - async(1000) { - connect() + private fun onError(code: Int) { + if (!body.classList.contains("offline")) { + console.log("Offline reason: $code") } + body.classList.add("offline") } - @Suppress("UNUSED_PARAMETER") - private fun onError(event: Event) { - console.log("An error occurred!") - } + private fun request() { + val xmlHttpRequest = XMLHttpRequest() - private fun connect() { - ws = WebSocket(url) + xmlHttpRequest.onreadystatechange = { + try { + if (xmlHttpRequest.readyState == 4.toShort()) { + if (xmlHttpRequest.status == 200.toShort() || xmlHttpRequest.status == 304.toShort()) { + val json = JSON.parse(xmlHttpRequest.responseText) - ws.onopen = this::onOpen - ws.onmessage = this::onMessage - ws.onclose = this::onClose - ws.onerror = this::onError + if (json.OK) { + val message = parser.parse(json.data, MessageBox.serializer()) + onMessage(message) + } else { + onError(-1) + } + + } else { + onError(xmlHttpRequest.status.toInt()) + } + } + } catch (e: Exception) { + console.error(e) + onError(-2) + } + } + xmlHttpRequest.open("GET", "$url?timestamp=$timestamp", true) + xmlHttpRequest.overrideMimeType("application/json") + xmlHttpRequest.send() } private val messageHandlers: List = listOf( @@ -71,11 +95,14 @@ class WebSocketClient() { TrackRepository.handler, UserRepository.handler, WorkGroupRepository.handler, - PostRepository.handler + PostRepository.handler, + AnnouncementRepository.handler ) init { - connect() + intervalId = interval(500) { + request() + } } } diff --git a/src/jsMain/kotlin/de/kif/frontend/main.kt b/src/jsMain/kotlin/de/kif/frontend/main.kt index ed186cf..7f10c8c 100644 --- a/src/jsMain/kotlin/de/kif/frontend/main.kt +++ b/src/jsMain/kotlin/de/kif/frontend/main.kt @@ -2,6 +2,7 @@ package de.kif.frontend import de.kif.frontend.views.board.initBoard import de.kif.frontend.views.calendar.initCalendar +import de.kif.frontend.views.initAnnouncement import de.kif.frontend.views.table.initTableLayout import de.kif.frontend.views.initWorkGroupConstraints import de.kif.frontend.views.overview.initOverviewMain @@ -34,4 +35,7 @@ fun main() = init { if (document.getElementsByClassName("board").length > 0) { initBoard() } + if (document.getElementsByClassName("announcement").length > 0) { + initAnnouncement() + } } diff --git a/src/jsMain/kotlin/de/kif/frontend/repository/Ajax.kt b/src/jsMain/kotlin/de/kif/frontend/repository/Ajax.kt index a08991d..1f5446b 100644 --- a/src/jsMain/kotlin/de/kif/frontend/repository/Ajax.kt +++ b/src/jsMain/kotlin/de/kif/frontend/repository/Ajax.kt @@ -8,10 +8,15 @@ import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine import kotlin.js.Promise +interface JsonResponse { + val OK: Boolean + val data: dynamic +} + suspend fun repositoryGet( url: String ): dynamic { - val promise = Promise { resolve, reject -> + val promise = Promise { resolve, reject -> val xhttp = XMLHttpRequest() xhttp.onreadystatechange = { @@ -47,7 +52,7 @@ suspend fun repositoryPost( url: String, data: String? = null ): dynamic { - val promise = Promise { resolve, reject -> + val promise = Promise { resolve, reject -> val xhttp = XMLHttpRequest() xhttp.onreadystatechange = { diff --git a/src/jsMain/kotlin/de/kif/frontend/repository/AnnouncementRepository.kt b/src/jsMain/kotlin/de/kif/frontend/repository/AnnouncementRepository.kt new file mode 100644 index 0000000..59a1965 --- /dev/null +++ b/src/jsMain/kotlin/de/kif/frontend/repository/AnnouncementRepository.kt @@ -0,0 +1,40 @@ +package de.kif.frontend.repository + +import de.kif.common.Message +import de.kif.common.Repository +import de.kif.common.RepositoryType +import de.kif.common.Serialization +import de.kif.common.model.Room +import de.kif.frontend.MessageHandler +import de.westermann.kobserve.event.EventHandler +import kotlinx.serialization.DynamicObjectParser +import kotlinx.serialization.list +import kotlinx.serialization.serializer + +object AnnouncementRepository { + + private val prefix = js("prefix") + + val onUpdate = EventHandler() + + private val parser = DynamicObjectParser() + + suspend fun getAnnouncement(): String { + val json = repositoryGet("$prefix/api/announcement") ?: return "" + return parser.parse(json, String.serializer()) + } + + suspend fun setAnnouncement(value: String){ + return repositoryPost("$prefix/api/announcement", Serialization.stringify(String.serializer(), value)) + ?: throw IllegalStateException("Cannot set announcement!") + } + + val handler = object : MessageHandler(RepositoryType.ROOM) { + + override fun onCreate(id: Long) {} + + override fun onUpdate(id: Long) = onUpdate.emit(Unit) + + override fun onDelete(id: Long) {} + } +} diff --git a/src/jsMain/kotlin/de/kif/frontend/repository/PostRepository.kt b/src/jsMain/kotlin/de/kif/frontend/repository/PostRepository.kt index aa56570..13c459e 100644 --- a/src/jsMain/kotlin/de/kif/frontend/repository/PostRepository.kt +++ b/src/jsMain/kotlin/de/kif/frontend/repository/PostRepository.kt @@ -3,6 +3,7 @@ package de.kif.frontend.repository import de.kif.common.Message import de.kif.common.Repository import de.kif.common.RepositoryType +import de.kif.common.Serialization import de.kif.common.model.Post import de.kif.frontend.MessageHandler import de.westermann.kobserve.event.EventHandler @@ -25,13 +26,13 @@ object PostRepository : Repository { } override suspend fun create(model: Post): Long { - return repositoryPost("$prefix/api/posts", Message.json.stringify(Post.serializer(), model)) + return repositoryPost("$prefix/api/posts", Serialization.stringify(Post.serializer(), model)) ?: throw IllegalStateException("Cannot create model!") } override suspend fun update(model: Post) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") - repositoryPost("$prefix/api/post/${model.id}", Message.json.stringify(Post.serializer(), model)) + repositoryPost("$prefix/api/post/${model.id}", Serialization.stringify(Post.serializer(), model)) } override suspend fun delete(id: Long) { diff --git a/src/jsMain/kotlin/de/kif/frontend/repository/RoomRepository.kt b/src/jsMain/kotlin/de/kif/frontend/repository/RoomRepository.kt index 1dc88ee..9d933f3 100644 --- a/src/jsMain/kotlin/de/kif/frontend/repository/RoomRepository.kt +++ b/src/jsMain/kotlin/de/kif/frontend/repository/RoomRepository.kt @@ -3,6 +3,7 @@ package de.kif.frontend.repository import de.kif.common.Message import de.kif.common.Repository import de.kif.common.RepositoryType +import de.kif.common.Serialization import de.kif.common.model.Room import de.kif.frontend.MessageHandler import de.westermann.kobserve.event.EventHandler @@ -25,13 +26,13 @@ object RoomRepository : Repository { } override suspend fun create(model: Room): Long { - return repositoryPost("$prefix/api/rooms", Message.json.stringify(Room.serializer(), model)) + return repositoryPost("$prefix/api/rooms", Serialization.stringify(Room.serializer(), model)) ?: throw IllegalStateException("Cannot create model!") } override suspend fun update(model: Room) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") - repositoryPost("$prefix/api/room/${model.id}", Message.json.stringify(Room.serializer(), model)) + repositoryPost("$prefix/api/room/${model.id}", Serialization.stringify(Room.serializer(), model)) } override suspend fun delete(id: Long) { diff --git a/src/jsMain/kotlin/de/kif/frontend/repository/ScheduleRepository.kt b/src/jsMain/kotlin/de/kif/frontend/repository/ScheduleRepository.kt index 60a2b73..3da61bf 100644 --- a/src/jsMain/kotlin/de/kif/frontend/repository/ScheduleRepository.kt +++ b/src/jsMain/kotlin/de/kif/frontend/repository/ScheduleRepository.kt @@ -1,9 +1,6 @@ package de.kif.frontend.repository -import de.kif.common.ConstraintMap -import de.kif.common.Message -import de.kif.common.Repository -import de.kif.common.RepositoryType +import de.kif.common.* import de.kif.common.model.Schedule import de.kif.frontend.MessageHandler import de.westermann.kobserve.event.EventHandler @@ -31,13 +28,13 @@ object ScheduleRepository : Repository { } override suspend fun create(model: Schedule): Long { - return repositoryPost("$prefix/api/schedules", Message.json.stringify(Schedule.serializer(), model)) + return repositoryPost("$prefix/api/schedules", Serialization.stringify(Schedule.serializer(), model)) ?: throw IllegalStateException("Cannot create model!") } override suspend fun update(model: Schedule) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") - repositoryPost("$prefix/api/schedule/${model.id}", Message.json.stringify(Schedule.serializer(), model)) + repositoryPost("$prefix/api/schedule/${model.id}", Serialization.stringify(Schedule.serializer(), model)) } override suspend fun delete(id: Long) { diff --git a/src/jsMain/kotlin/de/kif/frontend/repository/TrackRepository.kt b/src/jsMain/kotlin/de/kif/frontend/repository/TrackRepository.kt index cf5dcae..754cdef 100644 --- a/src/jsMain/kotlin/de/kif/frontend/repository/TrackRepository.kt +++ b/src/jsMain/kotlin/de/kif/frontend/repository/TrackRepository.kt @@ -3,6 +3,7 @@ package de.kif.frontend.repository import de.kif.common.Message import de.kif.common.Repository import de.kif.common.RepositoryType +import de.kif.common.Serialization import de.kif.common.model.Track import de.kif.frontend.MessageHandler import de.westermann.kobserve.event.EventHandler @@ -25,13 +26,13 @@ object TrackRepository : Repository { } override suspend fun create(model: Track): Long { - return repositoryPost("$prefix/api/tracks", Message.json.stringify(Track.serializer(), model)) + return repositoryPost("$prefix/api/tracks", Serialization.stringify(Track.serializer(), model)) ?: throw IllegalStateException("Cannot create model!") } override suspend fun update(model: Track) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") - repositoryPost("$prefix/api/track/${model.id}", Message.json.stringify(Track.serializer(), model)) + repositoryPost("$prefix/api/track/${model.id}", Serialization.stringify(Track.serializer(), model)) } override suspend fun delete(id: Long) { diff --git a/src/jsMain/kotlin/de/kif/frontend/repository/UserRepository.kt b/src/jsMain/kotlin/de/kif/frontend/repository/UserRepository.kt index b5a42db..90c0438 100644 --- a/src/jsMain/kotlin/de/kif/frontend/repository/UserRepository.kt +++ b/src/jsMain/kotlin/de/kif/frontend/repository/UserRepository.kt @@ -3,6 +3,7 @@ package de.kif.frontend.repository import de.kif.common.Message import de.kif.common.Repository import de.kif.common.RepositoryType +import de.kif.common.Serialization import de.kif.common.model.User import de.kif.frontend.MessageHandler import de.westermann.kobserve.event.EventHandler @@ -25,13 +26,13 @@ object UserRepository : Repository { } override suspend fun create(model: User): Long { - return repositoryPost("$prefix/api/users", Message.json.stringify(User.serializer(), model)) + return repositoryPost("$prefix/api/users", Serialization.stringify(User.serializer(), model)) ?: throw IllegalStateException("Cannot create model!") } override suspend fun update(model: User) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") - repositoryPost("$prefix/api/user/${model.id}", Message.json.stringify(User.serializer(), model)) + repositoryPost("$prefix/api/user/${model.id}", Serialization.stringify(User.serializer(), model)) } override suspend fun delete(id: Long) { diff --git a/src/jsMain/kotlin/de/kif/frontend/repository/WorkGroupRepository.kt b/src/jsMain/kotlin/de/kif/frontend/repository/WorkGroupRepository.kt index 18c240c..6714c71 100644 --- a/src/jsMain/kotlin/de/kif/frontend/repository/WorkGroupRepository.kt +++ b/src/jsMain/kotlin/de/kif/frontend/repository/WorkGroupRepository.kt @@ -3,6 +3,7 @@ package de.kif.frontend.repository import de.kif.common.Message import de.kif.common.Repository import de.kif.common.RepositoryType +import de.kif.common.Serialization import de.kif.common.model.WorkGroup import de.kif.frontend.MessageHandler import de.westermann.kobserve.event.EventHandler @@ -25,13 +26,13 @@ object WorkGroupRepository : Repository { } override suspend fun create(model: WorkGroup): Long { - return repositoryPost("$prefix/api/workgroups", Message.json.stringify(WorkGroup.serializer(), model)) + return repositoryPost("$prefix/api/workgroups", Serialization.stringify(WorkGroup.serializer(), model)) ?: throw IllegalStateException("Cannot create model!") } override suspend fun update(model: WorkGroup) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") - repositoryPost("$prefix/api/workgroup/${model.id}", Message.json.stringify(WorkGroup.serializer(), model)) + repositoryPost("$prefix/api/workgroup/${model.id}", Serialization.stringify(WorkGroup.serializer(), model)) } override suspend fun delete(id: Long) { diff --git a/src/jsMain/kotlin/de/kif/frontend/views/Announcements.kt b/src/jsMain/kotlin/de/kif/frontend/views/Announcements.kt new file mode 100644 index 0000000..4c65cc8 --- /dev/null +++ b/src/jsMain/kotlin/de/kif/frontend/views/Announcements.kt @@ -0,0 +1,21 @@ +package de.kif.frontend.views + +import de.kif.frontend.launch +import de.kif.frontend.repository.AnnouncementRepository +import org.w3c.dom.HTMLElement +import org.w3c.dom.get +import kotlin.browser.document + +fun initAnnouncement() { + val announcement = document.getElementsByClassName("announcement")[0] as? HTMLElement ?: return + val span = announcement.children[0] as? HTMLElement ?: return + + AnnouncementRepository.onUpdate { + launch { + val text = AnnouncementRepository.getAnnouncement() + + announcement.classList.toggle("announcement-blank", text.isBlank()) + span.textContent = text + } + } +} \ No newline at end of file diff --git a/src/jsMain/resources/style/components/_board.scss b/src/jsMain/resources/style/components/_board.scss index 4abf210..22ffc32 100644 --- a/src/jsMain/resources/style/components/_board.scss +++ b/src/jsMain/resources/style/components/_board.scss @@ -58,7 +58,7 @@ width: 100%; height: 100%; border-bottom: solid 1px var(--table-border-color); - box-shadow: 0 0 4px black; + box-shadow: 0 0 4px var(--shadow-color); top: -1rem; padding-top: 1.2rem; } @@ -205,3 +205,10 @@ margin-top: -1px !important; } } + +.board-announcement { + position: fixed; + bottom: 3rem; + z-index: 10; + margin-bottom: 0 !important; +} diff --git a/src/jsMain/resources/style/style.scss b/src/jsMain/resources/style/style.scss index a55129c..4f12582 100644 --- a/src/jsMain/resources/style/style.scss +++ b/src/jsMain/resources/style/style.scss @@ -160,3 +160,42 @@ a { } } } + +.offline-banner { + position: fixed; + bottom: 3rem; + left: 0; + right: 0; + height: 3rem; + line-height: 3rem; + text-align: center; + font-size: 1.2rem; + background-color: var(--primary-color); + color: var(--primary-text-color); + z-index: 12; + box-shadow: 0 1px 4px var(--shadow-color); + display: none; +} + +body.offline { + .offline-banner { + display: block; + } +} + +.announcement { + height: 3rem; + line-height: 3rem; + text-align: center; + width: 100%; + font-size: 1.2rem; + background-color: var(--primary-color); + color: var(--primary-text-color); + box-shadow: 0 1px 4px var(--shadow-color); + margin-bottom: 1rem; + display: block; + + &.announcement-blank { + display: none; + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/Application.kt b/src/jvmMain/kotlin/de/kif/backend/Application.kt index 008d8b5..e7efee4 100644 --- a/src/jvmMain/kotlin/de/kif/backend/Application.kt +++ b/src/jvmMain/kotlin/de/kif/backend/Application.kt @@ -15,7 +15,6 @@ import io.ktor.jackson.jackson import io.ktor.response.respond import io.ktor.routing.route import io.ktor.routing.routing -import io.ktor.websocket.WebSockets import org.slf4j.event.Level import java.nio.file.Paths @@ -31,7 +30,7 @@ fun Application.main() { install(ConditionalHeaders) install(Compression) install(DataConversion) - install(WebSockets) + install(AutoHeadResponse) install(StatusPages) { exception { @@ -91,6 +90,7 @@ fun Application.main() { workGroupApi() constraintsApi() postApi() + announcementApi() // Web socket push notifications pushService() diff --git a/src/jvmMain/kotlin/de/kif/backend/Configuration.kt b/src/jvmMain/kotlin/de/kif/backend/Configuration.kt index 21ffe5e..eb7d7e3 100644 --- a/src/jvmMain/kotlin/de/kif/backend/Configuration.kt +++ b/src/jvmMain/kotlin/de/kif/backend/Configuration.kt @@ -42,6 +42,7 @@ object Configuration { val sessions by required() val uploads by required() val database by required() + val announcement by required() } object Path { @@ -56,6 +57,9 @@ object Configuration { val database by c(PathSpec.database) val databasePath: java.nio.file.Path by lazy { Paths.get(database).toAbsolutePath() } + + val announcement by c(PathSpec.announcement) + val announcementPath: java.nio.file.Path by lazy { Paths.get(announcement).toAbsolutePath() } } private object ScheduleSpec : ConfigSpec("schedule") { diff --git a/src/jvmMain/kotlin/de/kif/backend/repository/AnnouncementRepository.kt b/src/jvmMain/kotlin/de/kif/backend/repository/AnnouncementRepository.kt new file mode 100644 index 0000000..af0a571 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/repository/AnnouncementRepository.kt @@ -0,0 +1,53 @@ +package de.kif.backend.repository + +import de.kif.backend.Configuration +import de.kif.backend.util.PushService +import de.kif.common.MessageType +import de.kif.common.RepositoryType +import de.westermann.kobserve.event.EventHandler +import kotlinx.coroutines.runBlocking + +object AnnouncementRepository { + + val onUpdate = EventHandler() + + private val file = Configuration.Path.announcementPath.toFile() + + private var announcement = "" + + fun setAnnouncement(value: String) { + val str = value.replace("\n", " ").trim() + if (announcement == str) return + + announcement = str + try { + file.writeText(str) + } catch (e: Exception) { + + } + + onUpdate.emit(Unit) + } + + fun getAnnouncement(): String { + return announcement + } + + fun registerPushService() { + onUpdate { + runBlocking { + PushService.notify(MessageType.UPDATE, RepositoryType.ANNOUNCEMENT, 0L) + } + } + } + + init { + try { + if (!file.exists()) { + file.createNewFile() + } + announcement = file.readText().trim() + } catch (e: Exception) { + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/repository/WorkGroupRepository.kt b/src/jvmMain/kotlin/de/kif/backend/repository/WorkGroupRepository.kt index a4ef155..46a1330 100644 --- a/src/jvmMain/kotlin/de/kif/backend/repository/WorkGroupRepository.kt +++ b/src/jvmMain/kotlin/de/kif/backend/repository/WorkGroupRepository.kt @@ -3,10 +3,7 @@ package de.kif.backend.repository import de.kif.backend.database.DbWorkGroup import de.kif.backend.database.dbQuery import de.kif.backend.util.PushService -import de.kif.common.Message -import de.kif.common.MessageType -import de.kif.common.Repository -import de.kif.common.RepositoryType +import de.kif.common.* import de.kif.common.model.Constraint import de.kif.common.model.WorkGroup import de.westermann.kobserve.event.EventHandler @@ -36,8 +33,8 @@ object WorkGroupRepository : Repository { val accessible = row[DbWorkGroup.accessible] val length = row[DbWorkGroup.length] val language = row[DbWorkGroup.language] - val leader = Message.json.parse(String.serializer().list, row[DbWorkGroup.leader]) - val constraints = Message.json.parse(Constraint.serializer().list, row[DbWorkGroup.constraints]) + val leader = Serialization.parse(String.serializer().list, row[DbWorkGroup.leader]) + val constraints = Serialization.parse(Constraint.serializer().list, row[DbWorkGroup.constraints]) val createdAt = row[DbWorkGroup.createdAt] val updatedAt = row[DbWorkGroup.updatedAt] @@ -92,8 +89,8 @@ object WorkGroupRepository : Repository { it[accessible] = model.accessible it[length] = model.length it[language] = model.language - it[leader] = Message.json.stringify(String.serializer().list, model.leader) - it[constraints] = Message.json.stringify(Constraint.serializer().list, model.constraints) + it[leader] = Serialization.stringify(String.serializer().list, model.leader) + it[constraints] = Serialization.stringify(Constraint.serializer().list, model.constraints) it[createdAt] = now it[updatedAt] = now @@ -124,8 +121,8 @@ object WorkGroupRepository : Repository { it[accessible] = model.accessible it[length] = model.length it[language] = model.language - it[leader] = Message.json.stringify(String.serializer().list, model.leader) - it[constraints] = Message.json.stringify(Constraint.serializer().list, model.constraints) + it[leader] = Serialization.stringify(String.serializer().list, model.leader) + it[constraints] = Serialization.stringify(Constraint.serializer().list, model.constraints) it[updatedAt] = now } diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Board.kt b/src/jvmMain/kotlin/de/kif/backend/route/Board.kt index 14479d0..1407793 100644 --- a/src/jvmMain/kotlin/de/kif/backend/route/Board.kt +++ b/src/jvmMain/kotlin/de/kif/backend/route/Board.kt @@ -1,6 +1,8 @@ package de.kif.backend.route import de.kif.backend.Configuration +import de.kif.backend.prefix +import de.kif.backend.repository.AnnouncementRepository import de.kif.backend.repository.RoomRepository import de.kif.backend.repository.ScheduleRepository import de.kif.backend.view.respondMain @@ -87,6 +89,18 @@ fun Route.board() { val nowLocale = now.time + timeOffset respondMain(true, true) { theme -> content { + + val announcement = AnnouncementRepository.getAnnouncement() + var classes = "board-announcement announcement" + if (announcement.isBlank()) { + classes += " announcement-blank" + } + div(classes) { + span { + +announcement + } + } + div("board") { div("board-header") { div("board-running") { @@ -142,7 +156,7 @@ fun Route.board() { } } div("board-logo") { - img("KIF 47.0", "/static/images/logo.svg") + img("KIF 47.0", "$prefix/static/images/logo.svg") div("board-header-date") { attributes["data-now"] = (now.time - timeOffset).toString() } diff --git a/src/jvmMain/kotlin/de/kif/backend/route/News.kt b/src/jvmMain/kotlin/de/kif/backend/route/News.kt index ccb2aa6..4d3662b 100644 --- a/src/jvmMain/kotlin/de/kif/backend/route/News.kt +++ b/src/jvmMain/kotlin/de/kif/backend/route/News.kt @@ -1,6 +1,7 @@ package de.kif.backend.route import de.kif.backend.* +import de.kif.backend.repository.AnnouncementRepository import de.kif.backend.repository.PostRepository import de.kif.backend.util.markdownToHtml import de.kif.backend.view.respondMain @@ -13,11 +14,13 @@ import io.ktor.http.content.PartData import io.ktor.http.content.forEachPart import io.ktor.http.content.streamProvider import io.ktor.request.receiveMultipart +import io.ktor.request.receiveParameters import io.ktor.response.respond import io.ktor.response.respondRedirect import io.ktor.routing.Route import io.ktor.routing.get import io.ktor.routing.post +import io.ktor.util.toMap import kotlinx.html.* import java.io.File @@ -75,9 +78,12 @@ fun Route.overview() { content { if (editable) { div("overview-new") { - a("post/new", classes = "form-btn") { + a("$prefix/post/new", classes = "form-btn") { +"Neuer Eintrag" } + a("$prefix/announcement", classes = "form-btn") { + +"Ankündigungsbanner bearbeiten" + } } } div("overview") { @@ -133,7 +139,7 @@ fun Route.overview() { } get("/post/{id}") { - authenticateOrRedirect(Permission.POST) { user -> + authenticateOrRedirect(Permission.POST) { val postId = call.parameters["id"]?.toLongOrNull() ?: return@get val editPost = PostRepository.get(postId) ?: return@get respondMain { @@ -291,7 +297,7 @@ fun Route.overview() { } post("/post/{id}") { - authenticateOrRedirect(Permission.POST) { user -> + authenticateOrRedirect(Permission.POST) { val postId = call.parameters["id"]?.toLongOrNull() ?: return@post var imageUploadName: String? = null @@ -361,7 +367,7 @@ fun Route.overview() { } get("/post/new") { - authenticateOrRedirect(Permission.POST) { user -> + authenticateOrRedirect(Permission.POST) { respondMain { content { h1 { +"Beitrag erstellen" } @@ -491,7 +497,7 @@ fun Route.overview() { } post("/post/new") { - authenticateOrRedirect(Permission.POST) { user -> + authenticateOrRedirect(Permission.POST) { var imageUploadName: String? = null val params = mutableMapOf() @@ -537,7 +543,7 @@ fun Route.overview() { } get("/post/{id}/delete") { - authenticateOrRedirect(Permission.POST) { user -> + authenticateOrRedirect(Permission.POST) { val postId = call.parameters["id"]?.toLongOrNull() ?: return@get PostRepository.delete(postId) @@ -545,4 +551,57 @@ fun Route.overview() { call.respondRedirect("$prefix/") } } + + + get("/announcement") { + authenticateOrRedirect(Permission.POST) { + respondMain { + content { + h1 { +"Ankündigungsbanner bearbeiten" } + form(method = FormMethod.post) { + div("form-group") { + label { + htmlFor = "announcement" + +"Ankündigung" + } + input( + name = "announcement", + classes = "form-control" + ) { + id = "announcement" + placeholder = "Ankündigung" + value = AnnouncementRepository.getAnnouncement() + } + } + + div("form-group") { + a("$prefix/") { + button(classes = "form-btn") { + +"Abbrechen" + } + } + button(type = ButtonType.submit, classes = "form-btn btn-primary") { + +"Speichern" + } + } + } + } + } + } + } + + + post("/announcement") { + authenticateOrRedirect(Permission.POST) { + val params = call.receiveParameters().toMap().mapValues { (_, list) -> + list.firstOrNull() + } + + val announcement = params["announcement"] ?: return@post + + AnnouncementRepository.setAnnouncement(announcement) + + call.respondRedirect("$prefix/") + } + } } diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Room.kt b/src/jvmMain/kotlin/de/kif/backend/route/Room.kt index ec8cad1..9d4cc55 100644 --- a/src/jvmMain/kotlin/de/kif/backend/route/Room.kt +++ b/src/jvmMain/kotlin/de/kif/backend/route/Room.kt @@ -29,7 +29,7 @@ import de.kif.backend.prefix fun Route.room() { get("/rooms") { - authenticateOrRedirect(Permission.ROOM) { user -> + authenticateOrRedirect(Permission.ROOM) { val search = call.parameters["search"] ?: "" val list = RoomRepository.all() @@ -106,7 +106,7 @@ fun Route.room() { } get("/room/{id}") { - authenticateOrRedirect(Permission.ROOM) { user -> + authenticateOrRedirect(Permission.ROOM) { val roomId = call.parameters["id"]?.toLongOrNull() ?: return@get val editRoom = RoomRepository.get(roomId) ?: return@get respondMain { @@ -260,7 +260,7 @@ fun Route.room() { } post("/room/{id}") { - authenticateOrRedirect(Permission.ROOM) { user -> + authenticateOrRedirect(Permission.ROOM) { val roomId = call.parameters["id"]?.toLongOrNull() ?: return@post val params = call.receiveParameters().toMap().mapValues { (_, list) -> list.firstOrNull() @@ -283,7 +283,7 @@ fun Route.room() { } get("/room/new") { - authenticateOrRedirect(Permission.ROOM) { user -> + authenticateOrRedirect(Permission.ROOM) { respondMain { content { h1 { +"Raum erstellen" } @@ -430,7 +430,7 @@ fun Route.room() { } post("/room/new") { - authenticateOrRedirect(Permission.ROOM) { user -> + authenticateOrRedirect(Permission.ROOM) { val params = call.receiveParameters().toMap().mapValues { (_, list) -> list.firstOrNull() } @@ -453,7 +453,7 @@ fun Route.room() { } get("/room/{id}/delete") { - authenticateOrRedirect(Permission.ROOM) { user -> + authenticateOrRedirect(Permission.ROOM) { val roomId = call.parameters["id"]?.toLongOrNull() ?: return@get RoomRepository.delete(roomId) diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Track.kt b/src/jvmMain/kotlin/de/kif/backend/route/Track.kt index 81b9d35..15764fe 100644 --- a/src/jvmMain/kotlin/de/kif/backend/route/Track.kt +++ b/src/jvmMain/kotlin/de/kif/backend/route/Track.kt @@ -83,7 +83,7 @@ fun DIV.colorPicker(color: Color?) { fun Route.track() { get("/tracks") { - authenticateOrRedirect(Permission.WORK_GROUP) { user -> + authenticateOrRedirect(Permission.WORK_GROUP) { val search = call.parameters["search"] ?: "" val list = TrackRepository.all() respondMain { @@ -145,7 +145,7 @@ fun Route.track() { } get("/track/{id}") { - authenticateOrRedirect(Permission.WORK_GROUP) { user -> + authenticateOrRedirect(Permission.WORK_GROUP) { val trackId = call.parameters["id"]?.toLongOrNull() ?: return@get val editTrack = TrackRepository.get(trackId) ?: return@get respondMain { @@ -192,7 +192,7 @@ fun Route.track() { } post("/track/{id}") { - authenticateOrRedirect(Permission.WORK_GROUP) { user -> + authenticateOrRedirect(Permission.WORK_GROUP) { val trackId = call.parameters["id"]?.toLongOrNull() ?: return@post val params = call.receiveParameters().toMap().mapValues { (_, list) -> list.firstOrNull() @@ -212,7 +212,7 @@ fun Route.track() { } get("/track/new") { - authenticateOrRedirect(Permission.WORK_GROUP) { user -> + authenticateOrRedirect(Permission.WORK_GROUP) { respondMain { content { h1 { +"Track erstellen" } @@ -251,7 +251,7 @@ fun Route.track() { } post("/track/new") { - authenticateOrRedirect(Permission.WORK_GROUP) { user -> + authenticateOrRedirect(Permission.WORK_GROUP) { val params = call.receiveParameters().toMap().mapValues { (_, list) -> list.firstOrNull() } @@ -270,7 +270,7 @@ fun Route.track() { } get("track/{id}/delete") { - authenticateOrRedirect(Permission.WORK_GROUP) { user -> + authenticateOrRedirect(Permission.WORK_GROUP) { val trackId = call.parameters["id"]?.toLongOrNull() ?: return@get TrackRepository.delete(trackId) diff --git a/src/jvmMain/kotlin/de/kif/backend/route/User.kt b/src/jvmMain/kotlin/de/kif/backend/route/User.kt index a301f1d..2f589f3 100644 --- a/src/jvmMain/kotlin/de/kif/backend/route/User.kt +++ b/src/jvmMain/kotlin/de/kif/backend/route/User.kt @@ -26,8 +26,8 @@ import kotlin.collections.component2 import kotlin.collections.set fun Route.user() { - get("/users") { param -> - authenticateOrRedirect(Permission.USER) { user -> + get("/users") { + authenticateOrRedirect(Permission.USER) { val search = call.parameters["search"] ?: "" val list = UserRepository.all() respondMain { diff --git a/src/jvmMain/kotlin/de/kif/backend/route/WorkGroup.kt b/src/jvmMain/kotlin/de/kif/backend/route/WorkGroup.kt index 0957c4b..9c90782 100644 --- a/src/jvmMain/kotlin/de/kif/backend/route/WorkGroup.kt +++ b/src/jvmMain/kotlin/de/kif/backend/route/WorkGroup.kt @@ -25,7 +25,7 @@ private const val separator = "###" fun Route.workGroup() { get("workgroups") { - authenticateOrRedirect(Permission.WORK_GROUP) { user -> + authenticateOrRedirect(Permission.WORK_GROUP) { val search = call.parameters["search"] ?: "" val list = WorkGroupRepository.all() respondMain { @@ -146,7 +146,7 @@ fun Route.workGroup() { } get("/workgroup/{id}") { - authenticateOrRedirect(Permission.WORK_GROUP) { user -> + authenticateOrRedirect(Permission.WORK_GROUP) { val workGroupId = call.parameters["id"]?.toLongOrNull() ?: return@get val editWorkGroup = WorkGroupRepository.get(workGroupId) ?: return@get val tracks = TrackRepository.all() @@ -532,7 +532,7 @@ fun Route.workGroup() { } post("/workgroup/{id}") { - authenticateOrRedirect(Permission.WORK_GROUP) { user -> + authenticateOrRedirect(Permission.WORK_GROUP) { val workGroupId = call.parameters["id"]?.toLongOrNull() ?: return@post val params = call.receiveParameters().toMap().mapValues { (_, list) -> list.firstOrNull() @@ -574,7 +574,7 @@ fun Route.workGroup() { } get("/workgroup/new") { - authenticateOrRedirect(Permission.WORK_GROUP) { user -> + authenticateOrRedirect(Permission.WORK_GROUP) { val tracks = TrackRepository.all() respondMain { content { @@ -823,7 +823,7 @@ fun Route.workGroup() { } post("/workgroup/new") { - authenticateOrRedirect(Permission.WORK_GROUP) { user -> + authenticateOrRedirect(Permission.WORK_GROUP) { val params = call.receiveParameters().toMap().mapValues { (_, list) -> list.firstOrNull() } @@ -872,7 +872,7 @@ fun Route.workGroup() { } get("/workgroup/{id}/delete") { - authenticateOrRedirect(Permission.WORK_GROUP) { user -> + authenticateOrRedirect(Permission.WORK_GROUP) { val workGroupId = call.parameters["id"]?.toLongOrNull() ?: return@get WorkGroupRepository.delete(workGroupId) diff --git a/src/jvmMain/kotlin/de/kif/backend/route/api/Announcement.kt b/src/jvmMain/kotlin/de/kif/backend/route/api/Announcement.kt new file mode 100644 index 0000000..938e94e --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/route/api/Announcement.kt @@ -0,0 +1,39 @@ +package de.kif.backend.route.api + +import de.kif.backend.authenticate +import de.kif.backend.repository.AnnouncementRepository +import de.kif.backend.repository.RoomRepository +import de.kif.common.model.Permission +import de.kif.common.model.Room +import io.ktor.application.call +import io.ktor.http.HttpStatusCode +import io.ktor.request.receive +import io.ktor.routing.Route +import io.ktor.routing.get +import io.ktor.routing.post + +fun Route.announcementApi() { + get("/api/announcement") { + try { + call.success(AnnouncementRepository.getAnnouncement()) + } catch (_: Exception) { + call.error(HttpStatusCode.InternalServerError) + } + } + + post("/api/announcement") { + try { + authenticate(Permission.POST) { + val announcement = call.receive() + + AnnouncementRepository.setAnnouncement(announcement) + + call.success() + } onFailure { + call.error(HttpStatusCode.Unauthorized) + } + } catch (_: Exception) { + call.error(HttpStatusCode.InternalServerError) + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/util/Backup.kt b/src/jvmMain/kotlin/de/kif/backend/util/Backup.kt index b9f758f..21c0c8d 100644 --- a/src/jvmMain/kotlin/de/kif/backend/util/Backup.kt +++ b/src/jvmMain/kotlin/de/kif/backend/util/Backup.kt @@ -4,6 +4,7 @@ import de.kif.backend.database.Connection import de.kif.backend.repository.* import de.kif.common.Message import de.kif.common.RepositoryType +import de.kif.common.Serialization import de.kif.common.model.* import kotlinx.serialization.Serializable @@ -39,15 +40,16 @@ data class Backup( RepositoryType.USER -> backup.copy(users = UserRepository.all()) RepositoryType.WORK_GROUP -> backup.copy(workGroups = WorkGroupRepository.all()) RepositoryType.POST -> backup.copy(posts = PostRepository.all()) + else -> backup } } - return Message.json.stringify(serializer(), backup) + return Serialization.stringify(serializer(), backup) } @Suppress("UNUSED_VARIABLE") suspend fun import(data: String) { - val backup = Message.json.parse(serializer(), data) + val backup = Serialization.parse(serializer(), data) backup.users.forEach { UserRepository.create(it); println("Import user ${it.username}") } backup.posts.forEach { PostRepository.create(it); println("Import post") } diff --git a/src/jvmMain/kotlin/de/kif/backend/util/PushService.kt b/src/jvmMain/kotlin/de/kif/backend/util/PushService.kt index 12f07e3..78c7127 100644 --- a/src/jvmMain/kotlin/de/kif/backend/util/PushService.kt +++ b/src/jvmMain/kotlin/de/kif/backend/util/PushService.kt @@ -1,48 +1,60 @@ package de.kif.backend.util -import de.kif.backend.prefix import de.kif.backend.repository.* -import de.kif.common.* +import de.kif.backend.route.api.error +import de.kif.backend.route.api.success +import de.kif.common.Message +import de.kif.common.MessageBox +import de.kif.common.MessageType import de.kif.common.RepositoryType -import io.ktor.http.cio.websocket.Frame -import io.ktor.http.cio.websocket.readText +import io.ktor.application.call +import io.ktor.http.HttpStatusCode import io.ktor.routing.Route -import io.ktor.websocket.WebSocketServerSession -import io.ktor.websocket.webSocket -import kotlinx.coroutines.channels.ClosedReceiveChannelException +import io.ktor.routing.get object PushService { - var clients: List = emptyList() + var leastValidTimestamp = System.currentTimeMillis() - suspend fun notify(type: MessageType, repository: RepositoryType, id: Long) { - try { - val data = Message(type, repository, id).stringify() - for (client in clients) { - client.outgoing.send(Frame.Text(data)) - } - } catch (e: Exception) { - e.printStackTrace() - } + /** + * Save the message with the current timestamp + */ + fun notify(type: MessageType, repository: RepositoryType, id: Long) { + val timestamp = System.currentTimeMillis() + val message = Message(type, repository, id) + } + + /** + * Get all messages created after the given timestamp. + * The return Box has the current timestamp. If the leastValidTimestamp is less then the current timestamp set the + * valid flag to false and return an empty message list. + */ + fun getMessages(timestamp: Long?): MessageBox { + return MessageBox( + System.currentTimeMillis(), + timestamp != null && timestamp > leastValidTimestamp, + emptyList() + ) + } + + /** + * Delete all messages before the given timestamp + */ + fun gc(timestamp: Long) { + leastValidTimestamp = timestamp } } fun Route.pushService() { - webSocket("/websocket") { - PushService.clients += this - + get("/api/updates") { try { - while (true) { - val text = (incoming.receive() as Frame.Text).readText() - println("onMessage($text)") - outgoing.send(Frame.Text(text)) - } - } catch (_: ClosedReceiveChannelException) { - PushService.clients -= this - } catch (e: Throwable) { - println("onFailure ${closeReason.await()}") - e.printStackTrace() - PushService.clients -= this + val timestamp = call.request.queryParameters["timestamp"]?.toLongOrNull() + + val messageBox = PushService.getMessages(timestamp) + + call.success(messageBox) + } catch (_: Exception) { + call.error(HttpStatusCode.InternalServerError) } } @@ -52,4 +64,5 @@ fun Route.pushService() { UserRepository.registerPushService() WorkGroupRepository.registerPushService() PostRepository.registerPushService() + AnnouncementRepository.registerPushService() } diff --git a/src/jvmMain/kotlin/de/kif/backend/view/MainTemplate.kt b/src/jvmMain/kotlin/de/kif/backend/view/MainTemplate.kt index f96a52e..3f8271e 100644 --- a/src/jvmMain/kotlin/de/kif/backend/view/MainTemplate.kt +++ b/src/jvmMain/kotlin/de/kif/backend/view/MainTemplate.kt @@ -3,6 +3,7 @@ package de.kif.backend.view import de.kif.backend.PortalSession import de.kif.backend.Resources import de.kif.backend.prefix +import de.kif.backend.repository.AnnouncementRepository import de.kif.common.model.User import io.ktor.application.ApplicationCall import io.ktor.application.call @@ -73,10 +74,25 @@ class MainTemplate( } } body { + attributes["data-timestamp"] = currentTimeMillis().toString() + if (!noMenu) { insert(MenuTemplate(url, user)) {} } + if (!noMenu) { + val announcement = AnnouncementRepository.getAnnouncement() + var classes = "announcement" + if (announcement.isBlank()) { + classes += " announcement-blank" + } + div(classes) { + span { + +announcement + } + } + } + val containerClasses = if (stretch) "container-full" else "container" div(containerClasses) { div("main") { @@ -84,6 +100,10 @@ class MainTemplate( } } + div("offline-banner") { + span { +"Die Verbindung zum Server ist unterbrochen" } + } + if (!noMenu) { div("footer") { div("container") { diff --git a/src/jvmMain/resources/portal.toml b/src/jvmMain/resources/portal.toml index 02fc07d..28f8096 100644 --- a/src/jvmMain/resources/portal.toml +++ b/src/jvmMain/resources/portal.toml @@ -9,6 +9,7 @@ web = "web" sessions = "data/sessions" uploads = "data/uploads" database = "data/portal.db" +announcement = "data/announcement.txt" [schedule] reference = "1970-01-01"