Initial commit

This commit is contained in:
2025-08-13 12:03:49 +00:00
commit b103631133
28 changed files with 960 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
package nl.astraeus.tmpl
import kotlinx.browser.document
import kotlinx.html.div
import nl.astraeus.komp.HtmlBuilder
import nl.astraeus.komp.Komponent
class HelloKomponent : Komponent() {
override fun HtmlBuilder.render() {
div {
+ "Hello, world!"
}
}
}
fun main() {
Komponent.create(document.body!!, HelloKomponent())
}

View File

@@ -0,0 +1,64 @@
package nl.astraeus.tmpl
import com.zaxxer.hikari.HikariConfig
import io.undertow.Undertow
import io.undertow.UndertowOptions
import io.undertow.predicate.Predicates
import io.undertow.server.handlers.encoding.ContentEncodingRepository
import io.undertow.server.handlers.encoding.EncodingHandler
import io.undertow.server.handlers.encoding.GzipEncodingProvider
import nl.astraeus.logger.Logger
import nl.astraeus.tmpl.db.Database
import nl.astraeus.tmpl.web.RequestHandler
val log = Logger()
fun main() {
Thread.currentThread().uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { t, e ->
log.warn(e) {
e.message
}
}
Runtime.getRuntime().addShutdownHook(
object : Thread() {
override fun run() {
Database.vacuumDatabase()
Database.closeDatabase()
}
}
)
Class.forName("nl.astraeus.jdbc.Driver")
Database.initialize(HikariConfig().apply {
driverClassName = "nl.astraeus.jdbc.Driver"
jdbcUrl = "jdbc:stat:webServerPort=$JDBC_PORT:jdbc:sqlite:data/$repoName.db"
username = "sa"
password = ""
maximumPoolSize = 25
isAutoCommit = false
validate()
})
val compressionHandler =
EncodingHandler(
ContentEncodingRepository()
.addEncodingHandler(
"gzip",
GzipEncodingProvider(), 50,
Predicates.parse("max-content-size(5)")
)
).setNext(RequestHandler)
val server = Undertow.builder()
.addHttpListener(SERVER_PORT, "localhost")
.setIoThreads(4)
.setHandler(compressionHandler)
.setServerOption(UndertowOptions.SHUTDOWN_TIMEOUT, 1000)
.build()
println("Starting undertow server at port 6007...")
server?.start()
}

View File

@@ -0,0 +1,10 @@
package nl.astraeus.tmpl
val REPO_NAME = "dummy so the gitea template compiles, please remove"
val SERVER_PORT = 7001
val JDBC_PORT = 8001
val pageTitle = "mtmc-web"
val itemUrl = "mtmc-web"
val repoName = "mtmc-web"

View File

@@ -0,0 +1,73 @@
package nl.astraeus.tmpl.db
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import java.sql.Connection
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.collections.set
import kotlin.use
private val currentConnection = ThreadLocal<Connection>()
fun <T> transaction(
block: (Connection) -> T
): T {
val hasConnection = currentConnection.get() != null
var oldConnection: Connection? = null
if (!hasConnection) {
currentConnection.set(Database.getConnection())
}
val connection = currentConnection.get()
try {
val result = block(connection)
connection.commit()
return result
} finally {
if (!hasConnection) {
currentConnection.set(oldConnection)
connection.close()
}
}
}
object Database {
var ds: HikariDataSource? = null
fun initialize(config: HikariConfig) {
val properties = Properties()
properties["journal_mode"] = "WAL"
config.dataSourceProperties = properties
config.addDataSourceProperty("cachePrepStmts", "true")
config.addDataSourceProperty("prepStmtCacheSize", "250")
config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048")
ds = HikariDataSource(config)
Migrations.databaseVersionTableCreated = AtomicBoolean(false)
Migrations.updateDatabaseIfNeeded()
}
fun getConnection() = ds?.connection ?: error("Database has not been initialized!")
fun vacuumDatabase() {
getConnection().use {
it.autoCommit = true
it.prepareStatement("VACUUM").use { ps ->
ps.executeUpdate()
}
}
}
fun closeDatabase() {
ds?.close()
}
}

View File

@@ -0,0 +1,105 @@
package nl.astraeus.tmpl.db
import nl.astraeus.tmpl.log
import java.sql.Connection
import java.sql.SQLException
import java.sql.Timestamp
import java.util.concurrent.atomic.AtomicBoolean
sealed class Migration {
class Query(
val query: String
) : Migration() {
override fun toString(): String {
return query
}
}
class Code(
val code: (Connection) -> Unit
) : Migration() {
override fun toString(): String {
return code.toString()
}
}
}
val DATABASE_MIGRATIONS = arrayOf<Migration>(
Migration.Query(
"""
CREATE TABLE DATABASE_VERSION (
ID INTEGER PRIMARY KEY,
QUERY TEXT,
EXECUTED TIMESTAMP
)
""".trimIndent()
),
Migration.Query("SELECT sqlite_version()"),
)
object Migrations {
var databaseVersionTableCreated = AtomicBoolean(false)
fun updateDatabaseIfNeeded() {
try {
transaction { con ->
con.prepareStatement(
"""
SELECT MAX(ID) FROM DATABASE_VERSION
""".trimIndent()
).use { ps ->
ps.executeQuery().use { rs ->
databaseVersionTableCreated.compareAndSet(false, true)
if(rs.next()) {
val maxId = rs.getInt(1)
for (index in maxId + 1..<DATABASE_MIGRATIONS.size) {
executeMigration(index)
}
}
}
}
}
} catch (e: SQLException) {
if (databaseVersionTableCreated.compareAndSet(false, true)) {
executeMigration(0)
updateDatabaseIfNeeded()
} else {
throw e
}
}
}
private fun executeMigration(index: Int) {
transaction { con ->
log.debug {
"Executing migration index - [DATABASE_MIGRATIONS[index]]"
}
val description = when(
val migration = DATABASE_MIGRATIONS[index]
) {
is Migration.Query -> {
con.prepareStatement(migration.query).use { ps ->
ps.execute()
}
migration.query
}
is Migration.Code -> {
migration.code(con)
migration.code.toString()
}
}
con.prepareStatement("INSERT INTO DATABASE_VERSION VALUES (?, ?, ?)").use { ps ->
ps.setInt(1, index)
ps.setString(2, description)
ps.setTimestamp(3, Timestamp(System.currentTimeMillis()))
ps.execute()
}
}
}
}

