Various NS auth and WebDAV token improvements
authorLanius Trolling <lanius@laniustrolling.dev>
Sun, 22 Dec 2024 19:30:06 +0000 (14:30 -0500)
committerLanius Trolling <lanius@laniustrolling.dev>
Sun, 22 Dec 2024 19:30:06 +0000 (14:30 -0500)
20 files changed:
libs/nsapi4j.jar
src/main/kotlin/info/mechyrdia/Configuration.kt
src/main/kotlin/info/mechyrdia/Factbooks.kt
src/main/kotlin/info/mechyrdia/auth/NationStates.kt
src/main/kotlin/info/mechyrdia/auth/ViewsLogin.kt
src/main/kotlin/info/mechyrdia/auth/WebDav.kt
src/main/kotlin/info/mechyrdia/data/Data.kt
src/main/kotlin/info/mechyrdia/data/DataFiles.kt
src/main/kotlin/info/mechyrdia/data/Nations.kt
src/main/kotlin/info/mechyrdia/data/ViewComments.kt
src/main/kotlin/info/mechyrdia/data/ViewsComment.kt
src/main/kotlin/info/mechyrdia/data/ViewsFiles.kt
src/main/kotlin/info/mechyrdia/data/ViewsUser.kt
src/main/kotlin/info/mechyrdia/lore/ParserPreprocess.kt
src/main/kotlin/info/mechyrdia/lore/ViewsRss.kt
src/main/kotlin/info/mechyrdia/route/ResourceBodies.kt
src/main/kotlin/info/mechyrdia/route/ResourceCsrf.kt
src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt
src/main/kotlin/info/mechyrdia/route/ResourceWebDav.kt
src/main/resources/static/admin.js

index a80a0c13c6987c49d7c142a8b7131434a467892c..8f0cd7d4649e733af223cc50012030907e13714f 100644 (file)
Binary files a/libs/nsapi4j.jar and b/libs/nsapi4j.jar differ
index 673d442c1068e2c7ba13e4a42e4cacd11dc6f96d..413fd54168d4e3678d9bc17ccb6c557e00ddaad4 100644 (file)
@@ -3,6 +3,7 @@ package info.mechyrdia
 import info.mechyrdia.auth.Argon2Hasher
 import info.mechyrdia.data.Id
 import info.mechyrdia.data.NationData
+import info.mechyrdia.data.NationUrlSlug
 import kotlinx.serialization.ExperimentalSerializationApi
 import kotlinx.serialization.InternalSerializationApi
 import kotlinx.serialization.KSerializer
@@ -14,6 +15,7 @@ import kotlinx.serialization.descriptors.buildSerialDescriptor
 import kotlinx.serialization.encoding.Decoder
 import kotlinx.serialization.encoding.Encoder
 import kotlinx.serialization.json.JsonDecoder
+import kotlinx.serialization.json.JsonEncoder
 import kotlinx.serialization.json.JsonPrimitive
 import java.io.File
 import java.nio.charset.Charset
