<?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
<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">
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
}
}
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))
+ })
+}
rootProject.name = "factbooks"
-
package info.mechyrdia
+import info.mechyrdia.data.Id
+import info.mechyrdia.data.NationData
import kotlinx.serialization.Serializable
import java.io.File
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()
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)
package info.mechyrdia
+import info.mechyrdia.auth.*
+import info.mechyrdia.data.*
import info.mechyrdia.lore.*
import io.ktor.http.*
import io.ktor.server.application.*
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)
}
}
- /*
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, _ ->
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())
}
redirect("/lore")
}
+ // Factbooks and assets
+
static("/static") {
resources("static")
}
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
+ )
+ }
}
}
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
+}
--- /dev/null
+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)
--- /dev/null
+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)
--- /dev/null
+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
+ }
+}
--- /dev/null
+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>,
+)
--- /dev/null
+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("/")
+}
--- /dev/null
+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))
+ }
+ }
+}
--- /dev/null
+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
+ )
+ }
+}
--- /dev/null
+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
+ }
+}
--- /dev/null
+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) }
+}
--- /dev/null
+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" }
+ }
+}
--- /dev/null
+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"
+ }
+ }
+ }
+ }
+ }
+ }
+}
--- /dev/null
+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}")
+}
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)
delegate.section(block = body)
}
}
+
+fun FlowOrPhrasingContent.dateTime(instant: Instant) {
+ span(classes = "moment") {
+ style = "display:none"
+ +"${instant.toEpochMilli()}"
+ }
+}
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)
+}
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]")
),
CODE(
TextParserTagType.Direct(
- { _, _ -> "<span style='font-family: monospace'>" },
- { "</span>" },
+ { _, _ -> "<span style='font-family: monospace'><pre>" },
+ { "</pre></span>" },
)
),
H1(
),
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>"
}
),
TD(
TextParserTagType.Direct(
{ tagParam, _ ->
-
val (width, height) = getSizeParam(tagParam)
val sizeAttrs = getTableSizeAttributes(width, height)
"<td$sizeAttrs>"
-
},
{ "</td>" },
)
TH(
TextParserTagType.Direct(
{ tagParam, _ ->
-
val (width, height) = getSizeParam(tagParam)
val sizeAttrs = getTableSizeAttributes(width, height)
"<th$sizeAttrs>"
-
},
{ "</th>" },
)
} 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\">>>$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);" } ?: "") + "\""
}
}
);
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, "-")
} 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() }
}
fun toPageTitle() = title!!
- fun toNavBar(): List<NavItem> = links.toList()
+ fun toNavBar(): List<NavItem> = listOf(NavLink("#page-top", title!!, aClasses = "left")) + links.toList()
}
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}"
+ }
}
}
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 ->
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 {
}
}
-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
}
}
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." }
}
}
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)
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 {
} 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)
+ }
}
}
}
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"
+"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")
+}
(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();
+ });
+ }
+ });
})();
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;
--iframe-border: #036;
+ --error-popup-border: #933;
+ --error-popup-backgr: #faa;
+ --error-popup-foregr: #622;
+
+ --comment-stroke: #025;
+ --comment-fill: #bbb;
+
/*************
* url params *
*************/
}
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);
--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);
--iframe-border: #036;
+ --error-popup-border: #933;
+ --error-popup-backgr: #faa;
+ --error-popup-foregr: #622;
+
+ --comment-stroke: #025;
+ --comment-fill: #bbb;
+
/*************
* url params *
*************/
}
html {
- color: #ddd;
background-color: #555;
/***************
* color params *
***************/
+ --text-color: #ddd;
+
--selection-fg: #111;
--selection-bg: rgba(102, 153, 255, 0.9);
--iframe-border: #9cf;
+ --error-popup-border: #311;
+ --error-popup-backgr: #622;
+ --error-popup-foregr: #fcc;
+
+ --comment-stroke: #bdf;
+ --comment-fill: #222;
+
/*************
* url params *
*************/
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);
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 {
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 {
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');
.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;
+}