View File

@@ -0,0 +1,16 @@
package nl.astraeus.tmpl.web
import java.security.SecureRandom
val idChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
val random = SecureRandom()
fun generateId(length: Int = 8): String {
val id = StringBuilder()
repeat(length) {
id.append(idChars[random.nextInt(idChars.length)])
}
return id.toString()
}

View File

@@ -0,0 +1,44 @@
package nl.astraeus.tmpl.web
import kotlinx.html.body
import kotlinx.html.head
import kotlinx.html.html
import kotlinx.html.meta
import kotlinx.html.script
import kotlinx.html.stream.appendHTML
import kotlinx.html.title
import nl.astraeus.tmpl.REPO_NAME
import nl.astraeus.tmpl.itemUrl
import nl.astraeus.tmpl.pageTitle
import nl.astraeus.tmpl.repoName
fun generateIndex(patch: String?): String {
val result = StringBuilder();
if (patch == null) {
result.appendHTML(true).html {
head {
title(pageTitle)
//link("/css/all.min.css", "stylesheet")
}
body {
script(src = "/$repoName.js") {}
}
}
} else {
result.appendHTML(true).html {
head {
title(pageTitle)
meta {
httpEquiv = "refresh"
content = "0; url=/$itemUrl/$patch"
}
}
body {
+"Redirecting to $itemUrl $patch..."
}
}
}
return result.toString()
}

View File

@@ -0,0 +1,38 @@
package nl.astraeus.tmpl.web
import io.undertow.server.HttpHandler
import io.undertow.server.HttpServerExchange
import io.undertow.server.handlers.PathHandler
import io.undertow.server.handlers.resource.PathResourceManager
import io.undertow.server.handlers.resource.ResourceHandler
import nl.astraeus.tmpl.itemUrl
import java.nio.file.Paths
import kotlin.text.startsWith
object IndexHandler : HttpHandler {
override fun handleRequest(exchange: HttpServerExchange) {
if (exchange.requestPath.startsWith("/$itemUrl/")) {
exchange.responseSender.send(generateIndex(null))
} else {
val itemId = generateId()
exchange.responseSender.send(generateIndex(itemId))
}
}
}
object RequestHandler : HttpHandler {
val resourceHandler = ResourceHandler(PathResourceManager(Paths.get("web")))
val pathHandler = PathHandler(resourceHandler)
init {
pathHandler.addExactPath("/", IndexHandler)
pathHandler.addExactPath("/index.html", IndexHandler)
pathHandler.addPrefixPath("/$itemUrl", IndexHandler)
pathHandler.addExactPath("/ws", WebsocketConnectHandler)
}
override fun handleRequest(exchange: HttpServerExchange) {
pathHandler.handleRequest(exchange)
}
}

View File

@@ -0,0 +1,55 @@
package nl.astraeus.tmpl.web
import io.undertow.Handlers.websocket
import io.undertow.server.HttpHandler
import io.undertow.server.HttpServerExchange
import io.undertow.websockets.WebSocketConnectionCallback
import io.undertow.websockets.core.AbstractReceiveListener
import io.undertow.websockets.core.BufferedBinaryMessage
import io.undertow.websockets.core.BufferedTextMessage
import io.undertow.websockets.core.WebSocketChannel
import io.undertow.websockets.extensions.PerMessageDeflateHandshake
import io.undertow.websockets.spi.WebSocketHttpExchange
import kotlin.also
class WebsocketHandler : AbstractReceiveListener(), WebSocketConnectionCallback {
// var user: String? = null
override fun onConnect(exchange: WebSocketHttpExchange, channel: WebSocketChannel) {
channel.receiveSetter.set(this)
channel.resumeReceives()
}
override fun onFullTextMessage(channel: WebSocketChannel, message: BufferedTextMessage) {
val data = message.data
TODO("handle text message")
}
override fun onFullBinaryMessage(channel: WebSocketChannel, message: BufferedBinaryMessage?) {
message?.data?.also { data ->
var length = 0
for (buffer in data.resource) {
length += buffer.remaining()
}
val bytes = ByteArray(length)
var offset = 0
for (buffer in data.resource) {
buffer.get(bytes, offset, buffer.remaining())
offset += buffer.remaining()
}
TODO("handle binary message")
}
}
}
object WebsocketConnectHandler : HttpHandler {
override fun handleRequest(exchange: HttpServerExchange) {
val handshakeHandler = websocket(WebsocketHandler())
handshakeHandler.addExtension(PerMessageDeflateHandshake())
handshakeHandler.handleRequest(exchange)
}
}