@@ -77,7 +79,9 @@ data class Configuration(
        val dbName: String = "nslore",
        val dbConn: String = "mongodb://localhost:27017",
        
-       val ownerNation: String = "mechyrdia",
+       val ownerNation: String = "1593419",
+       
+       val emergencyUsername: NationUrlSlug = NationUrlSlug("mechyrdia"),
        @Serializable(with = StoredPasswordConfigJsonSerializer::class)
        val emergencyPassword: StoredPassword? = null,
        
@@ -122,7 +126,10 @@ object StoredPasswordConfigJsonSerializer : KSerializer<StoredPassword> {
                get() = buildSerialDescriptor("StoredPasswordConfigJsonSerializer", PolymorphicKind.SEALED)
        
        override fun serialize(encoder: Encoder, value: StoredPassword) {
-               defaultSerializer.serialize(encoder, value)
+               if (encoder is JsonEncoder)
+                       encoder.encodeJsonElement(encoder.json.encodeToJsonElement(defaultSerializer, value))
+               else
+                       defaultSerializer.serialize(encoder, value)
        }
        
        override fun deserialize(decoder: Decoder): StoredPassword {
index 5df15c3dfa6b0fc0a3fc8d0bfd0a88c84b0e2f27..20e0bbc487eea45a835917077b5d37d197e0eeb3 100644 (file)
@@ -122,9 +122,7 @@ fun Application.factbooks() {
                generate {
                        "call_${counter.incrementAndGet().toULong()}_${System.currentTimeMillis()}"
                }
-               reply { call, callId ->
-                       call.response.header("X-Call-Id", callId)
-               }
+               replyToHeader("X-Call-Id")
        }
        
        install(CallLogging) {
@@ -272,7 +270,7 @@ fun Application.factbooks() {
                get<Root.Comments.DeleteConfirmPage>()
                post<Root.Comments.DeleteConfirmPost, _>()
                get<Root.User>()
-               get<Root.User.ById>()
+               get<Root.User.BySlug>()
                post<Root.Admin.Ban, _>()
                post<Root.Admin.Unban, _>()
                get<Root.Admin.NukeManagement>()
@@ -283,6 +281,7 @@ fun Application.factbooks() {
                get<Root.Admin.Vfs.View>()
                get<Root.Admin.Vfs.WebDavTokenPage>()
                post<Root.Admin.Vfs.WebDavTokenPost, _>()
+               post<Root.Admin.Vfs.WebDavTokenDelete, _>()
                get<Root.Admin.Vfs.CopyPage>()
                post<Root.Admin.Vfs.CopyPost, _>()
                postMultipart<Root.Admin.Vfs.Upload, _>()
index b7fab6df23f7b8135c08cfaca2b84de00f4a95c1..9b6b29ac59dc86c2c60d5f2f278eb4090ceb8c6e 100644 (file)
@@ -1,10 +1,11 @@
 package info.mechyrdia.auth
 
-import com.aventrix.jnanoid.jnanoid.NanoIdUtils
 import com.github.agadar.nationstates.DefaultNationStatesImpl
 import com.github.agadar.nationstates.NationStates
 import com.github.agadar.nationstates.exception.NationStatesResourceNotFoundException
 import com.github.agadar.nationstates.query.APIQuery
+import info.mechyrdia.data.NationUrlSlug
+import info.mechyrdia.data.nanoId
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.runInterruptible
 
@@ -18,9 +19,10 @@ suspend fun <Q : APIQuery<Q, R>, R> Q.executeSuspend(): R? = runInterruptible(Di
        }
 }
 
-fun String.toNationId() = replace(' ', '_').lowercase()
+fun String.toNationSlug() = NationUrlSlug(replace(' ', '_').lowercase())
 
-private val tokenAlphabet = "ABCDEFGHILMNOPQRSTVXYZ0123456789".toCharArray()
-fun token(): String = NanoIdUtils.randomNanoId(NanoIdUtils.DEFAULT_NUMBER_GENERATOR, tokenAlphabet, 16)
+fun token(): String = nanoId(16)
+
+fun gigaToken(): String = nanoId(32)
 
 class ForbiddenException(override val message: String) : RuntimeException(message)
index 4408087e311b7d6ba357f6464ff2a5f6451051d7..847d8ace8815b64376851de8900267cf1a083259 100644 (file)
@@ -1,13 +1,12 @@
 package info.mechyrdia.auth
 
-import com.github.agadar.nationstates.shard.NationShard
-import info.mechyrdia.Configuration
 import info.mechyrdia.data.DataDocument
 import info.mechyrdia.data.DocumentTable
 import info.mechyrdia.data.Id
 import info.mechyrdia.data.InstantSerializer
 import info.mechyrdia.data.MONGODB_ID_KEY
 import info.mechyrdia.data.NationData
+import info.mechyrdia.data.NationVerifyResult
 import info.mechyrdia.data.TableHolder
 import info.mechyrdia.lore.page
 import info.mechyrdia.lore.redirectHref
@@ -23,7 +22,19 @@ import io.ktor.server.sessions.clear
 import io.ktor.server.sessions.sessions
 import io.ktor.server.sessions.set
 import io.ktor.util.AttributeKey
-import kotlinx.html.*
+import kotlinx.html.FormMethod
+import kotlinx.html.HTML
+import kotlinx.html.br
+import kotlinx.html.button
+import kotlinx.html.form
+import kotlinx.html.h1
+import kotlinx.html.hiddenInput
+import kotlinx.html.label
+import kotlinx.html.p
+import kotlinx.html.section
+import kotlinx.html.style
+import kotlinx.html.submitInput
+import kotlinx.html.textInput
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
 import java.time.Instant
@@ -73,16 +84,14 @@ suspend fun ApplicationCall.loginPage(): HTML.() -> Unit {
                        form(method = FormMethod.post, action = href(Root.Auth.LoginPost())) {
                                installCsrfToken(call = this@loginPage)
                                
-                               hiddenInput {
-                                       name = "tokenId"
+                               hiddenInput(name = "tokenId") {
                                        value = tokenId
                                }
                                
                                label {
                                        +"Nation Name"
                                        br
-                                       textInput {
-                                               name = "nation"
+                                       textInput(name = "nation") {
                                                placeholder = "Name of your nation without pretitle, e.g. Mechyrdia, Valentine Z, Reinkalistan, etc."
                                        }
                                }
@@ -96,8 +105,7 @@ suspend fun ApplicationCall.loginPage(): HTML.() -> Unit {
                                label {
                                        +"Verification Checksum"
                                        br
-                                       textInput {
-                                               name = "checksum"
+                                       textInput(name = "checksum") {
                                                placeholder = "The random text checksum generated by NationStates for verification"
                                        }
                                }
@@ -108,24 +116,14 @@ suspend fun ApplicationCall.loginPage(): HTML.() -> Unit {
 }
 
 suspend fun ApplicationCall.loginRoute(nation: String, checksum: String, tokenId: String): Nothing {
-       val nationId = nation.toNationId()
+       val nationSlug = nation.toNationSlug()
        val nsToken = NsStoredToken.verifyToken(tokenId)
                ?: throw MissingRequestParameterException("tokenId")
        
-       val nationData = if (nationId == Configuration.Current.ownerNation && checksum == Configuration.Current.emergencyPassword)
-               NationData.get(Id(nationId))
-       else {
-               val result = NSAPI
-                       .verifyAndGetNation(nationId, checksum)
-                       .token("mechyrdia_$nsToken")
-                       .shards(NationShard.NAME, NationShard.FLAG_URL)
-                       .executeSuspend()
-                       ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "That nation does not exist.")
-               
-               if (!result.isVerified)
-                       redirectHrefWithError(Root.Auth.LoginPage(), error = "Checksum failed verification.")
-               
-               NationData(Id(result.id), result.name, result.flagUrl).also { NationData.Table.put(it) }
+       val nationData = when (val result = NationData.verify(nationSlug, nsToken, checksum)) {
+               NationVerifyResult.NationDoesNotExist -> redirectHrefWithError(Root.Auth.LoginPage(), error = "That nation does not exist.")
+               NationVerifyResult.ChecksumFailedVerification -> redirectHrefWithError(Root.Auth.LoginPage(), error = "Checksum failed verification.")
+               is NationVerifyResult.LoginSuccess -> result.nation
        }
        
        sessions.set(UserSession(nationData.id))
index 92c8d9757b4e1559f5971bbb118d6304973263d9..e9a51b0bc18c9a535e3198aa10684a4c4a4dca2c 100644 (file)
@@ -13,10 +13,12 @@ import info.mechyrdia.data.currentNation
 import info.mechyrdia.data.serialName
 import info.mechyrdia.lore.adminPage
 import info.mechyrdia.lore.dateTime
+import info.mechyrdia.lore.redirectHref
 import info.mechyrdia.lore.redirectHrefWithError
 import info.mechyrdia.route.Root
 import info.mechyrdia.route.href
 import info.mechyrdia.route.installCsrfToken
+import io.ktor.http.HttpStatusCode
 import io.ktor.server.application.ApplicationCall
 import kotlinx.coroutines.flow.toList
 import kotlinx.html.*
@@ -28,6 +30,7 @@ import java.time.Instant
 data class WebDavToken(
        @SerialName(MONGODB_ID_KEY)
        override val id: Id<WebDavToken> = Id(),
+       val pwHash: String,
        
        val holder: Id<NationData>,
        val validUntil: @Serializable(with = InstantSerializer::class) Instant
@@ -66,21 +69,28 @@ suspend fun ApplicationCall.adminRequestWebDavToken(): HTML.() -> Unit {
                                        
                                        table {
                                                tr {
-                                                       th { +"Token" }
-                                                       th { +"Expires at" }
+                                                       th(ThScope.col) { +"Token Name" }
+                                                       th(ThScope.col) { +"Expires at" }
+                                                       th(ThScope.col) { +Entities.nbsp }
                                                }
                                                
                                                for (existingToken in existingTokens) {
                                                        tr {
                                                                td {
-                                                                       textInput {
-                                                                               readonly = true
-                                                                               value = existingToken.id.id
+                                                                       code {
+                                                                               +existingToken.id.id
                                                                        }
                                                                }
                                                                td {
                                                                        dateTime(existingToken.validUntil)
                                                                }
+                                                               td {
+                                                                       form(method = FormMethod.post, action = href(Root.Admin.Vfs.WebDavTokenDelete())) {
+                                                                               installCsrfToken(call = this@adminRequestWebDavToken)
+                                                                               hiddenInput(name = "tokenId") { value = existingToken.id.id }
+                                                                               submitInput(classes = "evil") { value = "Delete Token" }
+                                                                       }
+                                                               }
                                                        }
                                                }
                                        }
@@ -94,9 +104,12 @@ suspend fun ApplicationCall.adminObtainWebDavToken(): HTML.() -> Unit {
        val nation = currentNation()
                ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "You must be logged in to generate WebDAV tokens")
        
+       val tokenPw = gigaToken()
+       
        val token = WebDavToken(
                holder = nation.id,
-               validUntil = Instant.now().plusSeconds(86_400)
+               pwHash = Argon2Hasher.createHash(tokenPw),
+               validUntil = Instant.now().plusSeconds(31_556_925)
        )
        
        WebDavToken.Table.put(token)
@@ -106,15 +119,54 @@ suspend fun ApplicationCall.adminObtainWebDavToken(): HTML.() -> Unit {
                        h1 { +"Your New WebDAV Token" }
                        div {
                                style = "text-align:center"
-                               textInput {
-                                       readonly = true
-                                       value = token.id.id
-                               }
-                               p {
-                                       +"Your new token will expire at "
-                                       dateTime(token.validUntil)
+                               table {
+                                       tr {
+                                               th(ThScope.row) {
+                                                       style = "text-align:right"
+                                                       +"Username"
+                                               }
+                                               td {
+                                                       a(href = "#", classes = "text-copy") {
+                                                               +token.id.id
+                                                       }
+                                               }
+                                       }
+                                       tr {
+                                               th(ThScope.row) {
+                                                       style = "text-align:right"
+                                                       +"Password"
+                                               }
+                                               td {
+                                                       a(href = "#", classes = "text-copy") {
+                                                               +tokenPw
+                                                       }
+                                               }
+                                       }
+                                       tr {
+                                               th(ThScope.row) {
+                                                       style = "text-align:right"
+                                                       +"Expiration"
+                                               }
+                                               td {
+                                                       dateTime(token.validUntil)
+                                               }
+                                       }
                                }
                        }
                }
        }
 }
+
+suspend fun ApplicationCall.adminDeleteWebDavToken(tokenId: Id<WebDavToken>): Nothing {
+       val nation = currentNation() ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "You must be logged in to delete WebDAV tokens")
+       
+       val token = WebDavToken.Table.get(tokenId) ?: redirectHrefWithError(Root.Admin.Vfs.WebDavTokenPage(), error = "That token does not exist")
+       
+       if (token.holder != nation.id) {
+               redirectHrefWithError(Root.Admin.Vfs.WebDavTokenPage(), error = "You do not own that token")
+       }
+       
+       WebDavToken.Table.del(token.id)
+       
+       redirectHref(Root.Admin.Vfs.WebDavTokenPage(), HttpStatusCode.SeeOther)
+}
index 4c7f79f84cf59b04e548a680c8c0674ae8bff9ee..8a55276357dea067b880bfd569e9919edd438ded 100644 (file)
@@ -47,7 +47,7 @@ import com.mongodb.reactivestreams.client.MongoDatabase as JMongoDatabase
 
 @Serializable(IdSerializer::class)
 @JvmInline
-value class Id<T>(val id: String) {
+value class Id<@Suppress("unused") T>(val id: String) {
        override fun toString() = id
        
        companion object {
@@ -57,7 +57,9 @@ value class Id<T>(val id: String) {
 
 private val secureRandom = SecureRandom.getInstanceStrong()
 private val alphabet = "ABCDEFGHILMNOPQRSTVXYZ0123456789".toCharArray()
-fun <T> Id() = Id<T>(NanoIdUtils.randomNanoId(secureRandom, alphabet, 24))
+fun nanoId(length: Int): String = NanoIdUtils.randomNanoId(secureRandom, alphabet, length)
+
+fun <T> Id() = Id<T>(nanoId(24))
 
 object IdSerializer : KSerializer<Id<*>> {
        override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Id", PrimitiveKind.STRING)
index 7e081d7421ab8d87e37ec2a60b1e502baf148ff5..6813dfa9c22aa3ab9583f42e5a585af8e53f481a 100644 (file)
@@ -294,8 +294,8 @@ private data class GridFsEntry(
 ) : DataDocument<GridFsEntry>
 
 private class GridFsStorage(val table: DocumentTable<GridFsEntry>, val bucket: GridFSBucket) : FileStorage {
-       private fun toExactPath(path: StoragePath) = path.elements.concat("/", prefix = "/")
-       private fun toPrefixPath(path: StoragePath) = path.elements.concat("/", prefix = "/", suffix = "/")
+       private fun toExactPath(path: StoragePath) = path.elements.map { "/$it" }.concat()
+       private fun toPrefixPath(path: StoragePath) = path.elements.map { "/$it" }.concat(suffix = "/")
        
        private suspend fun testExact(path: StoragePath) = table.number(Filters.eq(GridFsEntry::path.serialName, toExactPath(path))) > 0L
        private suspend fun getExact(path: StoragePath) = table.locate(Filters.eq(GridFsEntry::path.serialName, toExactPath(path)))
index 800bd274ac13e73b0c8ee3b374eddcec3a8a176c..5f177f848a87ddcdfe4a3310ee70fe94f5f2901f 100644 (file)
@@ -1,6 +1,11 @@
 package info.mechyrdia.data
 
+import com.github.agadar.nationstates.domain.nation.Nation
+import com.github.agadar.nationstates.domain.nation.NationVerification
+import com.github.agadar.nationstates.query.ShardQuery
 import com.github.agadar.nationstates.shard.NationShard
+import com.mongodb.client.model.Filters
+import info.mechyrdia.Configuration
 import info.mechyrdia.OwnerNationId
 import info.mechyrdia.auth.NSAPI
 import info.mechyrdia.auth.UserSession
@@ -17,10 +22,17 @@ import java.util.concurrent.ConcurrentHashMap
 
 private val NationsLogger: Logger = LoggerFactory.getLogger("info.mechyrdia.data.NationsKt")
 
+@Serializable
+@JvmInline
+value class NationUrlSlug(val slug: String) {
+       override fun toString() = slug
+}
+
 @Serializable
 data class NationData(
        @SerialName(MONGODB_ID_KEY)
        override val id: Id<NationData>,
+       val slug: NationUrlSlug,
        val name: String,
        val flag: String,
        
@@ -30,44 +42,118 @@ data class NationData(
                override val Table = DocumentTable<NationData>()
                
                override suspend fun initialize() {
+                       Table.index(NationData::slug.ascending)
                        Table.index(NationData::name.ascending)
                }
                
-               fun unknown(id: Id<NationData>): NationData {
-                       NationsLogger.warn("Unable to find nation with Id $id - did it CTE?")
-                       return NationData(id, "Unknown Nation", "https://www.nationstates.net/images/flags/exnation.png")
+               private fun unknownOfDbId(dbId: Id<NationData>): NationData {
+                       NationsLogger.warn("Unable to find nation with DB ID $dbId")
+                       return NationData(
+                               id = dbId,
+                               slug = NationUrlSlug("unknown_dbid_$dbId"),
+                               name = "Unknown Nation",
+                               flag = "https://www.nationstates.net/images/flags/exnation.png"
+                       )
+               }
+               
+               private fun unknownOfSlug(slug: NationUrlSlug): NationData {
+                       NationsLogger.warn("Unable to find nation with URL slug $slug")
+                       return NationData(
+                               id = Id("unknown_slug_$slug"),
+                               slug = slug,
+                               name = "Unknown Nation",
+                               flag = "https://www.nationstates.net/images/flags/exnation.png"
+                       )
+               }
+               
+               suspend fun getByDbId(nationDbId: Id<NationData>): NationData {
+                       return Table.get(nationDbId) ?: unknownOfDbId(nationDbId)
                }
                
-               suspend fun get(id: Id<NationData>): NationData = Table.getOrPut(id) {
-                       NSAPI
-                               .getNation(id.id)
-                               .shards(NationShard.NAME, NationShard.FLAG_URL)
-                               .executeSuspend()
-                               ?.let { nation ->
-                                       NationData(id = Id(nation.id), name = nation.name, flag = nation.flagUrl)
-                               } ?: unknown(id)
+               suspend fun getBySlug(nationSlug: NationUrlSlug, queryNsApi: Boolean = true): NationData {
+                       return Table.locate(Filters.eq(NationData::slug.serialName, nationSlug))
+                               ?: (if (queryNsApi)
+                                       NSAPI.getNation(nationSlug.slug)
+                                               .defaultShards()
+                                               .executeSuspend()
+                                               ?.toNationData()
+                               else null) ?: unknownOfSlug(nationSlug)
+               }
+               
+               suspend fun verify(nationSlug: NationUrlSlug, nsToken: String, checksum: String): NationVerifyResult {
+                       val result = if (nationSlug == Configuration.Current.emergencyUsername && Configuration.Current.emergencyPassword?.verify(checksum) == true) {
+                               NSAPI.getNation(nationSlug.slug)
+                                       .defaultShards()
+                                       .executeSuspend()
+                       } else {
+                               NSAPI.verifyAndGetNation(nationSlug.slug, checksum)
+                                       .token("mechyrdia_$nsToken")
+                                       .defaultShards()
+                                       .executeSuspend()
+                       }
+                       
+                       result ?: return NationVerifyResult.NationDoesNotExist
+                       
+                       if (result is NationVerification && !result.isVerified)
+                               return NationVerifyResult.ChecksumFailedVerification
+                       
+                       val nationData = result.toNationData(Table.get(Id(result.dbId))).also { Table.put(it) }
+                       return NationVerifyResult.LoginSuccess(nationData)
                }
        }
 }
 
-val CallNationCacheAttribute = AttributeKey<MutableMap<Id<NationData>, NationData>>("Mechyrdia.NationCache")
+private fun <Q : ShardQuery<Q, *, NationShard>> Q.defaultShards(): Q = shards(NationShard.DB_ID, NationShard.NAME, NationShard.FLAG_URL)
 
-val ApplicationCall.nationCache: MutableMap<Id<NationData>, NationData>
-       get() = attributes.computeIfAbsent(CallNationCacheAttribute) {
-               ConcurrentHashMap<Id<NationData>, NationData>()
-       }
+private fun Nation.toNationData(prev: NationData? = null) = NationData(
+       id = Id(dbId),
+       slug = NationUrlSlug(id),
+       name = name,
+       flag = flagUrl,
+       isBanned = prev?.isBanned == true
+)
 
-suspend fun MutableMap<Id<NationData>, NationData>.getNation(id: Id<NationData>): NationData {
-       return getOrPut(id) {
-               NationData.get(id)
+sealed interface NationVerifyResult {
+       data object NationDoesNotExist : NationVerifyResult
+       data object ChecksumFailedVerification : NationVerifyResult
+       
+       @JvmInline
+       value class LoginSuccess(val nation: NationData) : NationVerifyResult
+}
+
+val CallNationCacheAttribute = AttributeKey<NationCache>("Mechyrdia.NationCache")
+
+class NationCache {
+       private val bySlug = ConcurrentHashMap<NationUrlSlug, NationData>()
+       private val byDbId = ConcurrentHashMap<Id<NationData>, NationData>()
+       
+       suspend fun getBySlug(slug: NationUrlSlug): NationData {
+               return bySlug.getOrPut(slug) {
+                       NationData.getBySlug(slug, false).also { byDbId.putIfAbsent(it.id, it) }
+               }
+       }
+       
+       suspend fun getByDbId(dbId: Id<NationData>): NationData {
+               return byDbId.getOrPut(dbId) {
+                       NationData.getByDbId(dbId).also { bySlug.putIfAbsent(it.slug, it) }
+               }
        }
 }
 
+val ApplicationCall.nationCache: NationCache
+       get() = attributes.computeIfAbsent(CallNationCacheAttribute) {
+               NationCache()
+       }
+
 private val CallCurrentNationAttribute = AttributeKey<NationSession>("Mechyrdia.CurrentNation")
 
-fun ApplicationCall.ownerNationOnly() {
-       if (sessions.get<UserSession>()?.nationId != OwnerNationId)
+suspend fun ApplicationCall.adminNationOnly(): NationData {
+       val nationData = currentNation()
+       
+       if (nationData?.id != OwnerNationId)
                throw NoSuchElementException("Hidden page")
+       
+       return nationData
 }
 
 suspend fun ApplicationCall.currentNation(): NationData? {
@@ -77,7 +163,7 @@ suspend fun ApplicationCall.currentNation(): NationData? {
        
        return sessions.get<UserSession>()
                ?.nationId
-               ?.let { nationCache.getNation(it) }
+               ?.let { nationCache.getByDbId(it) }
                ?.also { attributes.put(CallCurrentNationAttribute, NationSession(it)) }
 }
 
index ad245648f4097d83e9f4e883ada6cfdb2a8b8529..e6a9610bc21b4a8d42133f64ae9daa1cb878d8e2 100644 (file)
@@ -36,9 +36,9 @@ data class CommentRenderData(
        val replyLinks: List<Id<Comment>>,
 ) {
        companion object {
-               private suspend fun render(comment: Comment, nations: MutableMap<Id<NationData>, NationData> = mutableMapOf()): CommentRenderData {
+               private suspend fun render(comment: Comment, nations: NationCache = NationCache()): CommentRenderData {
                        val (nationData, pageTitle, htmlResult) = coroutineScope {
-                               val nationDataAsync = async { nations.getNation(comment.submittedBy) }
+                               val nationDataAsync = async { nations.getByDbId(comment.submittedBy) }
                                val pageTitleAsync = async { (StoragePath.articleDir / comment.submittedIn).toFriendlyPathTitle() }
                                val htmlResultAsync = async { comment.contents.parseAs(ParserTree::toCommentHtml) }
                                
@@ -59,7 +59,7 @@ data class CommentRenderData(
                        )
                }
                
-               suspend operator fun invoke(comments: List<Comment>, nations: MutableMap<Id<NationData>, NationData> = mutableMapOf()): List<CommentRenderData> {
+               suspend operator fun invoke(comments: List<Comment>, nations: NationCache = NationCache()): List<CommentRenderData> {
                        return comments.mapSuspend { comment ->
                                render(comment, nations)
                        }
@@ -86,7 +86,7 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData
                        img(src = comment.submittedBy.flag, alt = "Flag of ${comment.submittedBy.name}", classes = "flag-icon")
                        span(classes = "author-name") {
                                +Entities.nbsp
-                               a(href = call.href(Root.User.ById(comment.submittedBy.id))) {
+                               a(href = call.href(Root.User.BySlug(comment.submittedBy.slug))) {
                                        +comment.submittedBy.name
                                }
                        }
index a7040c1903367b5e3678e07e0620292ce4d159cf..280fafde13f2d3a2fcd12c44e93ebe1cd31d4159 100644 (file)
@@ -41,7 +41,7 @@ suspend fun ApplicationCall.recentCommentsPage(limit: Int?): HTML.() -> Unit {
                Comment.Table
                        .sorted(Sorts.descending(Comment::submittedAt.serialName))
                        .filterNot { comment ->
-                               comment.submittedBy != currNation?.id && NationData.get(comment.submittedBy).isBanned
+                               comment.submittedBy != currNation?.id && NationData.getByDbId(comment.submittedBy).isBanned
                        }
                        .take(limit)
                        .toList(),
@@ -103,7 +103,7 @@ suspend fun ApplicationCall.viewCommentRoute(commentId: Id<Comment>): Nothing {
        val comment = Comment.Table.get(commentId)!!
        
        val currentNation = currentNation()
-       val submitter = nationCache.getNation(comment.submittedBy)
+       val submitter = nationCache.getByDbId(comment.submittedBy)
        
        if (submitter.isBanned && currentNation?.id != comment.submittedBy && currentNation?.id != OwnerNationId)
                throw NoSuchElementException("Shadowbanned comment")
index 9e2630d41ce4a8374ce7ef43bfa8f24b2227e0c4..2de8a3b15513be6108851aaebfcb1e7173dd5cf8 100644 (file)
@@ -119,7 +119,7 @@ suspend fun ApplicationCall.adminViewVfs(path: StoragePath): HTML.() -> Unit {
                        when (tree) {
                                is TreeNode.FileNode -> table {
                                        tr {
-                                               th {
+                                               th(ThScope.col) {
                                                        colSpan = "2"
                                                        +"/$path"
                                                }
@@ -133,19 +133,19 @@ suspend fun ApplicationCall.adminViewVfs(path: StoragePath): HTML.() -> Unit {
                                                }
                                        }
                                        tr {
-                                               th { +"Created at" }
+                                               th(ThScope.row) { +"Created at" }
                                                td { dateTime(tree.stats.created) }
                                        }
                                        tr {
-                                               th { +"Last updated at" }
+                                               th(ThScope.row) { +"Last updated at" }
                                                td { dateTime(tree.stats.updated) }
                                        }
                                        tr {
-                                               th { +"Size (bytes)" }
+                                               th(ThScope.row) { +"Size (bytes)" }
                                                td { +"${tree.stats.size}" }
                                        }
                                        tr {
-                                               th { +"Actions" }
+                                               th(ThScope.row) { +"Actions" }
                                                td {
                                                        ul {
                                                                li {
@@ -177,7 +177,7 @@ suspend fun ApplicationCall.adminViewVfs(path: StoragePath): HTML.() -> Unit {
                                                }
                                        }
                                        tr {
-                                               th { +"Navigate" }
+                                               th(ThScope.row) { +"Navigate" }
                                                td {
                                                        ul {
                                                                path.elements.indices.forEach { index ->
@@ -311,11 +311,11 @@ suspend fun ApplicationCall.adminConfirmDeleteFile(path: StoragePath) {
                                }
                                table {
                                        tr {
-                                               th { +"Last Updated" }
+                                               th(ThScope.row) { +"Last Updated" }
                                                td { dateTime(stats.updated) }
                                        }
                                        tr {
-                                               th { +"Size (bytes)" }
+                                               th(ThScope.row) { +"Size (bytes)" }
                                                td { +"${stats.size}" }
                                        }
                                }
index 5f814e177e21fcd7932d3a19408946a1dac2a11a..5d48b69e5b23864ce738c31a5deedfc34fc58cb0 100644 (file)
@@ -2,7 +2,6 @@ package info.mechyrdia.data
 
 import com.mongodb.client.model.Updates
 import info.mechyrdia.OwnerNationId
-import info.mechyrdia.auth.UserSession
 import info.mechyrdia.lore.NationProfileSidebar
 import info.mechyrdia.lore.page
 import info.mechyrdia.lore.redirectHref
@@ -12,22 +11,25 @@ import info.mechyrdia.route.href
 import info.mechyrdia.route.installCsrfToken
 import io.ktor.http.HttpStatusCode
 import io.ktor.server.application.ApplicationCall
-import io.ktor.server.sessions.get
-import io.ktor.server.sessions.sessions
 import kotlinx.coroutines.flow.toList
-import kotlinx.html.*
+import kotlinx.html.HTML
+import kotlinx.html.a
+import kotlinx.html.h1
+import kotlinx.html.id
+import kotlinx.html.p
+import kotlinx.html.section
 
-fun ApplicationCall.currentUserPage(): Nothing {
-       val currNationId = sessions.get<UserSession>()?.nationId
-       if (currNationId == null)
+suspend fun ApplicationCall.currentUserPage(): Nothing {
+       val currNation = currentNation()
+       if (currNation == null)
                redirectHref(Root.Auth.LoginPage(), HttpStatusCode.Found)
        else
-               redirectHref(Root.User.ById(currNationId), HttpStatusCode.Found)
+               redirectHref(Root.User.BySlug(currNation.slug), HttpStatusCode.Found)
 }
 
-suspend fun ApplicationCall.userPage(userId: Id<NationData>): HTML.() -> Unit {
+suspend fun ApplicationCall.userPage(userSlug: NationUrlSlug): HTML.() -> Unit {
        val currNation = currentNation()
-       val viewingNation = nationCache.getNation(userId)
+       val viewingNation = nationCache.getBySlug(userSlug)
        
        val comments = CommentRenderData(
                Comment.getCommentsBy(viewingNation.id).toList(),
@@ -41,13 +43,13 @@ suspend fun ApplicationCall.userPage(userId: Id<NationData>): HTML.() -> Unit {
                        if (currNation?.id == OwnerNationId) {
                                if (viewingNation.isBanned) {
                                        p { +"This user is banned" }
-                                       val unbanLink = href(Root.Admin.Unban(viewingNation.id))
+                                       val unbanLink = href(Root.Admin.Unban(viewingNation.slug))
                                        a(href = unbanLink) {
                                                installCsrfToken(unbanLink, call = this@userPage)
                                                +"Unban"
                                        }
                                } else {
-                                       val banLink = href(Root.Admin.Ban(viewingNation.id))
+                                       val banLink = href(Root.Admin.Ban(viewingNation.slug))
                                        a(href = banLink) {
                                                installCsrfToken(banLink, call = this@userPage)
                                                +"Ban"
@@ -60,20 +62,20 @@ suspend fun ApplicationCall.userPage(userId: Id<NationData>): HTML.() -> Unit {
        }
 }
 
-suspend fun ApplicationCall.adminBanUserRoute(userId: Id<NationData>): Nothing {
-       val bannedNation = nationCache.getNation(userId)
+suspend fun ApplicationCall.adminBanUserRoute(userSlug: NationUrlSlug): Nothing {
+       val bannedNation = nationCache.getBySlug(userSlug)
        
        if (!bannedNation.isBanned)
                NationData.Table.set(bannedNation.id, Updates.set(NationData::isBanned.serialName, true))
        
-       redirectHref(Root.User.ById(userId), HttpStatusCode.SeeOther)
+       redirectHref(Root.User.BySlug(userSlug), HttpStatusCode.SeeOther)
 }
 
-suspend fun ApplicationCall.adminUnbanUserRoute(userId: Id<NationData>): Nothing {
-       val bannedNation = nationCache.getNation(userId)
+suspend fun ApplicationCall.adminUnbanUserRoute(userSlug: NationUrlSlug): Nothing {
+       val bannedNation = nationCache.getBySlug(userSlug)
        
        if (bannedNation.isBanned)
                NationData.Table.set(bannedNation.id, Updates.set(NationData::isBanned.serialName, false))
        
-       redirectHref(Root.User.ById(userId), HttpStatusCode.SeeOther)
+       redirectHref(Root.User.BySlug(userSlug), HttpStatusCode.SeeOther)
 }
index 6f1e07d26c0d4c7503d2fa704ba913d1581d9934..188e89c551ce5c7c82a40cb1e14b13a9f191053a 100644 (file)
@@ -48,7 +48,7 @@ class PreProcessorContext private constructor(
                fun defaults(lorePath: StoragePath) = defaults(lorePath.elements.drop(1))
                
                fun defaults(lorePath: List<String>) = mapOf(
-                       PAGE_PATH_KEY to lorePath.concat("/", prefix = "/").textToTree(),
+                       PAGE_PATH_KEY to lorePath.map { "/$it" }.concat().textToTree(),
                        INSTANT_NOW_KEY to Instant.now().toEpochMilli().numberToTree(),
                )
        }
index dad60f335e69a4f01b3cdc65b1d4552ecc64e64a..3a7b6f36f762f48b6d6abbd74e7020fd925d8f4f 100644 (file)
@@ -14,7 +14,6 @@ import info.mechyrdia.data.XmlTag
 import info.mechyrdia.data.XmlTagConsumer
 import info.mechyrdia.data.currentNation
 import info.mechyrdia.data.declaration
-import info.mechyrdia.data.getNation
 import info.mechyrdia.data.nationCache
 import info.mechyrdia.data.respondXml
 import info.mechyrdia.data.root
@@ -150,7 +149,7 @@ suspend fun ApplicationCall.recentCommentsRssFeedGenerator(limit: Int): RssChann
                                if (currNation?.id == OwnerNationId)
                                        flow
                                else flow.filterNot { comment ->
-                                       comment.submittedBy != currNation?.id && nationCache.getNation(comment.submittedBy).isBanned
+                                       comment.submittedBy != currNation?.id && nationCache.getByDbId(comment.submittedBy).isBanned
                                }
                        }
                        .take(limit)
index bc5507d4b58b531722248e6f7920b5556fa846c4..1a43760cab7d7d7e48145c28f9c67fb01624f46f 100644 (file)
@@ -1,5 +1,7 @@
 package info.mechyrdia.route
 
+import info.mechyrdia.auth.WebDavToken
+import info.mechyrdia.data.Id
 import info.mechyrdia.lore.TextAlignment
 import kotlinx.html.*
 import kotlinx.serialization.Serializable
@@ -53,6 +55,9 @@ class AdminVfsCopyFilePayload(val from: String, override val csrfToken: String?
 @Serializable
 class AdminVfsRequestWebDavTokenPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
 
+@Serializable
+class AdminVfsDeleteWebDavTokenPayload(val tokenId: Id<WebDavToken>, override val csrfToken: String? = null) : CsrfProtectedResourcePayload
+
 @Serializable
 class AdminVfsDeleteFilePayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
 
index 27e15bea9ede6174d474e986e0bb3aa65d61015e..d9e633077431adc86b36fd219eb7f6c2014c3d89 100644 (file)
@@ -36,8 +36,7 @@ fun A.installCsrfToken(route: String = href, call: ApplicationCall) {
 }
 
 fun FORM.installCsrfToken(route: String = action, call: ApplicationCall) {
-       hiddenInput {
-               name = "csrfToken"
+       hiddenInput(name = "csrfToken") {
                value = call.createCsrfToken(route)
        }
 }
index 1db9c1c806615f55b2db8ce3398b6b00e3edea4f..24da667d8542618b9b854b260a54f57890bb2853 100644 (file)
@@ -1,5 +1,6 @@
 package info.mechyrdia.route
 
+import info.mechyrdia.auth.adminDeleteWebDavToken
 import info.mechyrdia.auth.adminObtainWebDavToken
 import info.mechyrdia.auth.adminRequestWebDavToken
 import info.mechyrdia.auth.loginPage
@@ -8,7 +9,7 @@ import info.mechyrdia.auth.logoutRoute
 import info.mechyrdia.concat
 import info.mechyrdia.data.Comment
 import info.mechyrdia.data.Id
-import info.mechyrdia.data.NationData
+import info.mechyrdia.data.NationUrlSlug
 import info.mechyrdia.data.StoragePath
 import info.mechyrdia.data.adminBanUserRoute
 import info.mechyrdia.data.adminConfirmDeleteFile
@@ -16,6 +17,7 @@ import info.mechyrdia.data.adminConfirmRemoveDirectory
 import info.mechyrdia.data.adminDeleteFile
 import info.mechyrdia.data.adminDoCopyFile
 import info.mechyrdia.data.adminMakeDirectory
+import info.mechyrdia.data.adminNationOnly
 import info.mechyrdia.data.adminOverwriteFile
 import info.mechyrdia.data.adminPreviewFile
 import info.mechyrdia.data.adminRemoveDirectory
@@ -29,7 +31,6 @@ import info.mechyrdia.data.deleteCommentPage
 import info.mechyrdia.data.deleteCommentRoute
 import info.mechyrdia.data.editCommentRoute
 import info.mechyrdia.data.newCommentRoute
-import info.mechyrdia.data.ownerNationOnly
 import info.mechyrdia.data.recentCommentsPage
 import info.mechyrdia.data.respondStoredFile
 import info.mechyrdia.data.respondXml
@@ -329,12 +330,12 @@ class Root : ResourceHandler, ResourceFilter {
                        call.currentUserPage()
                }
                
-               @Resource("{id}")
-               class ById(val id: Id<NationData>, val user: User = User()) : ResourceHandler {
+               @Resource("{slug}")
+               class BySlug(val slug: NationUrlSlug, val user: User = User()) : ResourceHandler {
                        override suspend fun RoutingContext.handleCall() {
                                with(user) { call.filterCall() }
                                
-                               call.respondHtml(HttpStatusCode.OK, call.userPage(id))
+                               call.respondHtml(HttpStatusCode.OK, call.userPage(slug))
                        }
                }
        }
@@ -343,26 +344,26 @@ class Root : ResourceHandler, ResourceFilter {
        class Admin(val root: Root = Root()) : ResourceFilter {
                override suspend fun ApplicationCall.filterCall() {
                        with(root) { filterCall() }
-                       ownerNationOnly()
+                       adminNationOnly()
                }
                
-               @Resource("ban/{id}")
-               class Ban(val id: Id<NationData>, val admin: Admin = Admin()) : ResourceReceiver<AdminBanUserPayload> {
+               @Resource("ban/{slug}")
+               class Ban(val slug: NationUrlSlug, val admin: Admin = Admin()) : ResourceReceiver<AdminBanUserPayload> {
                        override suspend fun RoutingContext.handleCall(payload: AdminBanUserPayload) {
                                with(admin) { call.filterCall() }
                                with(payload) { call.verifyCsrfToken() }
                                
-                               call.adminBanUserRoute(id)
+                               call.adminBanUserRoute(slug)
                        }
                }
                
-               @Resource("unban/{id}")
-               class Unban(val id: Id<NationData>, val admin: Admin = Admin()) : ResourceReceiver<AdminUnbanUserPayload> {
+               @Resource("unban/{slug}")
+               class Unban(val slug: NationUrlSlug, val admin: Admin = Admin()) : ResourceReceiver<AdminUnbanUserPayload> {
                        override suspend fun RoutingContext.handleCall(payload: AdminUnbanUserPayload) {
                                with(admin) { call.filterCall() }
                                with(payload) { call.verifyCsrfToken() }
                                
-                               call.adminUnbanUserRoute(id)
+                               call.adminUnbanUserRoute(slug)
                        }
                }
                
@@ -456,6 +457,16 @@ class Root : ResourceHandler, ResourceFilter {
                                }
                        }
                        
+                       @Resource("webdav-token/delete")
+                       class WebDavTokenDelete(val vfs: Vfs = Vfs()) : ResourceReceiver<AdminVfsDeleteWebDavTokenPayload> {
+                               override suspend fun RoutingContext.handleCall(payload: AdminVfsDeleteWebDavTokenPayload) {
+                                       with(vfs) { call.filterCall() }
+                                       with(payload) { call.verifyCsrfToken() }
+                                       
+                                       call.adminDeleteWebDavToken(payload.tokenId)
+                               }
+                       }
+                       
                        @Resource("copy/{path...}")
                        class CopyPage(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
                                override suspend fun RoutingContext.handleCall() {
index 95725b204b70bdde10fb95948b50828e6dfc3284..04eebdc1e0256fb2a8922563504a37b1d99c8965 100644 (file)
@@ -1,8 +1,9 @@
 package info.mechyrdia.route
 
+import info.mechyrdia.Configuration
 import info.mechyrdia.Utf8
+import info.mechyrdia.auth.Argon2Hasher
 import info.mechyrdia.auth.WebDavToken
-import info.mechyrdia.auth.toNationId
 import info.mechyrdia.concat
 import info.mechyrdia.data.FileStorage
 import info.mechyrdia.data.Id
@@ -22,6 +23,7 @@ import io.ktor.http.HttpHeaders
 import io.ktor.http.HttpMethod
 import io.ktor.http.HttpStatusCode
 import io.ktor.server.application.ApplicationCall
+import io.ktor.server.application.log
 import io.ktor.server.html.respondHtml
 import io.ktor.server.request.ApplicationRequest
 import io.ktor.server.request.authorization
@@ -42,9 +44,8 @@ import java.time.ZoneOffset
 import java.time.ZonedDateTime
 import java.time.format.DateTimeFormatter
 import java.util.Base64
-import java.util.UUID
 
-const val WebDavDomainName = "https://dav.mechyrdia.info"
+const val WebDavDomainName = "http://localhost:8180"
 
 private val dateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME
 
@@ -134,7 +135,7 @@ private suspend fun getWebDavPropertiesWithIncludeTags(path: StoragePath, webRoo
                        .filterNotNull()
                        .flatten()
                
-               val pathWithSuffix = path.elements.concat("/", suffix = "/")
+               val pathWithSuffix = path.elements.map { "$it/" }.concat()
                listOf(
                        WebDavProperties.Collection(
                                creationDate = subProps.mapNotNull { it.first.creationDate }.maxOrNull(),
@@ -198,7 +199,7 @@ private val base64Decoder = Base64.getDecoder()
 
 fun ApplicationRequest.basicAuth(): Pair<String, String>? {
        val auth = authorization() ?: return null
-       if (!auth.startsWith("Basic ")) return null
+       if (!auth.startsWith(" ")) return null
        val basic = auth.substring(6)
        return String(base64Decoder.decode(basic), Utf8)
                .split(':', limit = 2)
@@ -208,13 +209,16 @@ fun ApplicationRequest.basicAuth(): Pair<String, String>? {
 suspend fun ApplicationCall.beforeWebDav() {
        attributes.put(WebDavAttributeKey, true)
        
-       val (user, token) = request.basicAuth() ?: throw WebDavAuthRequired()
-       val tokenData = WebDavToken.Table.get(Id(token)) ?: throw WebDavAuthRequired()
+       response.header(HttpHeaders.DAV, "1,2")
        
-       if (tokenData.holder.id != user.toNationId() || tokenData.validUntil < Instant.now())
-               throw WebDavAuthRequired()
+       if (Configuration.Current.isDevMode)
+               return
        
-       response.header(HttpHeaders.DAV, "1,2")
+       val (tokenId, tokenPw) = request.basicAuth() ?: throw WebDavAuthRequired()
+       val tokenData = WebDavToken.Table.get(Id(tokenId)) ?: throw WebDavAuthRequired()
+       
+       if (tokenData.validUntil < Instant.now() || !Argon2Hasher.verifyHash(tokenData.pwHash, tokenPw))
+               throw WebDavAuthRequired()
 }
 
 suspend fun ApplicationCall.webDavOptions() {
@@ -360,10 +364,8 @@ suspend fun ApplicationCall.webDavDelete(path: StoragePath) {
 suspend fun ApplicationCall.webDavLock(path: StoragePath) {
        beforeWebDav()
        
-       if (request.header(HttpHeaders.ContentType) != null)
-               receiveText()
-       
-       val depth = request.header(HttpHeaders.Depth) ?: "Infinity"
+       val lockRequest = receiveText()
+       application.log.debug(lockRequest)
        
        respondXml {
                declaration()
@@ -372,12 +374,9 @@ suspend fun ApplicationCall.webDavLock(path: StoragePath) {
                                        "activelock" {
                                                "lockscope" { "shared"() }
                                                "locktype" { "write"() }
-                                               "depth" { +depth }
+                                               "depth" { +"0" }
                                                "owner"()
                                                "timeout" { +"Second-86400" }
-                                               "locktoken" {
-                                                       "href" { +"opaquelocktoken:${UUID.randomUUID()}" }
-                                               }
                                        }
                                }
                        }
index e6ad2416ece1c0671b68c2a9aff4a0cef70e5ce4..c935f7c3976a78e0c769733fd5a3cf964be551c9 100644 (file)
@@ -1,5 +1,18 @@
 (function () {
+       function getCookieMap() {
+               return document.cookie
+                       .split(";")
+                       .reduce((obj, entry) => {
+                               const trimmed = entry.trim();
+                               const eqI = trimmed.indexOf('=');
+                               const key = trimmed.substring(0, eqI).trimEnd();
+                               const value = trimmed.substring(eqI + 1).trimStart();
+                               return {...obj, [key]: value};
+                       }, {});
+       }
+
        window.addEventListener("load", function () {
+               // File uploads
                const fileInputs = document.querySelectorAll("input[type=file]");
                for (const fileInput of fileInputs) {
                        fileInput.addEventListener("change", e => {
                        moment.style.display = "inline";
                }
        });
+
+       window.addEventListener("load", function () {
+               // Text copying
+               const textsToCopy = document.querySelectorAll(".text-copy");
+               for (const textToCopy of textsToCopy) {
+                       textToCopy.addEventListener("click", e => {
+                               e.preventDefault();
+                               navigator.clipboard.writeText(e.currentTarget.innerText)
+                                       .catch(reason => {
+                                               console.error("Error copying text to clipboard!", reason);
+                                               alert("Text copy failed");
+                                       });
+                       });
+               }
+       });
+
+       window.addEventListener("load", function () {
+               // Error popup
+               const errorMsg = getCookieMap()["ERROR_MSG"];
+               if (errorMsg != null) {
+                       document.cookie = "ERROR_MSG=; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax; Secure";
+                       alert(errorMsg);
+               }
+       });
 })();