Add authentication and commenting
authorLanius Trolling <lanius@laniustrolling.dev>
Mon, 13 Feb 2023 18:02:22 +0000 (13:02 -0500)
committerLanius Trolling <lanius@laniustrolling.dev>
Mon, 13 Feb 2023 18:02:22 +0000 (13:02 -0500)
32 files changed:
.idea/kotlinc.xml
.idea/misc.xml
build.gradle.kts
libs/nsapi4j.jar [new file with mode: 0644]
settings.gradle.kts
src/main/kotlin/info/mechyrdia/Configuration.kt
src/main/kotlin/info/mechyrdia/Factbooks.kt
src/main/kotlin/info/mechyrdia/JSON.kt
src/main/kotlin/info/mechyrdia/auth/csrf.kt [new file with mode: 0644]
src/main/kotlin/info/mechyrdia/auth/nationstates.kt [new file with mode: 0644]
src/main/kotlin/info/mechyrdia/auth/session_storage.kt [new file with mode: 0644]
src/main/kotlin/info/mechyrdia/auth/sessions.kt [new file with mode: 0644]
src/main/kotlin/info/mechyrdia/auth/views_login.kt [new file with mode: 0644]
src/main/kotlin/info/mechyrdia/data/comments.kt [new file with mode: 0644]
src/main/kotlin/info/mechyrdia/data/data.kt [new file with mode: 0644]
src/main/kotlin/info/mechyrdia/data/data_utils.kt [new file with mode: 0644]
src/main/kotlin/info/mechyrdia/data/nations.kt [new file with mode: 0644]
src/main/kotlin/info/mechyrdia/data/view_comments.kt [new file with mode: 0644]
src/main/kotlin/info/mechyrdia/data/views_comment.kt [new file with mode: 0644]
src/main/kotlin/info/mechyrdia/data/views_user.kt [new file with mode: 0644]
src/main/kotlin/info/mechyrdia/lore/html_utils.kt
src/main/kotlin/info/mechyrdia/lore/http_utils.kt
src/main/kotlin/info/mechyrdia/lore/parser.kt
src/main/kotlin/info/mechyrdia/lore/parser_tags.kt
src/main/kotlin/info/mechyrdia/lore/parser_toc.kt
src/main/kotlin/info/mechyrdia/lore/preparser.kt
src/main/kotlin/info/mechyrdia/lore/view_nav.kt
src/main/kotlin/info/mechyrdia/lore/views_error.kt
src/main/kotlin/info/mechyrdia/lore/views_lore.kt
src/main/kotlin/info/mechyrdia/lore/views_prefs.kt
src/main/resources/static/init.js
src/main/resources/static/style.css

index e1eea1d6b9d84faa7006e37a676609e51525b125..2b8a50fc21f55078d651117a958476d2397572e5 100644 (file)
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <project version="4">
   <component name="KotlinJpsPluginSettings">
-    <option name="version" value="1.7.20" />
+    <option name="version" value="1.8.0" />
   </component>
 </project>
\ No newline at end of file
index 993619701851bf548c9bd775cd4d7ee7cf386b5a..f6d0002b3e202df9a0e4ec3341c240d2d78cd569 100644 (file)
@@ -88,7 +88,7 @@
       <textMaps />
     </LinkMapSettings>
   </component>
-  <component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_14" default="true" project-jdk-name="14" project-jdk-type="JavaSDK">
     <output url="file://$PROJECT_DIR$/out" />
   </component>
   <component name="RustProjectSettings">
index ea7c65e9611ebc8a896c17f09888c2fe5aa94d69..f18047b5f754abeaab2e4f7baa9615841e9be74a 100644 (file)
@@ -2,8 +2,8 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
 
 plugins {
        java
-       kotlin("jvm") version "1.7.20"
-       kotlin("plugin.serialization") version "1.7.20"
+       kotlin("jvm") version "1.8.0"
+       kotlin("plugin.serialization") version "1.8.0"
        id("com.github.johnrengelman.shadow") version "7.1.2"
        application
 }
@@ -16,36 +16,64 @@ repositories {
 }
 
 dependencies {
-       implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
+       implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4")
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.4")
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactive:1.6.4")
-       implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.4.1")
-       implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1")
+       implementation("org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.4.1")
+       implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.4.1")
        
-       implementation("io.ktor:ktor-server-netty:2.2.1")
-       implementation("io.ktor:ktor-server-html-builder:2.2.1")
+       implementation("io.ktor:ktor-server-core-jvm:2.2.3")
+       implementation("io.ktor:ktor-server-netty-jvm:2.2.3")
        
-       implementation("io.ktor:ktor-server-call-id:2.2.1")
-       implementation("io.ktor:ktor-server-call-logging:2.2.1")
-       implementation("io.ktor:ktor-server-forwarded-header:2.2.1")
-       implementation("io.ktor:ktor-server-sessions-jvm:2.2.1")
-       implementation("io.ktor:ktor-server-status-pages:2.2.1")
+       implementation("io.ktor:ktor-server-call-id:2.2.3")
+       implementation("io.ktor:ktor-server-call-logging:2.2.3")
+       implementation("io.ktor:ktor-server-forwarded-header:2.2.3")
+       implementation("io.ktor:ktor-server-html-builder:2.2.3")
+       implementation("io.ktor:ktor-server-sessions-jvm:2.2.3")
+       implementation("io.ktor:ktor-server-status-pages:2.2.3")
        
        implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.8.0")
        
        implementation("com.samskivert:jmustache:1.15")
        
+       implementation(files("libs/nsapi4j.jar"))
+       
+       implementation("com.aventrix.jnanoid:jnanoid:2.0.0")
+       implementation("org.litote.kmongo:kmongo-coroutine-serialization:4.8.0") {
+               exclude("org.jetbrains.kotlin", "kotlin-reflect")
+               
+               exclude("org.jetbrains.kotlinx", "kotlinx-coroutines-jdk8")
+               exclude("org.jetbrains.kotlinx", "kotlinx-coroutines-reactive")
+               exclude("org.jetbrains.kotlinx", "kotlinx-serialization-core-jvm")
+       }
+       
        implementation("org.slf4j:slf4j-api:1.7.36")
-       implementation("ch.qos.logback:logback-classic:1.2.11")
+       implementation("ch.qos.logback:logback-classic:1.3.5")
+}
+
+java {
+       toolchain {
+               languageVersion.set(JavaLanguageVersion.of(14))
+       }
+}
+
+kotlin {
+       jvmToolchain(14)
 }
 
 tasks.withType<KotlinCompile> {
        kotlinOptions {
-               jvmTarget = "1.8"
-               freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn")
+               jvmTarget = "14"
+               freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
        }
 }
 
 application {
        mainClass.set("info.mechyrdia.Factbooks")
 }
+
+tasks.withType<JavaExec> {
+       javaLauncher.set(javaToolchains.launcherFor {
+               languageVersion.set(JavaLanguageVersion.of(14))
+       })
+}
diff --git a/libs/nsapi4j.jar b/libs/nsapi4j.jar
new file mode 100644 (file)
index 0000000..d15c45a
Binary files /dev/null and b/libs/nsapi4j.jar differ
index a30b383da619f0ea52bda9b980ee0ec24e61c408..7dab3e568a5992a1a37c1b7dc1844cd66375f459 100644 (file)
@@ -1,3 +1,2 @@
 
 rootProject.name = "factbooks"
-
index cc0be479df7f29a0644f25eb6992dd3c15085c23..cf389c86b4c7c196b9e5a8f67af180b416d97a7d 100644 (file)
@@ -1,5 +1,7 @@
 package info.mechyrdia
 
+import info.mechyrdia.data.Id
+import info.mechyrdia.data.NationData
 import kotlinx.serialization.Serializable
 import java.io.File
 
@@ -12,6 +14,9 @@ data class Configuration(
        val templateDir: String = "../tpl",
        val assetDir: String = "../assets",
        val jsonDocDir: String = "../data",
+       
+       val dbName: String = "nslore",
+       val dbConn: String = "mongodb://localhost:27017",
 ) {
        companion object {
                private val DEFAULT_CONFIG = Configuration()
@@ -27,13 +32,16 @@ data class Configuration(
                                        if (file.exists())
                                                file.deleteRecursively()
                                        
-                                       val json = JSON.encodeToString(serializer(), DEFAULT_CONFIG)
+                                       val json = JsonFileCodec.encodeToString(serializer(), DEFAULT_CONFIG)
                                        file.writeText(json, Charsets.UTF_8)
                                        return DEFAULT_CONFIG
                                }
                                
                                val json = file.readText()
-                               return JSON.decodeFromString(serializer(), json).also { currentConfig = it }
+                               return JsonFileCodec.decodeFromString(serializer(), json).also { currentConfig = it }
                        }
        }
 }
+
+const val OWNER_NATION = "mechyrdia"
+val OwnerNationId = Id<NationData>(OWNER_NATION)
index 19dff0e130f84085c5515d1fac5076dde85a8ea2..4faddf4958a3acd1b6ad3e3e8de0ddb3c4477f6c 100644 (file)
@@ -2,6 +2,8 @@
 
 package info.mechyrdia
 
+import info.mechyrdia.auth.*
+import info.mechyrdia.data.*
 import info.mechyrdia.lore.*
 import io.ktor.http.*
 import io.ktor.server.application.*
@@ -18,21 +20,30 @@ import io.ktor.server.request.*
 import io.ktor.server.response.*
 import io.ktor.server.routing.*
 import io.ktor.server.sessions.*
+import io.ktor.server.sessions.serialization.*
+import io.ktor.server.util.*
 import io.ktor.util.*
 import org.slf4j.event.Level
 import java.io.File
 import java.io.IOException
 import java.util.concurrent.atomic.AtomicLong
 
+lateinit var application: Application
+       private set
+
 fun main() {
        System.setProperty("logback.statusListenerClass", "ch.qos.logback.core.status.NopStatusListener")
        
        System.setProperty("io.ktor.development", "false")
        
+       ConnectionHolder.initialize(Configuration.CurrentConfiguration.dbConn, Configuration.CurrentConfiguration.dbName)
+       
        embeddedServer(Netty, port = Configuration.CurrentConfiguration.port, host = Configuration.CurrentConfiguration.host, module = Application::factbooks).start(wait = true)
 }
 
 fun Application.factbooks() {
+       application = this
+       
        install(IgnoreTrailingSlash)
        install(XForwardedHeaders)
        
@@ -53,16 +64,16 @@ fun Application.factbooks() {
                }
        }
        
