Record visits of factbooks
authorLanius Trolling <lanius@laniustrolling.dev>
Tue, 25 Jul 2023 16:41:10 +0000 (12:41 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Tue, 25 Jul 2023 16:42:15 +0000 (12:42 -0400)
src/main/kotlin/info/mechyrdia/auth/csrf.kt
src/main/kotlin/info/mechyrdia/auth/views_login.kt
src/main/kotlin/info/mechyrdia/data/data.kt
src/main/kotlin/info/mechyrdia/data/data_utils.kt
src/main/kotlin/info/mechyrdia/data/nations.kt
src/main/kotlin/info/mechyrdia/data/visits.kt [new file with mode: 0644]
src/main/kotlin/info/mechyrdia/lore/views_lore.kt

index 23c854d2a2a622d4458242667e875a0530b6867b..cea7811608de8bdff1cd864bd6478a0cadbbcf61 100644 (file)
@@ -7,7 +7,6 @@ import io.ktor.server.application.*
 import io.ktor.server.plugins.*
 import io.ktor.server.request.*
 import io.ktor.server.sessions.*
-import io.ktor.server.util.*
 import kotlinx.html.FORM
 import kotlinx.html.hiddenInput
 import java.time.Instant
index 6595d49b60daad7447ad3f9a6808e99c415651c0..cce96bfbcf87500b72a4e7f9f55bedcd1d274718 100644 (file)
@@ -43,7 +43,7 @@ suspend fun ApplicationCall.loginPage(): HTML.() -> Unit = page("Log In With Nat
                        p {
                                style = "text-align:center"
                                button(classes = "view-checksum") {
-                                       attributes["data-token"] = nsToken
+                                       attributes["data-token"] = "mechyrdia_$nsToken"
                                        +"View Your Checksum"
                                }
                        }
@@ -65,12 +65,12 @@ suspend fun ApplicationCall.loginRoute(): Nothing {
        
        val nation = postParams.getOrFail("nation").toNationId()
        val checksum = postParams.getOrFail("checksum")
-       val token = nsTokenMap.remove(postParams.getOrFail("token"))
+       val nsToken = nsTokenMap.remove(postParams.getOrFail("token"))
                ?: throw MissingRequestParameterException("token")
        
        val result = NSAPI
                .verifyAndGetNation(nation, checksum)
-               .token(token)
+               .token("mechyrdia_$nsToken")
                .shards(NationShard.NAME, NationShard.FLAG_URL)
                .executeSuspend()
                ?: redirectWithError("/auth/login", "That nation does not exist.")
index 37835dd2fb5f7e6bfa47d083c9957918de5c0e5c..c15c4dca2be927300ef56a17a49a201f0cae39de 100644 (file)
@@ -17,6 +17,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor
 import kotlinx.serialization.encoding.Decoder
 import kotlinx.serialization.encoding.Encoder
 import org.bson.conversions.Bson
+import org.litote.kmongo.coroutine.CoroutineAggregatePublisher
 import org.litote.kmongo.coroutine.CoroutineClient
 import org.litote.kmongo.coroutine.coroutine
 import org.litote.kmongo.reactivestreams.KMongo
@@ -99,7 +100,7 @@ interface DataDocument<T : DataDocument<T>> {
        val id: Id<T>
 }
 
-class DocumentTable<T : DataDocument<T>>(val kClass: KClass<T>) {
+class DocumentTable<T : DataDocument<T>>(private val kClass: KClass<T>) {
        private suspend fun collection() = ConnectionHolder.getDatabase().database.getCollection(kClass.simpleName!!, kClass.java).coroutine
        
        suspend fun index(vararg properties: KProperty1<T, *>) {
@@ -175,8 +176,14 @@ class DocumentTable<T : DataDocument<T>>(val kClass: KClass<T>) {
        suspend fun remove(where: Bson) {
                collection().deleteMany(where)
        }
+       
+       suspend fun <T : Any> aggregate(pipeline: List<Bson>, resultClass: KClass<T>): CoroutineAggregatePublisher<T> {
+               return collection().collection.aggregate(pipeline, resultClass.java).coroutine
+       }
 }
 
+suspend inline fun <T : DataDocument<T>, reified R : Any> DocumentTable<T>.aggregate(pipeline: List<Bson>) = aggregate(pipeline, R::class)
+
 inline fun <reified T : DataDocument<T>> DocumentTable() = DocumentTable(T::class)
 
 interface TableHolder<T : DataDocument<T>> {
@@ -190,7 +197,8 @@ interface TableHolder<T : DataDocument<T>> {
                        SessionStorageDoc,
                        NationData,
                        Comment,
-                       CommentReplyLink
+                       CommentReplyLink,
+                       PageVisitData
                )
        }
 }
index 582dd03f86ab84f728ed6a95e90609fad7a714e8..6c799e29cf2698eb91a8b522fbf7afb246019504 100644 (file)
@@ -1,5 +1,13 @@
 package info.mechyrdia.data
 
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.builtins.ListSerializer
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
 suspend inline fun <T : DataDocument<T>> DocumentTable<T>.getOrPut(id: Id<T>, defaultValue: () -> T): T {
        val value = get(id)
        return if (value == null) {
@@ -13,3 +21,25 @@ suspend inline fun <T : DataDocument<T>> DocumentTable<T>.getOrPut(id: Id<T>, de
                value
        }
 }
+
+@Serializable
+private data class MapAsListEntry<K, V>(val key: K, val value: V) {
+       constructor(entry: Map.Entry<K, V>) : this(entry.key, entry.value)
+       
+       fun toPair() = key to value
+}
+
+@OptIn(ExperimentalSerializationApi::class)
+class MapAsListSerializer<K, V>(keySerializer: KSerializer<K>, valueSerializer: KSerializer<V>) : KSerializer<Map<K, V>> {
+       private val inner = ListSerializer(MapAsListEntry.serializer(keySerializer, valueSerializer))
+       
+       override val descriptor: SerialDescriptor = SerialDescriptor("MapAsListSerializer<${keySerializer.descriptor.serialName}, ${valueSerializer.descriptor.serialName}>", inner.descriptor)
+       
+       override fun serialize(encoder: Encoder, value: Map<K, V>) {
+               inner.serialize(encoder, value.map { MapAsListEntry(it) })
+       }
+       
+       override fun deserialize(decoder: Decoder): Map<K, V> {
+               return inner.deserialize(decoder).associate { it.toPair() }
+       }
+}
index 2882a14921a59aea4e48cba6ddc86f7b072e8fb8..4b6cef1c87750a1f47d0f01f3bf889344b7e8cc7 100644 (file)
@@ -11,7 +11,6 @@ import io.ktor.server.sessions.*
 import io.ktor.util.*
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
-import java.lang.NullPointerException
 
 @Serializable
 data class NationData(
diff --git a/src/main/kotlin/info/mechyrdia/data/visits.kt b/src/main/kotlin/info/mechyrdia/data/visits.kt
new file mode 100644 (file)
index 0000000..6d581e6
--- /dev/null
@@ -0,0 +1,106 @@
+package info.mechyrdia.data
+
+import info.mechyrdia.lore.dateTime
+import io.ktor.server.application.*
+import io.ktor.server.plugins.*
+import io.ktor.server.request.*
+import io.ktor.server.sessions.*
+import io.ktor.util.*
+import kotlinx.html.FlowContent
+import kotlinx.html.p
+import kotlinx.html.style
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import org.litote.kmongo.*
+import java.security.MessageDigest
+import java.time.Instant
+
+@Serializable
+data class PageVisitTotals(
+       val total: Long,
+       val totalUnique: Long,
+       val mostRecent: @Contextual Instant?
+)
+
+@Serializable
+data class PageVisitData(
+       @SerialName("_id")
+       override val id: Id<PageVisitData> = Id(),
+       
+       val path: String,
+       val visitor: String,
+       val visits: Long = 0L,
+       val lastVisit: @Contextual Instant = Instant.now()
+) : DataDocument<PageVisitData> {
+       companion object : TableHolder<PageVisitData> {
+               override val Table = DocumentTable<PageVisitData>()
+               
+               override suspend fun initialize() {
+                       Table.index(PageVisitData::path)
+                       Table.unique(PageVisitData::path, PageVisitData::visitor)
+                       Table.index(PageVisitData::lastVisit)
+               }
+               
+               suspend fun visit(path: String, visitor: String) {
+                       Table.update(
+                               and(PageVisitData::path eq path, PageVisitData::visitor eq visitor),
+                               combine(
+                                       inc(PageVisitData::visits, 1L),
+                                       setValue(PageVisitData::lastVisit, Instant.now())
+                               )
+                       )
+               }
+               
+               suspend fun totalVisits(path: String): PageVisitTotals {
+                       return Table.aggregate<_, PageVisitTotals>(
+                               listOf(
+                                       match(PageVisitData::path eq path),
+                                       group(
+                                               null,
+                                               PageVisitTotals::total sum PageVisitData::visits,
+                                               PageVisitTotals::totalUnique sum 1L,
+                                               PageVisitTotals::mostRecent max PageVisitData::lastVisit,
+                                       )
+                               )
+                       ).first() ?: PageVisitTotals(0L, 0L, null)
+               }
+       }
+}
+
+private val messageDigestProvider = ThreadLocal.withInitial { MessageDigest.getInstance("SHA-256") }
+
+fun ApplicationCall.anonymizedClientId(): String {
+       val messageDigest = messageDigestProvider.get()
+       
+       messageDigest.reset()
+       messageDigest.update(request.origin.remoteAddress.encodeToByteArray())
+       request.userAgent()?.encodeToByteArray()?.let { messageDigest.update(it) }
+       
+       return hex(messageDigest.digest())
+}
+
+suspend fun ApplicationCall.processGuestbook(): PageVisitTotals {
+       val path = request.path()
+       
+       val totals = PageVisitData.totalVisits(path)
+       PageVisitData.visit(path, anonymizedClientId())
+       
+       return totals
+}
+
+fun FlowContent.guestbook(totalsData: PageVisitTotals) {
+       p {
+               style = "font-size:0.8em"
+               
+               +"This page has been visited ${totalsData.total} times by ${totalsData.totalUnique} unique visitors, most recently "
+               
+               val mostRecent = totalsData.mostRecent
+               if (mostRecent == null)
+                       +"in the abyss of unwritten history"
+               else {
+                       +"at "
+                       dateTime(mostRecent)
+               }
+       }
+}
index 6ca8c9a62c0b720dbada9484f250ef9787ca13bd..4e58a4e775c1db7df1db04e3d0ebb320454d89e4 100644 (file)
@@ -11,6 +11,8 @@ import kotlinx.html.*
 import java.io.File
 
 suspend fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
+       val totalsData = processGuestbook()
+       
        val pagePathParts = parameters.getAll("path")!!
        val pagePath = pagePathParts.joinToString("/")
        val pageFile = File(Configuration.CurrentConfiguration.articleDir).combineSafe(pagePath)
@@ -45,15 +47,8 @@ suspend fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
                                        pageNode.subNodes.renderInto(this, pagePath.takeIf { it.isNotEmpty() })
                                }
                        }
-                       section {
-                               h2 {
-                                       a { id = "comments" }
-                                       +"Comments"
-                               }
-                               commentInput(pagePath, canCommentAs)
-                               for (comment in comments)
-                                       commentBox(comment, canCommentAs?.id)
-                       }
+                       
+                       finalSection(pagePath, canCommentAs, comments, totalsData)
                }
        } else if (pageFile.isFile) {
                val pageTemplate = pageFile.readText()
@@ -73,15 +68,8 @@ suspend fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
                                a { id = "page-top" }
                                unsafe { raw(pageHtml) }
                        }
-                       section {
-                               h2 {
-                                       a { id = "comments" }
-                                       +"Comments"
-                               }
-                               commentInput(pagePath, canCommentAs)
-                               for (comment in comments)
-                                       commentBox(comment, canCommentAs?.id)
-                       }
+                       
+                       finalSection(pagePath, canCommentAs, comments, totalsData)
                }
        } else {
                val title = pagePathParts.last().toFriendlyPageTitle()
@@ -105,15 +93,23 @@ suspend fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
                                        +"?"
                                }
                        }
-                       section {
-                               h2 {
-                                       a { id = "comments" }
-                                       +"Comments"
-                               }
-                               commentInput(pagePath, canCommentAs)
-                               for (comment in comments)
-                                       commentBox(comment, canCommentAs?.id)
-                       }
+                       
+                       finalSection(pagePath, canCommentAs, comments, totalsData)
                }
        }
 }
+
+context(ApplicationCall)
+private fun SECTIONS.finalSection(pagePath: String, canCommentAs: NationData?, comments: List<CommentRenderData>, totalsData: PageVisitTotals) {
+       section {
+               h2 {
+                       a { id = "comments" }
+                       +"Comments"
+               }
+               commentInput(pagePath, canCommentAs)
+               for (comment in comments)
+                       commentBox(comment, canCommentAs?.id)
+               
+               guestbook(totalsData)
+       }
+}