From: Lanius Trolling Date: Tue, 25 Jul 2023 16:41:10 +0000 (-0400) Subject: Record visits of factbooks X-Git-Url: https://gitweb.starshipfights.net/?a=commitdiff_plain;h=ea562151b05f356d113cb47553c3b61a62b818c5;p=factbooks Record visits of factbooks --- diff --git a/src/main/kotlin/info/mechyrdia/auth/csrf.kt b/src/main/kotlin/info/mechyrdia/auth/csrf.kt index 23c854d..cea7811 100644 --- a/src/main/kotlin/info/mechyrdia/auth/csrf.kt +++ b/src/main/kotlin/info/mechyrdia/auth/csrf.kt @@ -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 diff --git a/src/main/kotlin/info/mechyrdia/auth/views_login.kt b/src/main/kotlin/info/mechyrdia/auth/views_login.kt index 6595d49..cce96bf 100644 --- a/src/main/kotlin/info/mechyrdia/auth/views_login.kt +++ b/src/main/kotlin/info/mechyrdia/auth/views_login.kt @@ -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.") diff --git a/src/main/kotlin/info/mechyrdia/data/data.kt b/src/main/kotlin/info/mechyrdia/data/data.kt index 37835dd..c15c4dc 100644 --- a/src/main/kotlin/info/mechyrdia/data/data.kt +++ b/src/main/kotlin/info/mechyrdia/data/data.kt @@ -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> { val id: Id } -class DocumentTable>(val kClass: KClass) { +class DocumentTable>(private val kClass: KClass) { private suspend fun collection() = ConnectionHolder.getDatabase().database.getCollection(kClass.simpleName!!, kClass.java).coroutine suspend fun index(vararg properties: KProperty1) { @@ -175,8 +176,14 @@ class DocumentTable>(val kClass: KClass) { suspend fun remove(where: Bson) { collection().deleteMany(where) } + + suspend fun aggregate(pipeline: List, resultClass: KClass): CoroutineAggregatePublisher { + return collection().collection.aggregate(pipeline, resultClass.java).coroutine + } } +suspend inline fun , reified R : Any> DocumentTable.aggregate(pipeline: List) = aggregate(pipeline, R::class) + inline fun > DocumentTable() = DocumentTable(T::class) interface TableHolder> { @@ -190,7 +197,8 @@ interface TableHolder> { SessionStorageDoc, NationData, Comment, - CommentReplyLink + CommentReplyLink, + PageVisitData ) } } diff --git a/src/main/kotlin/info/mechyrdia/data/data_utils.kt b/src/main/kotlin/info/mechyrdia/data/data_utils.kt index 582dd03..6c799e2 100644 --- a/src/main/kotlin/info/mechyrdia/data/data_utils.kt +++ b/src/main/kotlin/info/mechyrdia/data/data_utils.kt @@ -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 > DocumentTable.getOrPut(id: Id, defaultValue: () -> T): T { val value = get(id) return if (value == null) { @@ -13,3 +21,25 @@ suspend inline fun > DocumentTable.getOrPut(id: Id, de value } } + +@Serializable +private data class MapAsListEntry(val key: K, val value: V) { + constructor(entry: Map.Entry) : this(entry.key, entry.value) + + fun toPair() = key to value +} + +@OptIn(ExperimentalSerializationApi::class) +class MapAsListSerializer(keySerializer: KSerializer, valueSerializer: KSerializer) : KSerializer> { + 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) { + inner.serialize(encoder, value.map { MapAsListEntry(it) }) + } + + override fun deserialize(decoder: Decoder): Map { + return inner.deserialize(decoder).associate { it.toPair() } + } +} diff --git a/src/main/kotlin/info/mechyrdia/data/nations.kt b/src/main/kotlin/info/mechyrdia/data/nations.kt index 2882a14..4b6cef1 100644 --- a/src/main/kotlin/info/mechyrdia/data/nations.kt +++ b/src/main/kotlin/info/mechyrdia/data/nations.kt @@ -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 index 0000000..6d581e6 --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/data/visits.kt @@ -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 = Id(), + + val path: String, + val visitor: String, + val visits: Long = 0L, + val lastVisit: @Contextual Instant = Instant.now() +) : DataDocument { + companion object : TableHolder { + override val Table = DocumentTable() + + 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) + } + } +} diff --git a/src/main/kotlin/info/mechyrdia/lore/views_lore.kt b/src/main/kotlin/info/mechyrdia/lore/views_lore.kt index 6ca8c9a..4e58a4e 100644 --- a/src/main/kotlin/info/mechyrdia/lore/views_lore.kt +++ b/src/main/kotlin/info/mechyrdia/lore/views_lore.kt @@ -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, totalsData: PageVisitTotals) { + section { + h2 { + a { id = "comments" } + +"Comments" + } + commentInput(pagePath, canCommentAs) + for (comment in comments) + commentBox(comment, canCommentAs?.id) + + guestbook(totalsData) + } +}