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
p {
style = "text-align:center"
button(classes = "view-checksum") {
- attributes["data-token"] = nsToken
+ attributes["data-token"] = "mechyrdia_$nsToken"
+"View Your Checksum"
}
}
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.")
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
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, *>) {
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>> {
SessionStorageDoc,
NationData,
Comment,
- CommentReplyLink
+ CommentReplyLink,
+ PageVisitData
)
}
}
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) {
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() }
+ }
+}
import io.ktor.util.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
-import java.lang.NullPointerException
@Serializable
data class NationData(
--- /dev/null
+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)
+ }
+ }
+}
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)
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()
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()
+"?"
}
}
- 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)
+ }
+}