-       /*
        install(Sessions) {
-               cookie<UserSession>("USER_SESSION", SessionStorageMongoDB()) {
+               cookie("USER_SESSION", SessionStorageMongoDB) {
                        identity { Id<UserSession>().id }
                        
+                       serializer = KotlinxSessionSerializer(UserSession.serializer(), JsonStorageCodec)
+                       
                        cookie.extensions["SameSite"] = "lax"
                        cookie.extensions["Secure"] = null
                }
        }
-       */
        
        install(StatusPages) {
                status(HttpStatusCode.NotFound) { call, _ ->
@@ -75,6 +86,12 @@ fun Application.factbooks() {
                exception<MissingRequestParameterException> { call, _ ->
                        call.respondHtml(HttpStatusCode.BadRequest, call.error400())
                }
+               exception<ForbiddenException> { call, _ ->
+                       call.respondHtml(HttpStatusCode.Forbidden, call.error403())
+               }
+               exception<CsrfFailedException> { call, _ ->
+                       call.respondHtml(HttpStatusCode.Forbidden, call.error403PageExpired())
+               }
                exception<NullPointerException> { call, _ ->
                        call.respondHtml(HttpStatusCode.NotFound, call.error404())
                }
@@ -95,6 +112,8 @@ fun Application.factbooks() {
                        redirect("/lore")
                }
                
+               // Factbooks and assets
+               
                static("/static") {
                        resources("static")
                }
@@ -107,22 +126,87 @@ fun Application.factbooks() {
                        files(File(Configuration.CurrentConfiguration.assetDir))
                }
                
+               // Client settings
+               
                get("/change-theme") {
                        call.respondHtml(HttpStatusCode.OK, call.changeThemePage())
                }
                
                post("/change-theme") {
-                       val newTheme = when (call.receiveParameters()["theme"]) {
-                               "light" -> "light"
-                               "dark" -> "dark"
-                               else -> "system"
-                       }
-                       call.response.cookies.append("factbook-theme", newTheme, maxAge = Int.MAX_VALUE.toLong())
-                       redirect("/lore")
+                       call.changeThemeRoute()
+               }
+               
+               // Authentication
+               
+               get("/auth/login") {
+                       call.respondHtml(HttpStatusCode.OK, call.loginPage())
+               }
+               
+               post("/auth/login") {
+                       call.loginRoute()
+               }
+               
+               post("/auth/logout") {
+                       call.logoutRoute()
+               }
+               
+               // Commenting
+               
+               get("/comment/help") {
+                       call.respondHtml(HttpStatusCode.OK, call.commentHelpPage())
+               }
+               
+               post("/comment/new/{path...}") {
+                       call.newCommentRoute()
+               }
+               
+               get("/comment/view/{id}") {
+                       call.viewCommentRoute()
+               }
+               
+               post("/comment/edit/{id}") {
+                       call.editCommentRoute()
+               }
+               
+               get("/comment/delete/{id}") {
+                       call.respondHtml(HttpStatusCode.OK, call.deleteCommentPage())
+               }
+               
+               post("/comment/delete/{id}") {
+                       call.deleteCommentRoute()
+               }
+               
+               // User pages
+               
+               get("/user/{id}") {
+                       call.respondHtml(HttpStatusCode.OK, call.userPage())
+               }
+               
+               // Administration
+               
+               post("/admin/ban/{id}") {
+                       call.adminBanUserRoute()
                }
                
+               post("/admin/unban/{id}") {
+                       call.adminUnbanUserRoute()
+               }
+               
+               // Utilities
+               
                post("/tylan-lang") {
                        call.respondText(TylanAlphabet.tylanToFontAlphabet(call.receiveText()))
                }
+               
+               post("/preview-comment") {
+                       val result = TextParserState.parseText(call.receiveText(), TextParserCommentTags.asTags, Unit)
+                       call.respondText(
+                               text = result.html,
+                               contentType = ContentType.Text.Html,
+                               status = if (result.succeeded)
+                                       HttpStatusCode.OK
+                               else HttpStatusCode.BadRequest
+                       )
+               }
        }
 }
index 75754b51096e0ca948273a23fc3bbd0b3dbbc4fc..d810cca355b941166d5fae5fdbde3865e0b2abc9 100644 (file)
@@ -3,10 +3,15 @@ package info.mechyrdia
 import kotlinx.serialization.ExperimentalSerializationApi
 import kotlinx.serialization.json.Json
 
-val JSON = Json {
+val JsonFileCodec = Json {
        prettyPrint = true
        @OptIn(ExperimentalSerializationApi::class)
        prettyPrintIndent = "\t"
        
        useAlternativeNames = false
 }
+
+val JsonStorageCodec = Json {
+       prettyPrint = false
+       useAlternativeNames = false
+}
diff --git a/src/main/kotlin/info/mechyrdia/auth/csrf.kt b/src/main/kotlin/info/mechyrdia/auth/csrf.kt
new file mode 100644 (file)
index 0000000..f8b130b
--- /dev/null
@@ -0,0 +1,55 @@
+package info.mechyrdia.auth
+
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.plugins.*
+import io.ktor.server.request.*
+import io.ktor.server.util.*
+import kotlinx.html.FORM
+import kotlinx.html.hiddenInput
+import java.time.Instant
+import java.util.concurrent.ConcurrentHashMap
+
+data class CsrfPayload(
+       val route: String,
+       val remoteAddress: String,
+       val userAgent: String?,
+       val expires: Instant = Instant.now().plusSeconds(3600)
+)
+
+fun ApplicationCall.csrfPayload(route: String, withExpiration: Instant = Instant.now().plusSeconds(3600)) =
+       CsrfPayload(
+               route = route,
+               remoteAddress = request.origin.remoteAddress,
+               userAgent = request.userAgent(),
+               expires = withExpiration
+       )
+
+private val csrfMap = ConcurrentHashMap<String, CsrfPayload>()
+
+fun ApplicationCall.createCsrfToken(route: String = request.origin.uri): String {
+       return token().also { csrfMap[it] = csrfPayload(route) }
+}
+
+fun FORM.installCsrfToken(token: String) {
+       hiddenInput {
+               name = "csrf-token"
+               value = token
+       }
+}
+
+suspend fun ApplicationCall.verifyCsrfToken(route: String = request.origin.uri): Parameters {
+       val params = receive<Parameters>()
+       val token = params.getOrFail("csrf-token")
+       
+       val check = csrfMap.remove(token) ?: throw CsrfFailedException("CSRF token does not exist")
+       val payload = csrfPayload(route, check.expires)
+       if (check != payload)
+               throw CsrfFailedException("CSRF token does not match")
+       if (payload.expires < Instant.now())
+               throw CsrfFailedException("CSRF token has expired")
+       
+       return params
+}
+
+class CsrfFailedException(override val message: String) : RuntimeException(message)
diff --git a/src/main/kotlin/info/mechyrdia/auth/nationstates.kt b/src/main/kotlin/info/mechyrdia/auth/nationstates.kt
new file mode 100644 (file)
index 0000000..ad37132
--- /dev/null
@@ -0,0 +1,26 @@
+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 kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+
+val NSAPI: NationStates = DefaultNationStatesImpl("Mechyrdia Factbooks <samfrancis00@gmail.com>")
+
+suspend fun <Q : APIQuery<Q, R>, R> Q.executeSuspend(): R? = runInterruptible(Dispatchers.IO) {
+       try {
+               execute()
+       } catch (ex: NationStatesResourceNotFoundException) {
+               null
+       }
+}
+
+fun String.toNationId() = replace(' ', '_').lowercase()
+
+private val tokenAlphabet = "ABCDEFGHILMNOPQRSTVXYZ0123456789".toCharArray()
+fun token(): String = NanoIdUtils.randomNanoId(NanoIdUtils.DEFAULT_NUMBER_GENERATOR, tokenAlphabet, 16)
+
+class ForbiddenException(override val message: String) : RuntimeException(message)
diff --git a/src/main/kotlin/info/mechyrdia/auth/session_storage.kt b/src/main/kotlin/info/mechyrdia/auth/session_storage.kt
new file mode 100644 (file)
index 0000000..8d3d4c5
--- /dev/null
@@ -0,0 +1,34 @@
+package info.mechyrdia.auth
+
+import info.mechyrdia.data.DataDocument
+import info.mechyrdia.data.DocumentTable
+import info.mechyrdia.data.Id
+import info.mechyrdia.data.TableHolder
+import io.ktor.server.sessions.*
+import kotlinx.serialization.Serializable
+
+object SessionStorageMongoDB : SessionStorage {
+       override suspend fun invalidate(id: String) {
+               SessionStorageDoc.Table.del(Id(id))
+       }
+       
+       override suspend fun read(id: String): String {
+               return SessionStorageDoc.Table.get(Id(id))?.session ?: throw NoSuchElementException("Session $id not found")
+       }
+       
+       override suspend fun write(id: String, value: String) {
+               SessionStorageDoc.Table.put(SessionStorageDoc(Id(id), value))
+       }
+}
+
+@Serializable
+data class SessionStorageDoc(
+       override val id: Id<SessionStorageDoc>,
+       val session: String
+) : DataDocument<SessionStorageDoc> {
+       companion object : TableHolder<SessionStorageDoc> {
+               override val Table = DocumentTable<SessionStorageDoc>()
+               
+               override suspend fun initialize() = Unit
+       }
+}
diff --git a/src/main/kotlin/info/mechyrdia/auth/sessions.kt b/src/main/kotlin/info/mechyrdia/auth/sessions.kt
new file mode 100644 (file)
index 0000000..f47b9c1
--- /dev/null
@@ -0,0 +1,10 @@
+package info.mechyrdia.auth
+
+import info.mechyrdia.data.Id
+import info.mechyrdia.data.NationData
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class UserSession(
+       val nationId: Id<NationData>,
+)
diff --git a/src/main/kotlin/info/mechyrdia/auth/views_login.kt b/src/main/kotlin/info/mechyrdia/auth/views_login.kt
new file mode 100644 (file)
index 0000000..a857944
--- /dev/null
@@ -0,0 +1,95 @@
+package info.mechyrdia.auth
+
+import com.github.agadar.nationstates.shard.NationShard
+import info.mechyrdia.data.Id
+import info.mechyrdia.data.NationData
+import info.mechyrdia.lore.page
+import info.mechyrdia.lore.redirect
+import info.mechyrdia.lore.redirectWithError
+import info.mechyrdia.lore.standardNavBar
+import io.ktor.server.application.*
+import io.ktor.server.plugins.*
+import io.ktor.server.sessions.*
+import io.ktor.server.util.*
+import kotlinx.html.*
+import java.util.concurrent.ConcurrentHashMap
+
+private val nsTokenMap = ConcurrentHashMap<String, String>()
+
+suspend fun ApplicationCall.loginPage(): HTML.() -> Unit = page("Log In With NationStates", standardNavBar()) {
+       val tokenKey = token()
+       val nsToken = token()
+       
+       nsTokenMap[tokenKey] = nsToken
+       
+       section {
+               h1 { +"Log In With NationStates" }
+               form(method = FormMethod.post, action = "/auth/login") {
+                       installCsrfToken(createCsrfToken())
+                       
+                       hiddenInput {
+                               name = "token"
+                               value = tokenKey
+                       }
+                       
+                       label {
+                               +"Nation Name"
+                               br
+                               textInput {
+                                       name = "nation"
+                                       placeholder = "Name of your nation without pretitle, e.g. Mechyrdia, Valentine Z, Reinkalistan, etc."
+                               }
+                       }
+                       p {
+                               style = "text-align:center"
+                               button(classes = "view-checksum") {
+                                       attributes["data-token"] = nsToken
+                                       +"View Your Checksum"
+                               }
+                       }
+                       label {
+                               +"Verification Checksum"
+                               br
+                               textInput {
+                                       name = "checksum"
+                                       placeholder = "The random text checksum generated by NationStates for verification"
+                               }
+                       }
+                       submitInput { value = "Log In" }
+               }
+       }
+}
+
+suspend fun ApplicationCall.loginRoute(): Nothing {
+       val postParams = verifyCsrfToken()
+       
+       val nation = postParams.getOrFail("nation").toNationId()
+       val checksum = postParams.getOrFail("checksum")
+       val token = nsTokenMap[postParams.getOrFail("token")]
+               ?: throw MissingRequestParameterException("token")
+       
+       val result = NSAPI
+               .verifyAndGetNation(nation, checksum)
+               .token(token)
+               .shards(NationShard.NAME, NationShard.FLAG_URL)
+               .executeSuspend()
+               ?: redirectWithError("/auth/login", "That nation does not exist.")
+       
+       if (!result.isVerified)
+               redirectWithError("/auth/login", "Checksum failed verification.")
+       
+       val nationData = NationData(Id(result.id), result.name, result.flagUrl)
+       NationData.Table.put(nationData)
+       
+       sessions.set(UserSession(nationData.id))
+       
+       redirect("/")
+}
+
+suspend fun ApplicationCall.logoutRoute(): Nothing {
+       verifyCsrfToken()
+       
+       sessions.clear<UserSession>()
+       
+       redirect("/")
+}
diff --git a/src/main/kotlin/info/mechyrdia/data/comments.kt b/src/main/kotlin/info/mechyrdia/data/comments.kt
new file mode 100644 (file)
index 0000000..905cc30
--- /dev/null
@@ -0,0 +1,39 @@
+package info.mechyrdia.data
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.Serializable
+import org.litote.kmongo.descending
+import org.litote.kmongo.eq
+import java.time.Instant
+
+@Serializable
+data class Comment(
+       override val id: Id<Comment>,
+       
+       val submittedBy: Id<NationData>,
+       val submittedIn: String,
+       val submittedAt: @Contextual Instant,
+       
+       val numEdits: Int,
+       val lastEdit: @Contextual Instant?,
+       
+       val contents: String
+) : DataDocument<Comment> {
+       companion object : TableHolder<Comment> {
+               override val Table: DocumentTable<Comment> = DocumentTable()
+               
+               override suspend fun initialize() {
+                       Table.index(Comment::submittedBy, Comment::submittedAt)
+                       Table.index(Comment::submittedIn, Comment::submittedAt)
+               }
+               
+               suspend fun getCommentsIn(page: String): Flow<Comment> {
+                       return Table.select(Comment::submittedIn eq page, descending(Comment::submittedAt))
+               }
+               
+               suspend fun getCommentsBy(user: Id<NationData>): Flow<Comment> {
+                       return Table.select(Comment::submittedBy eq user, descending(Comment::submittedAt))
+               }
+       }
+}
diff --git a/src/main/kotlin/info/mechyrdia/data/data.kt b/src/main/kotlin/info/mechyrdia/data/data.kt
new file mode 100644 (file)
index 0000000..ffc0d4d
--- /dev/null
@@ -0,0 +1,199 @@
+package info.mechyrdia.data
+
+import com.aventrix.jnanoid.jnanoid.NanoIdUtils
+import com.mongodb.client.model.BulkWriteOptions
+import com.mongodb.client.model.IndexOptions
+import com.mongodb.client.model.ReplaceOptions
+import info.mechyrdia.auth.SessionStorageDoc
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.builtins.serializer
+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.CoroutineClient
+import org.litote.kmongo.coroutine.coroutine
+import org.litote.kmongo.reactivestreams.KMongo
+import org.litote.kmongo.replaceOne
+import org.litote.kmongo.serialization.IdController
+import org.litote.kmongo.serialization.changeIdController
+import org.litote.kmongo.serialization.registerSerializer
+import org.litote.kmongo.util.KMongoUtil
+import java.security.SecureRandom
+import kotlin.reflect.KClass
+import kotlin.reflect.KProperty1
+
+@Serializable(IdSerializer::class)
+@JvmInline
+value class Id<T>(val id: String) {
+       override fun toString() = id
+       
+       companion object {
+               fun serializer(): KSerializer<Id<*>> = IdSerializer
+       }
+}
+
+fun <T, U> Id<T>.reinterpret() = Id<U>(id)
+
+private val secureRandom = SecureRandom.getInstanceStrong()
+private val alphabet = "ABCDEFGHILMNOPQRSTVXYZ0123456789".toCharArray()
+fun <T> Id() = Id<T>(NanoIdUtils.randomNanoId(secureRandom, alphabet, 24))
+
+object IdSerializer : KSerializer<Id<*>> {
+       private val inner = String.serializer()
+       
+       override val descriptor: SerialDescriptor
+               get() = inner.descriptor
+       
+       override fun serialize(encoder: Encoder, value: Id<*>) {
+               inner.serialize(encoder, value.id)
+       }
+       
+       override fun deserialize(decoder: Decoder): Id<*> {
+               return Id<Any>(inner.deserialize(decoder))
+       }
+}
+
+object DocumentIdController : IdController {
+       override fun findIdProperty(type: KClass<*>): KProperty1<*, *> {
+               return DataDocument<*>::id
+       }
+       
+       @Suppress("UNCHECKED_CAST")
+       override fun <T, R> getIdValue(idProperty: KProperty1<T, R>, instance: T): R? {
+               return (instance as DataDocument<*>).id as R
+       }
+       
+       override fun <T, R> setIdValue(idProperty: KProperty1<T, R>, instance: T) {
+               throw UnsupportedOperationException("Cannot set id property of DataDocument<T>!")
+       }
+}
+
+object ConnectionHolder {
+       private lateinit var databaseName: String
+       
+       private val clientDeferred = CompletableDeferred<CoroutineClient>()
+       
+       suspend fun getDatabase() = clientDeferred.await().getDatabase(databaseName)
+       
+       fun initialize(conn: String, db: String) {
+               if (clientDeferred.isCompleted)
+                       error("Cannot initialize database twice!")
+               
+               changeIdController(DocumentIdController)
+               registerSerializer(IdSerializer)
+               
+               databaseName = db
+               clientDeferred.complete(KMongo.createClient(conn).coroutine)
+               
+               runBlocking {
+                       for (holder in TableHolder.entries)
+                               holder.initialize()
+               }
+       }
+}
+
+interface DataDocument<T : DataDocument<T>> {
+       @SerialName("_id")
+       val id: Id<T>
+}
+
+class DocumentTable<T : DataDocument<T>>(val kclass: KClass<T>) {
+       private suspend fun collection() = ConnectionHolder.getDatabase().database.getCollection(kclass.simpleName!!, kclass.java).coroutine
+       
+       suspend fun index(vararg properties: KProperty1<T, *>) {
+               collection().ensureIndex(*properties)
+       }
+       
+       suspend fun unique(vararg properties: KProperty1<T, *>) {
+               collection().ensureUniqueIndex(*properties)
+       }
+       
+       suspend fun indexIf(condition: Bson, vararg properties: KProperty1<T, *>) {
+               collection().ensureIndex(*properties, indexOptions = IndexOptions().partialFilterExpression(condition))
+       }
+       
+       suspend fun uniqueIf(condition: Bson, vararg properties: KProperty1<T, *>) {
+               collection().ensureUniqueIndex(*properties, indexOptions = IndexOptions().partialFilterExpression(condition))
+       }
+       
+       suspend fun put(doc: T) {
+               collection().replaceOneById(doc.id, doc, ReplaceOptions().upsert(true))
+       }
+       
+       suspend fun put(docs: Iterable<T>) {
+               if (docs.any())
+                       collection().bulkWrite(
+                               docs.map { doc ->
+                                       replaceOne(KMongoUtil.idFilterQuery(doc.id), doc, ReplaceOptions().upsert(true))
+                               },
+                               BulkWriteOptions().ordered(false)
+                       )
+       }
+       
+       suspend fun set(id: Id<T>, set: Bson): Boolean {
+               return collection().updateOneById(id, set).matchedCount != 0L
+       }
+       
+       suspend fun get(id: Id<T>): T? {
+               return collection().findOneById(id)
+       }
+       
+       suspend fun del(id: Id<T>) {
+               collection().deleteOneById(id)
+       }
+       
+       suspend fun all(): Flow<T> {
+               return collection().find().toFlow()
+       }
+       
+       suspend fun filter(where: Bson): Flow<T> {
+               return collection().find(where).toFlow()
+       }
+       
+       suspend fun sorted(order: Bson): Flow<T> {
+               return collection().find().sort(order).toFlow()
+       }
+       
+       suspend fun select(where: Bson, order: Bson): Flow<T> {
+               return collection().find(where).sort(order).toFlow()
+       }
+       
+       suspend fun number(where: Bson): Long {
+               return collection().countDocuments(where)
+       }
+       
+       suspend fun locate(where: Bson): T? {
+               return collection().findOne(where)
+       }
+       
+       suspend fun update(where: Bson, set: Bson) {
+               collection().updateMany(where, set)
+       }
+       
+       suspend fun remove(where: Bson) {
+               collection().deleteMany(where)
+       }
+}
+
+inline fun <reified T : DataDocument<T>> DocumentTable() = DocumentTable(T::class)
+
+interface TableHolder<T : DataDocument<T>> {
+       @Suppress("PropertyName")
+       val Table: DocumentTable<T>
+       
+       suspend fun initialize()
+       
+       companion object {
+               val entries = listOf(
+                       SessionStorageDoc,
+                       NationData,
+                       Comment
+               )
+       }
+}
diff --git a/src/main/kotlin/info/mechyrdia/data/data_utils.kt b/src/main/kotlin/info/mechyrdia/data/data_utils.kt
new file mode 100644 (file)
index 0000000..582dd03
--- /dev/null
@@ -0,0 +1,15 @@
+package info.mechyrdia.data
+
+suspend inline fun <T : DataDocument<T>> DocumentTable<T>.getOrPut(id: Id<T>, defaultValue: () -> T): T {
+       val value = get(id)
+       return if (value == null) {
+               val answer = defaultValue()
+               if (answer.id != id) {
+                       throw IllegalArgumentException("Default value $answer has different Id than provided: $id")
+               }
+               put(answer)
+               answer
+       } else {
+               value
+       }
+}
diff --git a/src/main/kotlin/info/mechyrdia/data/nations.kt b/src/main/kotlin/info/mechyrdia/data/nations.kt
new file mode 100644 (file)
index 0000000..5fd9bdb
--- /dev/null
@@ -0,0 +1,59 @@
+package info.mechyrdia.data
+
+import com.github.agadar.nationstates.shard.NationShard
+import info.mechyrdia.application
+import info.mechyrdia.auth.NSAPI
+import info.mechyrdia.auth.UserSession
+import info.mechyrdia.auth.executeSuspend
+import io.ktor.server.application.*
+import io.ktor.server.sessions.*
+import io.ktor.util.*
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class NationData(
+       override val id: Id<NationData>,
+       val name: String,
+       val flag: String,
+       
+       val isBanned: Boolean = false
+) : DataDocument<NationData> {
+       companion object : TableHolder<NationData> {
+               override val Table = DocumentTable<NationData>()
+               
+               override suspend fun initialize() {
+                       Table.index(NationData::name)
+               }
+               
+               fun unknown(id: Id<NationData>): NationData {
+                       application.log.warn("Unable to find nation with Id $id - did it CTE?")
+                       return NationData(id, "Unknown Nation", "https://www.nationstates.net/images/flags/exnation.png")
+               }
+               
+               suspend fun get(id: Id<NationData>): NationData = Table.getOrPut(id) {
+                       NSAPI
+                               .getNation(id.id)
+                               .shards(NationShard.NAME, NationShard.FLAG_URL)
+                               .executeSuspend()
+                               ?.let {
+                                       NationData(id = Id(it.id), name = it.name, flag = it.flagUrl)
+                               } ?: unknown(id)
+               }
+       }
+}
+
+suspend fun MutableMap<Id<NationData>, NationData>.getNation(id: Id<NationData>): NationData {
+       return getOrPut(id) {
+               NationData.get(id)
+       }
+}
+
+val CallCurrentNationAttribute = AttributeKey<NationData>("CurrentNation")
+
+suspend fun ApplicationCall.currentNation(): NationData? {
+       attributes.getOrNull(CallCurrentNationAttribute)?.let { return it }
+       
+       return sessions.get<UserSession>()?.nationId?.let { id ->
+               NationData.get(id)
+       }?.also { attributes.put(CallCurrentNationAttribute, it) }
+}
diff --git a/src/main/kotlin/info/mechyrdia/data/view_comments.kt b/src/main/kotlin/info/mechyrdia/data/view_comments.kt
new file mode 100644 (file)
index 0000000..78c7044
--- /dev/null
@@ -0,0 +1,160 @@
+package info.mechyrdia.data
+
+import info.mechyrdia.OwnerNationId
+import info.mechyrdia.auth.createCsrfToken
+import info.mechyrdia.auth.installCsrfToken
+import info.mechyrdia.lore.TextParserCommentTags
+import info.mechyrdia.lore.TextParserState
+import info.mechyrdia.lore.dateTime
+import io.ktor.server.application.*
+import kotlinx.html.*
+import java.time.Instant
+
+data class CommentRenderData(
+       val id: Id<Comment>,
+       
+       val submittedBy: NationData,
+       val submittedIn: String,
+       val submittedAt: Instant,
+       
+       val numEdits: Int,
+       val lastEdit: Instant?,
+       
+       val contentsRaw: String,
+       val contentsHtml: String
+) {
+       companion object {
+               suspend operator fun invoke(comments: List<Comment>, nations: MutableMap<Id<NationData>, NationData> = mutableMapOf()): List<CommentRenderData> {
+                       return comments.mapNotNull { comment ->
+                               val nationData = nations.getNation(comment.submittedBy)
+                               val htmlResult = TextParserState.parseText(comment.contents, TextParserCommentTags.asTags, Unit)
+                               
+                               if (htmlResult.succeeded)
+                                       CommentRenderData(
+                                               comment.id,
+                                               nationData,
+                                               comment.submittedIn,
+                                               comment.submittedAt,
+                                               comment.numEdits,
+                                               comment.lastEdit,
+                                               comment.contents,
+                                               htmlResult.html
+                                       )
+                               else null
+                       }
+               }
+       }
+}
+
+context(ApplicationCall)
+fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData>?, viewingUserPage: Boolean = false) {
+       if (comment.submittedBy.isBanned && !viewingUserPage && loggedInAs != comment.submittedBy.id && loggedInAs != OwnerNationId)
+               return
+       
+       if (viewingUserPage)
+               p {
+                       style = "font-size:1.5em;margin-top:2.5em"
+                       +"On factbook "
+                       a(href = "/lore/${comment.submittedIn}") {
+                               +"/${comment.submittedIn}"
+                       }
+               }
+       
+       div(classes = "comment-box") {
+               id = "comment-${comment.id}"
+               div(classes = "comment-author") {
+                       img(src = comment.submittedBy.flag, alt = "Flag of ${comment.submittedBy.name}", classes = "flag-icon")
+                       span(classes = "author-name") {
+                               +Entities.nbsp
+                               a(href = "/user/${comment.submittedBy.id}") {
+                                       +comment.submittedBy.name
+                               }
+                       }
+                       span(classes = "posted-at") {
+                               dateTime(comment.submittedAt)
+                       }
+               }
+               
+               div(classes = "comment") {
+                       unsafe { raw(comment.contentsHtml) }
+                       comment.lastEdit?.let { lastEdit ->
+                               p {
+                                       style = "font-size:0.8em"
+                                       +"Edited ${comment.numEdits} times, last edited at "
+                                       dateTime(lastEdit)
+                               }
+                       }
+                       p {
+                               style = "font-size:0.8em"
+                               a(href = "/comment/view/${comment.id}") {
+                                       +"Link"
+                               }
+                               
+                               +Entities.nbsp
+                               +"\u2022"
+                               +Entities.nbsp
+                               a(href = "#", classes = "copy-text") {
+                                       attributes["data-text"] = "[reply]${comment.id}[/reply]"
+                                       +"Reply"
+                               }
+                               
+                               if (loggedInAs == comment.submittedBy.id) {
+                                       +Entities.nbsp
+                                       +"\u2022"
+                                       +Entities.nbsp
+                                       a(href = "#", classes = "comment-edit-link") {
+                                               attributes["data-edit-id"] = "comment-edit-box-${comment.id}"
+                                               +"Edit"
+                                       }
+                               }
+                               
+                               if (loggedInAs == comment.submittedBy.id || loggedInAs == OwnerNationId) {
+                                       +Entities.nbsp
+                                       +"\u2022"
+                                       +Entities.nbsp
+                                       a(href = "/comment/delete/${comment.id}", classes = "comment-delete-link") {
+                                               +"Delete"
+                                       }
+                               }
+                       }
+               }
+       }
+       
+       if (loggedInAs == comment.submittedBy.id) {
+               val formPath = "/comment/edit/${comment.id}"
+               form(action = formPath, method = FormMethod.post, classes = "comment-input comment-edit-box") {
+                       id = "comment-edit-box-${comment.id}"
+                       div(classes = "comment-preview")
+                       textArea(classes = "comment-markup") {
+                               name = "comment"
+                               +comment.contentsRaw
+                       }
+                       installCsrfToken(createCsrfToken(formPath))
+                       submitInput { value = "Edit Comment" }
+                       button(classes = "comment-cancel-edit") {
+                               +"Cancel Editing"
+                       }
+               }
+       }
+}
+
+context(ApplicationCall)
+fun FlowContent.commentInput(commentingOn: String, commentingAs: NationData?) {
+       if (commentingAs == null) {
+               p {
+                       a(href = "/auth/login") { +"Log in" }
+                       +" to comment"
+               }
+               return
+       }
+       
+       val formPath = "/comment/new/$commentingOn"
+       form(action = formPath, method = FormMethod.post, classes = "comment-input") {
+               div(classes = "comment-preview")
+               textArea(classes = "comment-markup") {
+                       name = "comment"
+               }
+               installCsrfToken(createCsrfToken(formPath))
+               submitInput { value = "Submit Comment" }
+       }
+}
diff --git a/src/main/kotlin/info/mechyrdia/data/views_comment.kt b/src/main/kotlin/info/mechyrdia/data/views_comment.kt
new file mode 100644 (file)
index 0000000..9f0762d
--- /dev/null
@@ -0,0 +1,408 @@
+package info.mechyrdia.data
+
+import info.mechyrdia.OwnerNationId
+import info.mechyrdia.auth.ForbiddenException
+import info.mechyrdia.auth.createCsrfToken
+import info.mechyrdia.auth.installCsrfToken
+import info.mechyrdia.auth.verifyCsrfToken
+import info.mechyrdia.lore.*
+import io.ktor.server.application.*
+import io.ktor.server.util.*
+import kotlinx.html.*
+import java.time.Instant
+
+suspend fun ApplicationCall.newCommentRoute(): Nothing {
+       val pagePathParts = parameters.getAll("path")!!
+       val pagePath = pagePathParts.joinToString("/")
+       
+       val formParams = verifyCsrfToken()
+       val loggedInAs = currentNation() ?: redirectWithError("/auth/login", "You must be logged in to write comments")
+       
+       val contents = formParams.getOrFail("comment")
+       
+       val comment = Comment(
+               id = Id(),
+               submittedBy = loggedInAs.id,
+               submittedIn = pagePath,
+               submittedAt = Instant.now(),
+               
+               numEdits = 0,
+               lastEdit = null,
+               
+               contents = contents
+       )
+       
+       Comment.Table.put(comment)
+       
+       redirect("/lore/$pagePath#comment-${comment.id}")
+}
+
+suspend fun ApplicationCall.viewCommentRoute(): Nothing {
+       val commentId = Id<Comment>(parameters["id"]!!)
+       
+       val comment = Comment.Table.get(commentId)!!
+       
+       redirect("/lore/${comment.submittedIn}#comment-$commentId")
+}
+
+suspend fun ApplicationCall.editCommentRoute(): Nothing {
+       val commentId = Id<Comment>(parameters["id"]!!)
+       
+       val oldComment = Comment.Table.get(commentId)!!
+       
+       val formParams = verifyCsrfToken()
+       val currNation = currentNation() ?: redirectWithError("/auth/login", "You must be logged in to edit comments")
+       
+       if (currNation.id != oldComment.submittedBy)
+               throw ForbiddenException("Illegal attempt by ${currNation.id} to edit comment by ${oldComment.submittedBy}")
+       
+       val newContents = formParams.getOrFail("comment")
+       
+       val newComment = oldComment.copy(
+               numEdits = oldComment.numEdits + 1,
+               lastEdit = Instant.now(),
+               contents = newContents
+       )
+       
+       Comment.Table.put(newComment)
+       
+       redirect("/comment/view/$commentId")
+}
+
+suspend fun ApplicationCall.deleteCommentPage(): HTML.() -> Unit {
+       val currNation = currentNation() ?: redirectWithError("/auth/login", "You must be logged in to delete comments")
+       
+       val commentId = Id<Comment>(parameters["id"]!!)
+       val comment = Comment.Table.get(commentId)!!
+       
+       if (currNation.id != comment.submittedBy && currNation.id != OwnerNationId)
+               throw ForbiddenException("Illegal attempt by ${currNation.id} to delete comment by ${comment.submittedBy}")
+       
+       val commentDisplay = CommentRenderData(listOf(comment), mutableMapOf(currNation.id to currNation)).single()
+       
+       return page("Confirm Deletion of Commment", standardNavBar()) {
+               section {
+                       p {
+                               +"Are you sure you want to delete this comment? "
+                               strong { +"It will be gone forever!" }
+                       }
+                       
+                       commentBox(commentDisplay, null)
+                       
+                       form(method = FormMethod.get, action = "/comment/view/$commentId") {
+                               submitInput { value = "No, take me back" }
+                       }
+                       form(method = FormMethod.post, action = "/comment/delete/$commentId") {
+                               installCsrfToken(createCsrfToken())
+                               submitInput(classes = "evil") { value = "Yes, delete it" }
+                       }
+               }
+       }
+}
+
+suspend fun ApplicationCall.deleteCommentRoute(): Nothing {
+       val currNation = currentNation() ?: redirectWithError("/auth/login", "You must be logged in to delete comments")
+       
+       val commentId = Id<Comment>(parameters["id"]!!)
+       val comment = Comment.Table.get(commentId)!!
+       
+       if (currNation.id != comment.submittedBy && currNation.id != OwnerNationId)
+               throw ForbiddenException("Illegal attempt by ${currNation.id} to delete comment by ${comment.submittedBy}")
+       
+       Comment.Table.del(commentId)
+       
+       redirect("/lore/${comment.submittedIn}")
+}
+
+suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commenting Help", standardNavBar()) {
+       section {
+               h1 { +"Commenting Help" }
+               p { +"Comments on this repository support a subset of the BBCode used in factbook markup." }
+               p { +"The following tags are supported:" }
+               table {
+                       thead {
+                               tr {
+                                       th { +"Tag" }
+                                       th { +"Purpose" }
+                               }
+                       }
+                       tbody {
+                               tr {
+                                       td { +"[b]Text goes here[/b]" }
+                                       td {
+                                               span {
+                                                       style = "font-weight:bold"
+                                                       +"Emboldens"
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[i]Text goes here[/i]" }
+                                       td {
+                                               span {
+                                                       style = "font-style:italic"
+                                                       +"Italicizes"
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[u]Text goes here[/u]" }
+                                       td {
+                                               span {
+                                                       style = "text-decoration: underline"
+                                                       +"Underlines"
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[s]Text goes here[/s]" }
+                                       td {
+                                               span {
+                                                       style = "text-decoration: line-through"
+                                                       +"Strikes out"
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[sup]Text goes here[/sup]" }
+                                       td {
+                                               sup {
+                                                       +"Superscripts"
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[sub]Text goes here[/sub]" }
+                                       td {
+                                               sub {
+                                                       +"Subscripts"
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[color=#CC8844]Text goes here[/sub]" }
+                                       td {
+                                               span {
+                                                       style = "color:#CC8844"
+                                                       +"Colors"
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[ipa]Text goes here[/ipa]" }
+                                       td {
+                                               span {
+                                                       style = "font-family:serif"
+                                                       +"Applies IPA font to "
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[code]Text goes here[/code]" }
+                                       td {
+                                               span {
+                                                       style = "font-family:monospace"
+                                                       +"Applies code font to "
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[align=(left, center, right, or justify)]Text goes here[/align]" }
+                                       td {
+                                               +"Aligns text on the left, center, right, or justified"
+                                       }
+                               }
+                               tr {
+                                       td { +"e.g. [align=center]Text goes here[/align]" }
+                                       td {
+                                               div {
+                                                       style = "text-align: center"
+                                                       +"Center-aligns text"
+                                               }
+                                       }
+                               }
+                               tr {
+                                       td { +"[aside=(left or right)]Text goes here[/aside]" }
+                                       td {
+                                               +"Creates a floating block to the side, on either the left or the right"
+                                       }
+                               }
+                               tr {
+                                       td { +"[ul][li]List items go here[/li]... [/ul]" }
+                                       td {
+                                               +"Creates a bullet list, e.g."
+                                               ul {
+                                                       li { +"Item" }
+                                                       li { +"The cooler item" }
+                                               }
+                                       }
+                               }
+                               tr {
+                                       td { +"[ol][li]List items go here[/li]... [/ol]" }
+                                       td {
+                                               +"Creates a numbered list, e.g."
+                                               ol {
+                                                       li { +"Item" }
+                                                       li { +"Another item" }
+                                               }
+                                       }
+                               }
+                               tr {
+                                       td { +"[table](table rows go here...)[/table]" }
+                                       td {
+                                               +"The root element of a table"
+                                       }
+                               }
+                               tr {
+                                       td { +"[tr](table cells go here...)[/tr]" }
+                                       td {
+                                               +"A row of a table"
+                                       }
+                               }
+                               tr {
+                                       td { +"[th]Text goes here[/th]" }
+                                       td {
+                                               +"A heading cell of a table"
+                                       }
+                               }
+                               tr {
+                                       td { +"[td]Text goes here[/td]" }
+                                       td {
+                                               +"A data cell of a table"
+                                       }
+                               }
+                       }
+               }
+               val tableDemoMarkup =
+                       """
+                               |[table]
+                               |[tr]
+                               |[th=2x2][i]ab[/i][sup]-1[/sup] mod 10[/th]
+                               |[th=10][i]a[/i][/th]
+                               |[/tr]
+                               |[tr]
+                               |[th]0[/th]
+                               |[th]1[/th]
+                               |[th]2[/th]
+                               |[th]3[/th]
+                               |[th]4[/th]
+                               |[th]5[/th]
+                               |[th]6[/th]
+                               |[th]7[/th]
+                               |[th]8[/th]
+                               |[th]9[/th]
+                               |[/tr]
+                               |[tr]
+                               |[th=x4][i]b[/i][/th]
+                               |[th]1[/th]
+                               |[td]0[/td]
+                               |[td]1[/td]
+                               |[td]2[/td]
+                               |[td]3[/td]
+                               |[td]4[/td]
+                               |[td]5[/td]
+                               |[td]6[/td]
+                               |[td]7[/td]
+                               |[td]8[/td]
+                               |[td]9[/td]
+                               |[/tr]
+                               |[tr]
+                               |[th]3[/th]
+                               |[td]0[/td]
+                               |[td]7[/td]
+                               |[td]4[/td]
+                               |[td]1[/td]
+                               |[td]8[/td]
+                               |[td]5[/td]
+                               |[td]2[/td]
+                               |[td]9[/td]
+                               |[td]6[/td]
+                               |[td]3[/td]
+                               |[/tr]
+                               |[tr]
+                               |[th]7[/th]
+                               |[td]0[/td]
+                               |[td]3[/td]
+                               |[td]6[/td]
+                               |[td]9[/td]
+                               |[td]2[/td]
+                               |[td]5[/td]
+                               |[td]8[/td]
+                               |[td]1[/td]
+                               |[td]4[/td]
+                               |[td]7[/td]
+                               |[/tr]
+                               |[tr]
+                               |[th]9[/th]
+                               |[td]0[/td]
+                               |[td]9[/td]
+                               |[td]8[/td]
+                               |[td]7[/td]
+                               |[td]6[/td]
+                               |[td]5[/td]
+                               |[td]4[/td]
+                               |[td]3[/td]
+                               |[td]2[/td]
+                               |[td]1[/td]
+                               |[/tr]
+                               |[/table]
+                       """.trimMargin()
+               val tableDemoHtml = TextParserState.parseText(tableDemoMarkup, TextParserCommentTags.asTags, Unit).html
+               p {
+                       +"Table cells in this custom BBCode markup also support row-spans and column-spans, even at the same time:"
+               }
+               pre { +tableDemoMarkup }
+               unsafe { raw(tableDemoHtml) }
+               p {
+                       +"The format goes as [td=(width)x(height)]. If one is omitted, then the format can be [td=(width)] or [td=x(height)]"
+               }
+               table {
+                       thead {
+                               tr {
+                                       th { +"Tag" }
+                                       th { +"Purpose" }
+                               }
+                       }
+                       tbody {
+                               tr {
+                                       td { +"[url=https://google.com/]Text goes here[/url]" }
+                                       td {
+                                               +"Creates an "
+                                               a(href = "https://google.com/") { +"HTML link" }
+                                       }
+                               }
+                               tr {
+                                       td { +"[reply](comment id)[/reply]" }
+                                       td { +"Creates a reply link to a comment" }
+                               }
+                               tr {
+                                       td { +"[lang=tylan]Rheagda Tulasra[/lang]" }
+                                       td {
+                                               +"Writes text in the Tylan alphabet: "
+                                               span(classes = "lang-tylan") {
+                                                       +TylanAlphabet.tylanToFontAlphabet("rheagda tulasra")
+                                               }
+                                       }
+                               }
+                               tr {
+                                       td { +"[lang=gothic]Gutiska Razda[/lang]" }
+                                       td {
+                                               +"Writes text in the Gothic alphabet: "
+                                               span(classes = "lang-gothic") {
+                                                       +"\uD800\uDF32\uD800\uDF3F\uD800\uDF44\uD800\uDF39\uD800\uDF43\uD800\uDF3A\uD800\uDF30 \uD800\uDF42\uD800\uDF30\uD800\uDF36\uD800\uDF33\uD800\uDF30"
+                                               }
+                                       }
+                               }
+                       }
+               }
+       }
+}
diff --git a/src/main/kotlin/info/mechyrdia/data/views_user.kt b/src/main/kotlin/info/mechyrdia/data/views_user.kt
new file mode 100644 (file)
index 0000000..6bdf218
--- /dev/null
@@ -0,0 +1,74 @@
+package info.mechyrdia.data
+
+import info.mechyrdia.OwnerNationId
+import info.mechyrdia.auth.createCsrfToken
+import info.mechyrdia.auth.verifyCsrfToken
+import info.mechyrdia.lore.page
+import info.mechyrdia.lore.redirect
+import info.mechyrdia.lore.standardNavBar
+import io.ktor.server.application.*
+import kotlinx.coroutines.flow.toList
+import kotlinx.html.*
+import org.litote.kmongo.setValue
+
+suspend fun ApplicationCall.userPage(): HTML.() -> Unit {
+       val currNation = currentNation()
+       val viewingNation = NationData.get(Id(parameters["id"]!!))
+       
+       val comments = CommentRenderData(Comment.getCommentsBy(viewingNation.id).toList())
+       
+       return page(viewingNation.name, standardNavBar()) {
+               section {
+                       a { id = "page-top" }
+                       h1 { +viewingNation.name }
+                       if (currNation?.id == OwnerNationId) {
+                               if (viewingNation.isBanned) {
+                                       p { +"This user is banned" }
+                                       val unbanLink = "/admin/unban/${viewingNation.id}"
+                                       a(href = unbanLink) {
+                                               attributes["data-method"] = "post"
+                                               attributes["data-csrf-token"] = createCsrfToken(unbanLink)
+                                               +"Unban"
+                                       }
+                               } else {
+                                       val banLink = "/admin/ban/${viewingNation.id}"
+                                       a(href = banLink) {
+                                               attributes["data-method"] = "post"
+                                               attributes["data-csrf-token"] = createCsrfToken(banLink)
+                                               +"Ban"
+                                       }
+                               }
+                       }
+                       for (comment in comments)
+                               commentBox(comment, currNation?.id, viewingUserPage = true)
+               }
+       }
+}
+
+suspend fun ApplicationCall.adminBanUserRoute(): Nothing {
+       val currNation = currentNation()
+       if (currNation?.id != OwnerNationId)
+               throw NullPointerException()
+       
+       verifyCsrfToken()
+       
+       val bannedNation = NationData.get(Id(parameters["id"]!!))
+       
+       NationData.Table.set(bannedNation.id, setValue(NationData::isBanned, true))
+       
+       redirect("/user/${bannedNation.id}")
+}
+
+suspend fun ApplicationCall.adminUnbanUserRoute(): Nothing {
+       val currNation = currentNation()
+       if (currNation?.id != OwnerNationId)
+               throw NullPointerException()
+       
+       verifyCsrfToken()
+       
+       val bannedNation = NationData.get(Id(parameters["id"]!!))
+       
+       NationData.Table.set(bannedNation.id, setValue(NationData::isBanned, false))
+       
+       redirect("/user/${bannedNation.id}")
+}
index a446df11a310aeeb5123921a6784f6c8e98d1e19..5d4e59fdfc0fe57c0ef01399c6bacbc889cdbfa5 100644 (file)
@@ -1,8 +1,7 @@
 package info.mechyrdia.lore
 
-import kotlinx.html.MAIN
-import kotlinx.html.SECTION
-import kotlinx.html.section
+import kotlinx.html.*
+import java.time.Instant
 
 fun interface SECTIONS {
        fun section(body: SECTION.() -> Unit)
@@ -15,3 +14,10 @@ private class MainSections(private val delegate: MAIN) : SECTIONS {
                delegate.section(block = body)
        }
 }
+
+fun FlowOrPhrasingContent.dateTime(instant: Instant) {
+       span(classes = "moment") {
+               style = "display:none"
+               +"${instant.toEpochMilli()}"
+       }
+}
index b3daef937a523d7917b7d9e16431dbedda171fa7..6ded4da3889b5fbedabd8f3fe2f9d459bfa4b631 100644 (file)
@@ -1,5 +1,12 @@
 package info.mechyrdia.lore
 
+import io.ktor.http.*
+
 data class HttpRedirectException(val url: String, val permanent: Boolean) : RuntimeException()
 
 fun redirect(url: String, permanent: Boolean = false): Nothing = throw HttpRedirectException(url, permanent)
+
+fun redirectWithError(url: String, error: String): Nothing {
+       val urlWithError = url + "?" + parametersOf("error", error).formUrlEncode()
+       redirect(urlWithError, false)
+}
index 8737a05f6e0e63bc582a9629f7036a2cd178787d..51f416e57e3eb72d3525e2f263f852f22abd2d50 100644 (file)
@@ -133,11 +133,11 @@ sealed class TextParserState<TContext>(
                override fun processCharacter(char: Char): TextParserState<TContext> {
                        return if (char == ']') {
                                val tagType = scope.tags[tag]
-                               if (tagType is TextParserTagType.Direct<TContext> && insideDirectTags.last().equals(tag, ignoreCase = true)) {
+                               if (tagType is TextParserTagType.Direct<TContext> && insideDirectTags.lastOrNull().equals(tag, ignoreCase = true)) {
                                        appendTextRaw(tagType.end(scope.ctx))
                                        
                                        PlainText(scope, "", insideTags, insideDirectTags.dropLast(1))
-                               } else if (insideTags.isNotEmpty() && insideTags.last().first.equals(tag, ignoreCase = true)) {
+                               } else if (insideTags.isNotEmpty() && insideTags.lastOrNull()?.first.equals(tag, ignoreCase = true)) {
                                        PlainText(scope, "", insideTags.dropLast(1), insideDirectTags)
                                } else {
                                        appendText("[/$tag]")
index b9ac1f893674b776a0b8005fb6e178f33e9291e5..9b4b3f2579b75603278ae9c15c3506abf9005be3 100644 (file)
@@ -79,8 +79,8 @@ enum class TextParserFormattingTag(val type: TextParserTagType<Unit>) {
        ),
        CODE(
                TextParserTagType.Direct(
-                       { _, _ -> "<span style='font-family: monospace'>" },
-                       { "</span>" },
+                       { _, _ -> "<span style='font-family: monospace'><pre>" },
+                       { "</pre></span>" },
                )
        ),
        H1(
@@ -90,31 +90,31 @@ enum class TextParserFormattingTag(val type: TextParserTagType<Unit>) {
        ),
        H2(
                TextParserTagType.Indirect { _, content, _ ->
-                       val anchor = TextParserToCBuilderTag.headerContentToAnchor(content)
-                       "</section><section><h2><a id='$anchor'></a>$content</h2>${TextParserToCBuilderTag.RETURN_TO_TOP}"
+                       val anchor = headerContentToAnchor(content)
+                       "</section><section><h2><a id='$anchor'></a>$content</h2>"
                }
        ),
        H3(
                TextParserTagType.Indirect { _, content, _ ->
-                       val anchor = TextParserToCBuilderTag.headerContentToAnchor(content)
+                       val anchor = headerContentToAnchor(content)
                        "<h3><a id='$anchor'></a>$content</h3>"
                }
        ),
        H4(
                TextParserTagType.Indirect { _, content, _ ->
-                       val anchor = TextParserToCBuilderTag.headerContentToAnchor(content)
+                       val anchor = headerContentToAnchor(content)
                        "<h4><a id='$anchor'></a>$content</h4>"
                }
        ),
        H5(
                TextParserTagType.Indirect { _, content, _ ->
-                       val anchor = TextParserToCBuilderTag.headerContentToAnchor(content)
+                       val anchor = headerContentToAnchor(content)
                        "<h5><a id='$anchor'></a>$content</h5>"
                }
        ),
        H6(
                TextParserTagType.Indirect { _, content, _ ->
-                       val anchor = TextParserToCBuilderTag.headerContentToAnchor(content)
+                       val anchor = headerContentToAnchor(content)
                        "<h6><a id='$anchor'></a>$content</h6>"
                }
        ),
@@ -204,12 +204,10 @@ enum class TextParserFormattingTag(val type: TextParserTagType<Unit>) {
        TD(
                TextParserTagType.Direct(
                        { tagParam, _ ->
-                               
                                val (width, height) = getSizeParam(tagParam)
                                val sizeAttrs = getTableSizeAttributes(width, height)
                                
                                "<td$sizeAttrs>"
-                               
                        },
                        { "</td>" },
                )
@@ -217,12 +215,10 @@ enum class TextParserFormattingTag(val type: TextParserTagType<Unit>) {
        TH(
                TextParserTagType.Direct(
                        { tagParam, _ ->
-                               
                                val (width, height) = getSizeParam(tagParam)
                                val sizeAttrs = getTableSizeAttributes(width, height)
                                
                                "<th$sizeAttrs>"
-                               
                        },
                        { "</th>" },
                )
@@ -286,28 +282,52 @@ enum class TextParserFormattingTag(val type: TextParserTagType<Unit>) {
                        } else content
                }
        ),
+       ;
        
-       // DANGER ZONE
-       RAWHTML(
+       companion object {
+               val asTags: TextParserTags<Unit>
+                       get() = TextParserTags(values().associate { it.name to it.type })
+       }
+}
+
+enum class TextParserCommentTags(val type: TextParserTagType<Unit>) {
+       B(TextParserFormattingTag.B.type),
+       I(TextParserFormattingTag.I.type),
+       U(TextParserFormattingTag.U.type),
+       S(TextParserFormattingTag.S.type),
+       SUP(TextParserFormattingTag.SUP.type),
+       SUB(TextParserFormattingTag.SUB.type),
+       IPA(TextParserFormattingTag.IPA.type),
+       CODE(TextParserFormattingTag.CODE.type),
+       COLOR(TextParserFormattingTag.COLOR.type),
+       
+       ALIGN(TextParserFormattingTag.ALIGN.type),
+       ASIDE(TextParserFormattingTag.ASIDE.type),
+       
+       UL(TextParserFormattingTag.UL.type),
+       OL(TextParserFormattingTag.OL.type),
+       LI(TextParserFormattingTag.LI.type),
+       
+       TABLE(TextParserFormattingTag.TABLE.type),
+       TR(TextParserFormattingTag.TR.type),
+       TD(TextParserFormattingTag.TD.type),
+       TH(TextParserFormattingTag.TH.type),
+       URL(TextParserFormattingTag.EXTLINK.type),
+       
+       REPLY(
                TextParserTagType.Indirect { _, content, _ ->
-                       TextParserState.uncensorText(content)
+                       val id = sanitizeLink(content)
+                       
+                       "<a href=\"/comment/view/$id\">&gt;&gt;$id</a>"
                }
        ),
+       
+       LANG(TextParserFormattingTag.LANG.type)
        ;
        
        companion object {
                val asTags: TextParserTags<Unit>
                        get() = TextParserTags(values().associate { it.name to it.type })
-               
-               fun sanitizeLink(html: String) = html.replace(Regex("[^#a-zA-Z\\d\\-._]"), "").replace("..", ".")
-               
-               fun getSizeParam(tagParam: String?): Pair<Int?, Int?> = tagParam?.let { resolution ->
-                       val parts = resolution.split('x')
-                       parts.getOrNull(0)?.toIntOrNull() to parts.getOrNull(1)?.toIntOrNull()
-               } ?: (null to null)
-               
-               fun getTableSizeAttributes(width: Int?, height: Int?) = (width?.let { " colspan=\"$it\"" } ?: "") + (height?.let { " rowspan=\"$it\"" } ?: "")
-               fun getImageSizeAttributes(width: Int?, height: Int?) = " style=\"" + (width?.let { "width: calc(var(--media-size-unit) * $it);" } ?: "") + (height?.let { "height: calc(var(--media-size-unit) * $it);" } ?: "") + "\""
        }
 }
 
@@ -350,15 +370,25 @@ enum class TextParserToCBuilderTag(val type: TextParserTagType<TableOfContentsBu
        );
        
        companion object {
-               val NON_ANCHOR_CHAR = Regex("[^a-zA-Z\\d\\-]")
-               val INSIDE_TAG_TEXT = Regex("\\[.*?]")
-               
-               fun headerContentToLabel(content: String) = TextParserState.uncensorText(content.replace(INSIDE_TAG_TEXT, ""))
-               fun headerContentToAnchor(content: String) = headerContentToLabel(content).replace(NON_ANCHOR_CHAR, "-")
-               
                val asTags: TextParserTags<TableOfContentsBuilder>
                        get() = TextParserTags(values().associate { it.name to it.type })
-               
-               val RETURN_TO_TOP = "<p style='font-size:0.8em;margin:0'><a href='#page-top'>Back to Top</a></p>"
        }
 }
+
+val NON_LINK_CHAR = Regex("[^#a-zA-Z\\d\\-._]")
+val DOT_CHARS = Regex("\\.+")
+fun sanitizeLink(html: String) = html.replace(NON_LINK_CHAR, "").replace(DOT_CHARS, ".")
+
+fun getSizeParam(tagParam: String?): Pair<Int?, Int?> = tagParam?.let { resolution ->
+       val parts = resolution.split('x')
+       parts.getOrNull(0)?.toIntOrNull() to parts.getOrNull(1)?.toIntOrNull()
+} ?: (null to null)
+
+fun getTableSizeAttributes(width: Int?, height: Int?) = (width?.let { " colspan=\"$it\"" } ?: "") + (height?.let { " rowspan=\"$it\"" } ?: "")
+fun getImageSizeAttributes(width: Int?, height: Int?) = " style=\"" + (width?.let { "width: calc(var(--media-size-unit) * $it);" } ?: "") + (height?.let { "height: calc(var(--media-size-unit) * $it);" } ?: "") + "\""
+
+val NON_ANCHOR_CHAR = Regex("[^a-zA-Z\\d\\-]")
+val INSIDE_TAG_TEXT = Regex("\\[.*?]")
+
+fun headerContentToLabel(content: String) = TextParserState.uncensorText(content.replace(INSIDE_TAG_TEXT, ""))
+fun headerContentToAnchor(content: String) = headerContentToLabel(content).replace(NON_ANCHOR_CHAR, "-")
index 9aad1dfc8c3450f7a198de3ed082315a590aa851..119b34d292ae94cb9e5b48f25d43f2f79dabf40d 100644 (file)
@@ -21,7 +21,10 @@ class TableOfContentsBuilder {
                        } else
                                throw IllegalArgumentException("[h${level + 1}] cannot appear after [h${levels.size + 1}]!")
                } else {
-                       levels.addAll(levels.take(level).also { levels.clear() }.mapIndexed { i, n -> if (i == level - 1) n + 1 else n })
+                       val newLevels = levels.take(level).mapIndexed { i, n -> if (i == level - 1) n + 1 else n }
+                       levels.clear()
+                       levels.addAll(newLevels)
+                       
                        levels.joinToString(separator = ".") { it.toString() }
                }
                
@@ -30,5 +33,5 @@ class TableOfContentsBuilder {
        
        fun toPageTitle() = title!!
        
-       fun toNavBar(): List<NavItem> = links.toList()
+       fun toNavBar(): List<NavItem> = listOf(NavLink("#page-top", title!!, aClasses = "left")) + links.toList()
 }
index 0a1b17fd195233b653700dd7c5a28bb6f2b4806e..8cfe07015205379bbe52019ed6abaec95bbb4d9d 100644 (file)
@@ -4,45 +4,111 @@ import com.samskivert.mustache.Escapers
 import com.samskivert.mustache.Mustache
 import com.samskivert.mustache.Template
 import info.mechyrdia.Configuration
-import info.mechyrdia.JSON
+import info.mechyrdia.JsonFileCodec
 import io.ktor.util.*
 import kotlinx.serialization.json.*
 import java.io.File
 import java.security.MessageDigest
 
+@JvmInline
+value class JsonPath private constructor(private val pathElements: List<String>) {
+       constructor(path: String) : this(path.split('.').filterNot { it.isBlank() })
+       
+       operator fun component1() = pathElements.firstOrNull()
+       operator fun component2() = JsonPath(pathElements.drop(1))
+       
+       override fun toString(): String {
+               return pathElements.joinToString(separator = ".")
+       }
+}
+
+operator fun JsonElement.get(path: JsonPath): JsonElement {
+       val (pathHead, pathTail) = path
+       pathHead ?: return this
+       
+       return when (this) {
+               is JsonObject -> this.getValue(pathHead)[pathTail]
+               is JsonArray -> this[pathHead.toInt()][pathTail]
+               is JsonPrimitive -> throw NoSuchElementException("Cannot resolve path $path on JSON primitive $this")
+       }
+}
+
+@JvmInline
+value class JsonImport private constructor(private val importFrom: Pair<File, JsonPath>) {
+       fun resolve(): Pair<File, JsonElement> {
+               return try {
+                       importFrom.let { (file, path) ->
+                               file to JsonFileCodec.parseToJsonElement(file.readText())[path]
+                       }
+               } catch (ex: RuntimeException) {
+                       val filePath = importFrom.first.toRelativeString(File(Configuration.CurrentConfiguration.jsonDocDir))
+                       val jsonPath = importFrom.second
+                       throw IllegalArgumentException("Unable to resolve JSON path $jsonPath on file $filePath", ex)
+               }
+       }
+       
+       companion object {
+               operator fun invoke(statement: String, currentFile: File): JsonImport? {
+                       if (!statement.startsWith('@')) return null
+                       val splitterIndex = statement.lastIndexOf('#')
+                       
+                       val (filePath, jsonPath) = if (splitterIndex != -1)
+                               statement.substring(1, splitterIndex) to statement.substring(splitterIndex + 1)
+                       else
+                               statement.substring(1) to ""
+                       
+                       val file = if (filePath.startsWith('/'))
+                               File(Configuration.CurrentConfiguration.jsonDocDir).combineSafe("$filePath.json")
+                       else
+                               currentFile.parentFile.combineSafe("$filePath.json")
+                       
+                       if (!file.isFile)
+                               throw IllegalArgumentException("JSON import path '$filePath' does not point to a file")
+                       
+                       return JsonImport(file to JsonPath(jsonPath))
+               }
+       }
+}
+
 object PreParser {
        private val compiler = Mustache.compiler()
                .withEscaper(Escapers.NONE)
-               .defaultValue("{{ MISSING }}")
+               .defaultValue("{{ MISSING VALUE \"{{name}}\" }}")
                .withLoader { File(Configuration.CurrentConfiguration.templateDir, "$it.tpl").bufferedReader() }
        
        private val cache = mutableMapOf<String, Template>()
        
-       private fun convertJson(json: JsonElement): Any? = when (json) {
+       private fun convertJson(json: JsonElement, currentFile: File): Any? = when (json) {
                JsonNull -> null
-               is JsonPrimitive -> if (json.isString)
-                       json.content
-               else
-                       json.intOrNull ?: json.double
+               is JsonPrimitive -> if (json.isString) {
+                       JsonImport(json.content, currentFile)?.let { jsonImport ->
+                               val (nextFile, jsonData) = jsonImport.resolve()
+                               convertJson(jsonData, nextFile)
+                       } ?: json.content
+               } else json.intOrNull ?: json.double
                
-               is JsonObject -> json.mapValues { (_, it) -> convertJson(it) }
-               is JsonArray -> json.map { convertJson(it) }
+               is JsonObject -> json.mapValues { (_, it) -> convertJson(it, currentFile) }
+               is JsonArray -> json.map { convertJson(it, currentFile) }
        }
        
-       private fun loadJson(name: String): JsonElement =
+       private fun loadJsonContext(name: String): Any? =
                File(Configuration.CurrentConfiguration.jsonDocDir).combineSafe("$name.json")
                        .takeIf { it.isFile }
-                       ?.readText()
-                       ?.let { JSON.parseToJsonElement(it) }
-                       ?: JsonNull
+                       ?.let { file ->
+                               convertJson(JsonFileCodec.parseToJsonElement(file.readText()), file)
+                       }
        
        private val msgDigest = ThreadLocal.withInitial { MessageDigest.getInstance("SHA-256") }
        
        fun preparse(name: String, content: String): String {
-               val contentHash = hex(msgDigest.get().digest(content.toByteArray()))
-               val template = cache[contentHash] ?: compiler.compile(content)
-               
-               val context = convertJson(loadJson(name))
-               return template.execute(context)
+               return try {
+                       val contentHash = hex(msgDigest.get().digest(content.toByteArray()))
+                       val template = cache[contentHash] ?: compiler.compile(content)
+                       
+                       val context = loadJsonContext(name)
+                       template.execute(context)
+               } catch (ex: RuntimeException) {
+                       "[h1]Error[/h1]\n\nThere was an error pre-parsing this factbook: ${ex.message}"
+               }
        }
 }
index 7eb6846ff28ac505f3a45dc5e4e6667c89c1b8f3..7ea5db1a936f73e81518679db150d536d8a4b203 100644 (file)
@@ -1,11 +1,30 @@
 package info.mechyrdia.lore
 
+import info.mechyrdia.OWNER_NATION
+import info.mechyrdia.auth.createCsrfToken
+import info.mechyrdia.data.currentNation
+import io.ktor.server.application.*
 import kotlinx.html.DIV
 import kotlinx.html.a
 import kotlinx.html.span
 import kotlinx.html.style
+import kotlin.collections.List
+import kotlin.collections.Map
+import kotlin.collections.component1
+import kotlin.collections.component2
+import kotlin.collections.dropLast
+import kotlin.collections.emptyMap
+import kotlin.collections.iterator
+import kotlin.collections.joinToString
+import kotlin.collections.listOf
+import kotlin.collections.mapIndexed
+import kotlin.collections.mapOf
+import kotlin.collections.orEmpty
+import kotlin.collections.plus
+import kotlin.collections.set
+import kotlin.collections.take
 
-fun standardNavBar(path: List<String>? = null) = listOf(
+suspend fun ApplicationCall.standardNavBar(path: List<String>? = null) = listOf(
        NavLink("/lore", "Lore Index")
 ) + path?.let { pathParts ->
        pathParts.dropLast(1).mapIndexed { i, part ->
@@ -13,10 +32,22 @@ fun standardNavBar(path: List<String>? = null) = listOf(
                NavLink("/lore/$subPath", part)
        }
 }.orEmpty() + listOf(
-       NavHead("Preferences"),
+       NavHead("Client Preferences"),
        NavLink("/change-theme", "Light/Dark Mode"),
-       NavHead("External Links"),
-       NavLink("https://nationstates.net/mechyrdia", "On NationStates"),
+) + (currentNation()?.let {
+       listOf(
+               NavHead(it.name),
+               NavLink("/user/${it.id}", "Your User Page"),
+               NavLink("https://nationstates.net/${it.id}", "Your NationStates Page"),
+               NavLink("/auth/logout", "Log Out", attributes = mapOf("data-method" to "post", "data-csrf-token" to createCsrfToken("/auth/logout")))
+       )
+} ?: listOf(
+       NavHead("Log In"),
+       NavLink("/auth/login", "Log In with NationStates")
+)) + listOf(
+       NavHead("Useful Links"),
+       NavLink("/comment/help", "Commenting Help"),
+       NavLink("https://nationstates.net/$OWNER_NATION", "Mechyrdia on NationStates"),
 )
 
 sealed class NavItem {
@@ -33,9 +64,12 @@ data class NavHead(val label: String) : NavItem() {
        }
 }
 
-data class NavLink(val to: String, val text: String, val aClasses: String? = null) : NavItem() {
+data class NavLink(val to: String, val text: String, val aClasses: String? = null, val attributes: Map<String, String> = emptyMap()) : NavItem() {
        override fun DIV.display() {
                a(href = to, classes = aClasses) {
+                       for ((attrName, attrValue) in attributes)
+                               attributes[attrName] = attrValue
+                       
                        +text
                }
        }
index 6b37ea2f630258ca26e645cec095d2dc4aa4c7ba..6c6771d67eab2528f801fb84d4359a292552fa9b 100644 (file)
@@ -1,27 +1,51 @@
 package info.mechyrdia.lore
 
+import io.ktor.http.*
 import io.ktor.server.application.*
+import io.ktor.server.request.*
 import kotlinx.html.HTML
+import kotlinx.html.a
 import kotlinx.html.h1
 import kotlinx.html.p
 
-fun ApplicationCall.error400(): HTML.() -> Unit = page("Bad Request", standardNavBar()) {
+suspend fun ApplicationCall.error400(): HTML.() -> Unit = page("400 Bad Request", standardNavBar()) {
        section {
-               h1 { +"Bad Request" }
+               h1 { +"400 Bad Request" }
                p { +"The request your browser sent was improperly formatted." }
        }
 }
 
-fun ApplicationCall.error404(): HTML.() -> Unit = page("Not Found", standardNavBar()) {
+suspend fun ApplicationCall.error403(): HTML.() -> Unit = page("403 Forbidden", standardNavBar()) {
        section {
-               h1 { +"Not Found" }
+               h1 { +"403 Forbidden" }
+               p { +"You are not allowed to do that." }
+       }
+}
+
+suspend fun ApplicationCall.error403PageExpired(): HTML.() -> Unit = page("Page Expired", standardNavBar()) {
+       section {
+               h1 { +"Page Expired" }
+               p {
+                       +"The page you were on has expired."
+                       request.header(HttpHeaders.Referrer)?.let { referrer ->
+                               +" You can"
+                               a(href = referrer) { +"return to the previous page" }
+                               +" and retry your action."
+                       }
+               }
+       }
+}
+
+suspend fun ApplicationCall.error404(): HTML.() -> Unit = page("404 Not Found", standardNavBar()) {
+       section {
+               h1 { +"404 Not Found" }
                p { +"Unfortunately, we could not find what you were looking for." }
        }
 }
 
-fun ApplicationCall.error500(): HTML.() -> Unit = page("Internal Error", standardNavBar()) {
+suspend fun ApplicationCall.error500(): HTML.() -> Unit = page("500 Internal Error", standardNavBar()) {
        section {
-               h1 { +"Internal Error" }
+               h1 { +"500 Internal Error" }
                p { +"The servers made a bit of a mistake. Please be patient while we clean up our mess." }
        }
 }
index 9c4292359525f5dbc44bfe08d60cf7f502426efe..d4743c23f2d0066bda9291315741bc1e436b1777 100644 (file)
@@ -1,12 +1,16 @@
 package info.mechyrdia.lore
 
 import info.mechyrdia.Configuration
+import info.mechyrdia.data.*
 import io.ktor.server.application.*
 import io.ktor.util.*
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.toList
 import kotlinx.html.*
 import java.io.File
 
-fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
+suspend fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
        val pagePathParts = parameters.getAll("path")!!
        val pagePath = pagePathParts.joinToString("/")
        val pageFile = File(Configuration.CurrentConfiguration.articleDir).combineSafe(pagePath)
@@ -15,7 +19,7 @@ fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
        if (pageFile.isDirectory) {
                val navbar = standardNavBar(pagePathParts.takeIf { it.isNotEmpty() })
                
-               val title = pagePath.takeIf { it.isNotEmpty() }?.let { "~/$it" } ?: "Mechyrdia Infobase"
+               val title = pagePath.takeIf { it.isNotEmpty() } ?: "Mechyrdia Infobase"
                
                return page(title, navbar, null) {
                        section {
@@ -28,20 +32,56 @@ fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
        } else {
                val pageTemplate = pageFile.readText()
                val pageMarkup = PreParser.preparse(pagePath, pageTemplate)
-               val pageHtml = TextParserState.parseText(pageMarkup, TextParserFormattingTag.asTags, Unit).html
+               val pageResult = TextParserState.parseText(pageMarkup, TextParserFormattingTag.asTags, Unit)
+               val pageHtml = pageResult.html
+               if (!pageResult.succeeded) {
+                       return page("Error rendering page", standardNavBar(pagePathParts), null) {
+                               section {
+                                       a { id = "page-top" }
+                                       unsafe { raw(pageHtml) }
+                               }
+                       }
+               }
                
                val pageToC = TableOfContentsBuilder()
-               TextParserState.parseText(pageMarkup, TextParserToCBuilderTag.asTags, pageToC)
-               val pageNav = pageToC.toNavBar()
+               val pageToCResult = TextParserState.parseText(pageMarkup, TextParserToCBuilderTag.asTags, pageToC)
+               if (!pageToCResult.succeeded) {
+                       return page("Error generating table of contents", standardNavBar(pagePathParts), null) {
+                               section {
+                                       a { id = "page-top" }
+                                       unsafe { raw(pageToCResult.html) }
+                               }
+                       }
+               }
+               
+               val pageNav = pageToC.toNavBar() + NavLink("#comments", "Comments", aClasses = "left")
+               
+               val (canCommentAs, comments) = coroutineScope {
+                       val canCommentAs = async { currentNation() }
+                       val comments = async {
+                               CommentRenderData(Comment.getCommentsIn(pagePath).toList())
+                       }
+                       
+                       canCommentAs.await() to comments.await()
+               }
                
                val navbar = standardNavBar(pagePathParts)
-               val sidebar = if (pageNav.isEmpty()) null else PageNavSidebar(pageNav)
+               val sidebar = PageNavSidebar(pageNav)
                
                return page(pageToC.toPageTitle(), navbar, sidebar) {
                        section {
                                a { id = "page-top" }
                                unsafe { raw(pageHtml) }
                        }
+                       section {
+                               h2 {
+                                       a { id = "comments" }
+                                       +"Comments"
+                               }
+                               commentInput(pagePath, canCommentAs)
+                               for (comment in comments)
+                                       commentBox(comment, canCommentAs?.id)
+                       }
                }
        }
 }
index fc707d67bbace1a0b93e4e4ea3bdaa82f60c30d1..cd03f1aea782513ed81f04673c52791cf49b63c4 100644 (file)
@@ -1,19 +1,23 @@
 package info.mechyrdia.lore
 
+import info.mechyrdia.auth.createCsrfToken
+import info.mechyrdia.auth.installCsrfToken
+import info.mechyrdia.auth.verifyCsrfToken
 import io.ktor.server.application.*
 import kotlinx.html.*
 
-fun ApplicationCall.changeThemePage(): HTML.() -> Unit {
+suspend fun ApplicationCall.changeThemePage(): HTML.() -> Unit {
        val theme = when (request.cookies["factbook-theme"]) {
                "light" -> "light"
                "dark" -> "dark"
                else -> null
        }
        
-       return page("/etc/prefs", standardNavBar(), null) {
+       return page("Client Preferences", standardNavBar(), null) {
                section {
                        h1 { +"Client Preferences" }
                        form(action = "/change-theme", method = FormMethod.post) {
+                               installCsrfToken(createCsrfToken())
                                label {
                                        radioInput(name = "theme") {
                                                id = "system-theme"
@@ -47,10 +51,19 @@ fun ApplicationCall.changeThemePage(): HTML.() -> Unit {
                                        +"Dark Theme"
                                }
                                br
-                               submitInput {
-                                       value = "Accept Changes"
-                               }
+                               submitInput { value = "Accept Changes" }
                        }
                }
        }
 }
+
+suspend fun ApplicationCall.changeThemeRoute(): Nothing {
+       val newTheme = when (verifyCsrfToken()["theme"]) {
+               "light" -> "light"
+               "dark" -> "dark"
+               else -> "system"
+       }
+       response.cookies.append("factbook-theme", newTheme, maxAge = Int.MAX_VALUE.toLong())
+       
+       redirect("/change-theme")
+}
index b816e8dea635b462902c6ffe903e4a71a93a6c54..ed9a1fe79e2d714e0540ec6a817fbf4f5b61ddd3 100644 (file)
@@ -1,4 +1,8 @@
 (function () {
+       function delay(amount) {
+               return new Promise(resolve => window.setTimeout(resolve, amount));
+       }
+
        window.addEventListener("load", function () {
                // Tylan alphabet
                async function tylanToFont(input, output) {
                        });
                }
        });
+
+       window.addEventListener("load", function () {
+               // Localize dates and times
+               const moments = document.getElementsByClassName("moment");
+               for (const moment of moments) {
+                       let date = new Date(Number(moment.textContent.trim()));
+                       moment.innerHTML = date.toLocaleString();
+                       moment.style.display = "inline";
+               }
+       });
+
+       window.addEventListener("load", function () {
+               // Login button
+               const viewChecksumButtons = document.getElementsByClassName("view-checksum");
+               for (const viewChecksumButton of viewChecksumButtons) {
+                       const token = viewChecksumButton.getAttribute("data-token");
+                       const url = (token != null && token !== "") ? ("https://www.nationstates.net/page=verify_login?token=" + token) : "https://www.nationstates.net/page=verify_login"
+                       viewChecksumButton.addEventListener("click", e => {
+                               e.preventDefault();
+                               window.open(url);
+                       });
+               }
+       });
+
+       window.addEventListener("load", function () {
+               // Allow POSTing with <a>s
+               const anchors = document.getElementsByTagName("a");
+               for (const anchor of anchors) {
+                       const method = anchor.getAttribute("data-method");
+                       if (method == null) continue;
+
+                       anchor.onclick = e => {
+                               e.preventDefault();
+
+                               let form = document.createElement("form");
+                               form.style.display = "none";
+                               form.action = anchor.href;
+                               form.method = method;
+
+                               const csrfToken = anchor.getAttribute("data-csrf-token");
+                               if (csrfToken != null) {
+                                       let csrfInput = document.createElement("input");
+                                       csrfInput.name = "csrf-token";
+                                       csrfInput.type = "hidden";
+                                       csrfInput.value = csrfToken;
+                                       form.append(csrfInput);
+                               }
+
+                               document.body.append(form);
+                               form.submit();
+                       };
+               }
+       });
+
+       window.addEventListener("load", function () {
+               // Comment previews
+               async function commentPreview(input, output) {
+                       const inText = input.value;
+
+                       await delay(500);
+                       if (input.value !== inText)
+                               return;
+
+                       const outText = await (await fetch('/preview-comment', {
+                               method: 'POST',
+                               headers: {
+                                       'Content-Type': 'text/plain',
+                               },
+                               body: inText,
+                       })).text();
+                       if (input.value !== inText)
+                               return;
+
+                       output.innerHTML = outText;
+               }
+
+               const commentInputBoxes = document.getElementsByClassName("comment-input");
+               for (const commentInputBox of commentInputBoxes) {
+                       const inputBox = commentInputBox.getElementsByClassName("comment-markup")[0];
+                       const outputBox = commentInputBox.getElementsByClassName("comment-preview")[0];
+
+                       inputBox.addEventListener("input", () => commentPreview(inputBox, outputBox));
+               }
+       });
+
+       window.addEventListener("load", function () {
+               // Comment editing and deleting
+               const commentEditLinks = document.getElementsByClassName("comment-edit-link");
+               for (const commentEditLink of commentEditLinks) {
+                       commentEditLink.onclick = e => {
+                               e.preventDefault();
+
+                               const elementId = e.currentTarget.getAttribute("data-edit-id");
+                               document.getElementById(elementId).classList.add("visible");
+                       };
+               }
+
+               const commentEditCancelButtons = document.getElementsByClassName("comment-cancel-edit");
+               for (const commentEditCancelButton of commentEditCancelButtons) {
+                       commentEditCancelButton.onclick = e => {
+                               e.preventDefault();
+
+                               e.currentTarget.parentElement.classList.remove("visible");
+                       };
+               }
+       });
+
+       window.addEventListener("load", function () {
+               // Copying text
+               const copyTextElements = document.getElementsByClassName("copy-text");
+               for (const copyTextElement of copyTextElements) {
+                       copyTextElement.onclick = e => {
+                               e.preventDefault();
+
+                               const thisElement = e.currentTarget;
+                               if (thisElement.hasAttribute("data-copying"))
+                                       return;
+
+                               const elementHtml = thisElement.innerHTML;
+
+                               thisElement.setAttribute("data-copying", "copying");
+
+                               const text = thisElement.getAttribute("data-text");
+                               navigator.clipboard.writeText(text)
+                                       .then(() => {
+                                               thisElement.innerHTML = "Text copied!";
+                                               window.setTimeout(() => {
+                                                       thisElement.innerHTML = elementHtml;
+                                                       thisElement.removeAttribute("data-copying");
+                                               }, 750);
+                                       })
+                                       .catch(reason => {
+                                               console.error("Error copying text to clipboard", reason);
+                                       });
+
+                               thisElement.innerHTML = "Copying text...";
+                       };
+               }
+       });
+
+       window.addEventListener("load", function () {
+               // Error popup
+               const queryParams = new URLSearchParams(window.location.search);
+               if (queryParams.has("error")) {
+                       const errorMessage = queryParams.get("error");
+
+                       const errorPopup = document.createElement("div");
+                       errorPopup.id = "error-popup";
+
+                       const errorBg = document.createElement("div");
+                       errorBg.classList.add("bg");
+                       errorPopup.append(errorBg);
+
+                       const errorMsg = document.createElement("div");
+                       errorMsg.classList.add("msg");
+
+                       const errorP1 = document.createElement("p");
+                       errorP1.append(document.createTextNode(errorMessage));
+                       errorMsg.append(errorP1);
+
+                       const errorP2 = document.createElement("p");
+                       errorP2.append(document.createTextNode("Click to close this popup"));
+                       errorMsg.append(errorP2);
+
+                       errorPopup.append(errorMsg);
+
+                       document.body.append(errorPopup);
+                       errorPopup.addEventListener("click", e => {
+                               e.preventDefault();
+                               errorPopup.remove();
+                       });
+               }
+       });
 })();
index 023c30798c0ffa9ac215d69c18dc5cd30c845748..25c7bfaa18b83f631467d7af972383f01d4a3cb7 100644 (file)
@@ -1,12 +1,15 @@
 html {
        margin: 0;
        padding: 0;
-       color: #222;
+
+       color: var(--text-color);
        background-color: #aaa;
 
        font-family: 'Noto Sans', sans-serif;
        font-size: 100%;
 
+       --text-color: #222;
+
        --h1-size: 1.6em;
        --h2-size: 1.4em;
        --h3-size: 1.2em;
@@ -62,6 +65,13 @@ html {
 
        --iframe-border: #036;
 
+       --error-popup-border: #933;
+       --error-popup-backgr: #faa;
+       --error-popup-foregr: #622;
+
+       --comment-stroke: #025;
+       --comment-fill: #bbb;
+
        /*************
        * url params *
        *************/
@@ -72,13 +82,14 @@ html {
 }
 
 html[data-theme="dark"] {
-       color: #ddd;
        background-color: #555;
 
        /***************
        * color params *
        ***************/
 
+       --text-color: #ddd;
+
        --selection-fg: #111;
        --selection-bg: rgba(102, 153, 255, 0.9);
 
@@ -124,24 +135,32 @@ html[data-theme="dark"] {
 
        --iframe-border: #9cf;
 
+       --error-popup-border: #311;
+       --error-popup-backgr: #622;
+       --error-popup-foregr: #fcc;
+
+       --comment-stroke: #bdf;
+       --comment-fill: #222;
+
        /*************
        * url params *
        *************/
 
        --panel: url("/static/images/panel-dark.svg");
-       --bgimg: linear-gradient(to bottom, #457, #013);
+       --bgimg: linear-gradient(to bottom, #347, #123);
        --extln: url("/static/images/external-link-dark.svg");
 }
 
 @media only screen and (prefers-color-scheme: dark) {
        html[data-theme="light"] {
-               color: #222;
                background-color: #aaa;
 
                /***************
                * color params *
                ***************/
 
+               --text-color: #222;
+
                --selection-fg: #eee;
                --selection-bg: rgba(51, 102, 204, 0.6);
 
@@ -187,6 +206,13 @@ html[data-theme="dark"] {
 
                --iframe-border: #036;
 
+               --error-popup-border: #933;
+               --error-popup-backgr: #faa;
+               --error-popup-foregr: #622;
+
+               --comment-stroke: #025;
+               --comment-fill: #bbb;
+
                /*************
                * url params *
                *************/
@@ -197,13 +223,14 @@ html[data-theme="dark"] {
        }
 
        html {
-               color: #ddd;
                background-color: #555;
 
                /***************
                * color params *
                ***************/
 
+               --text-color: #ddd;
+
                --selection-fg: #111;
                --selection-bg: rgba(102, 153, 255, 0.9);
 
@@ -249,6 +276,13 @@ html[data-theme="dark"] {
 
                --iframe-border: #9cf;
 
+               --error-popup-border: #311;
+               --error-popup-backgr: #622;
+               --error-popup-foregr: #fcc;
+
+               --comment-stroke: #bdf;
+               --comment-fill: #222;
+
                /*************
                * url params *
                *************/
@@ -532,16 +566,6 @@ table {
        width: 100%;
 }
 
-table + table {
-       margin-top: 0;
-       border-top: 0;
-}
-
-table + table tr:first-child td,
-table + table tr:first-child th {
-       border-top: 0;
-}
-
 td {
        border: 0.125rem solid var(--tbl-border);
        background-color: var(--tbl-backgr);
@@ -610,8 +634,9 @@ button, input[type=submit] {
        display: block;
 
        font-size: 1.5em;
-       margin: 1em auto;
+       margin: 1em;
        padding: 0.85em 1.15em;
+       width: calc(100% - 2em);
 }
 
 button:hover, input[type=submit]:hover {
@@ -624,15 +649,7 @@ button:active, input[type=submit]:active {
 
 button.evil, input[type=submit].evil {
        background-color: var(--evil-btn-bg);
-       border: none;
-       border-radius: 0.3em;
        color: var(--evil-btn-fg);
-       cursor: pointer;
-       display: block;
-
-       font-size: 1.5em;
-       margin: 1em auto;
-       padding: 0.85em 1.15em;
 }
 
 button.evil:hover, input[type=submit].evil:hover {
@@ -656,6 +673,30 @@ iframe {
        border-color: var(--iframe-border);
 }
 
+#error-popup > .bg {
+       position: fixed;
+       width: 100vw;
+       height: 100vh;
+       background-color: rgba(0, 0, 0, 40%);
+       z-index: 998;
+}
+
+#error-popup > .msg {
+       position: fixed;
+       top: 50%;
+       left: 50%;
+       transform: translate(-50%, -50%);
+
+       border: 0.5em solid var(--error-popup-border);
+       border-radius: 1.5em;
+       padding: 1.5em;
+
+       background-color: var(--error-popup-backgr);
+       color: var(--error-popup-foregr);
+
+       text-align: center;
+}
+
 @font-face {
        font-family: 'Tulasra';
        src: url(/static/font/tylan-language-alphabet-3.woff) format('woff');
@@ -677,3 +718,59 @@ textarea.lang-tylan {
 .lang-gothic {
        font-family: 'Noto Sans Gothic', sans-serif;
 }
+
+.comment-box {
+       border: 0.25em solid var(--comment-stroke);
+       background-color: var(--comment-fill);
+       padding: 0.75em;
+       margin: 1em 0;
+}
+
+.comment-box > .comment-author {
+       display: flex;
+}
+
+.comment-box > .comment-author > .flag-icon {
+       object-fit: cover;
+       width: 2em;
+       height: 2em;
+       border-radius: 1em;
+
+       flex-grow: 0;
+       flex-shrink: 0;
+}
+
+.comment-box > .comment-author > .author-name {
+       font-size: 1.5em;
+       font-weight: bold;
+
+       text-align: left;
+       vertical-align: center;
+       flex-grow: 1;
+       flex-shrink: 0;
+}
+
+.comment-box > .comment-author > .posted-at {
+       text-align: right;
+       vertical-align: center;
+       flex-grow: 1;
+       flex-shrink: 1;
+}
+
+.comment-box > .comment {
+       border-top: 0.25em solid var(--comment-stroke);
+       padding-top: 0.5em;
+}
+
+.comment-edit-box {
+       display: none;
+}
+
+.comment-edit-box.visible {
+       display: block;
+}
+
+a.copy-text[data-copying] {
+       color: var(--text-color);
+       pointer-events: none;
+}