From: Lanius Trolling Date: Mon, 13 Feb 2023 18:02:22 +0000 (-0500) Subject: Add authentication and commenting X-Git-Url: https://gitweb.starshipfights.net/?a=commitdiff_plain;h=3763e274543cd01c917b5f64e9b5afdb6fa813a6;p=factbooks Add authentication and commenting --- diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index e1eea1d..2b8a50f 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 9936197..f6d0002 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -88,7 +88,7 @@ - + diff --git a/build.gradle.kts b/build.gradle.kts index ea7c65e..f18047b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 { 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 { + javaLauncher.set(javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(14)) + }) +} diff --git a/libs/nsapi4j.jar b/libs/nsapi4j.jar new file mode 100644 index 0000000..d15c45a Binary files /dev/null and b/libs/nsapi4j.jar differ diff --git a/settings.gradle.kts b/settings.gradle.kts index a30b383..7dab3e5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,2 @@ rootProject.name = "factbooks" - diff --git a/src/main/kotlin/info/mechyrdia/Configuration.kt b/src/main/kotlin/info/mechyrdia/Configuration.kt index cc0be47..cf389c8 100644 --- a/src/main/kotlin/info/mechyrdia/Configuration.kt +++ b/src/main/kotlin/info/mechyrdia/Configuration.kt @@ -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(OWNER_NATION) diff --git a/src/main/kotlin/info/mechyrdia/Factbooks.kt b/src/main/kotlin/info/mechyrdia/Factbooks.kt index 19dff0e..4faddf4 100644 --- a/src/main/kotlin/info/mechyrdia/Factbooks.kt +++ b/src/main/kotlin/info/mechyrdia/Factbooks.kt @@ -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("USER_SESSION", SessionStorageMongoDB()) { + cookie("USER_SESSION", SessionStorageMongoDB) { identity { Id().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 { call, _ -> call.respondHtml(HttpStatusCode.BadRequest, call.error400()) } + exception { call, _ -> + call.respondHtml(HttpStatusCode.Forbidden, call.error403()) + } + exception { call, _ -> + call.respondHtml(HttpStatusCode.Forbidden, call.error403PageExpired()) + } exception { 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 + ) + } } } diff --git a/src/main/kotlin/info/mechyrdia/JSON.kt b/src/main/kotlin/info/mechyrdia/JSON.kt index 75754b5..d810cca 100644 --- a/src/main/kotlin/info/mechyrdia/JSON.kt +++ b/src/main/kotlin/info/mechyrdia/JSON.kt @@ -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 index 0000000..f8b130b --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/auth/csrf.kt @@ -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() + +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() + 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 index 0000000..ad37132 --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/auth/nationstates.kt @@ -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 ") + +suspend fun , 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 index 0000000..8d3d4c5 --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/auth/session_storage.kt @@ -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, + val session: String +) : DataDocument { + companion object : TableHolder { + override val Table = DocumentTable() + + 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 index 0000000..f47b9c1 --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/auth/sessions.kt @@ -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, +) 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 index 0000000..a857944 --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/auth/views_login.kt @@ -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() + +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() + + 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 index 0000000..905cc30 --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/data/comments.kt @@ -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, + + val submittedBy: Id, + val submittedIn: String, + val submittedAt: @Contextual Instant, + + val numEdits: Int, + val lastEdit: @Contextual Instant?, + + val contents: String +) : DataDocument { + companion object : TableHolder { + override val Table: DocumentTable = DocumentTable() + + override suspend fun initialize() { + Table.index(Comment::submittedBy, Comment::submittedAt) + Table.index(Comment::submittedIn, Comment::submittedAt) + } + + suspend fun getCommentsIn(page: String): Flow { + return Table.select(Comment::submittedIn eq page, descending(Comment::submittedAt)) + } + + suspend fun getCommentsBy(user: Id): Flow { + 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 index 0000000..ffc0d4d --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/data/data.kt @@ -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(val id: String) { + override fun toString() = id + + companion object { + fun serializer(): KSerializer> = IdSerializer + } +} + +fun Id.reinterpret() = Id(id) + +private val secureRandom = SecureRandom.getInstanceStrong() +private val alphabet = "ABCDEFGHILMNOPQRSTVXYZ0123456789".toCharArray() +fun Id() = Id(NanoIdUtils.randomNanoId(secureRandom, alphabet, 24)) + +object IdSerializer : KSerializer> { + 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(inner.deserialize(decoder)) + } +} + +object DocumentIdController : IdController { + override fun findIdProperty(type: KClass<*>): KProperty1<*, *> { + return DataDocument<*>::id + } + + @Suppress("UNCHECKED_CAST") + override fun getIdValue(idProperty: KProperty1, instance: T): R? { + return (instance as DataDocument<*>).id as R + } + + override fun setIdValue(idProperty: KProperty1, instance: T) { + throw UnsupportedOperationException("Cannot set id property of DataDocument!") + } +} + +object ConnectionHolder { + private lateinit var databaseName: String + + private val clientDeferred = CompletableDeferred() + + 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> { + @SerialName("_id") + val id: Id +} + +class DocumentTable>(val kclass: KClass) { + private suspend fun collection() = ConnectionHolder.getDatabase().database.getCollection(kclass.simpleName!!, kclass.java).coroutine + + suspend fun index(vararg properties: KProperty1) { + collection().ensureIndex(*properties) + } + + suspend fun unique(vararg properties: KProperty1) { + collection().ensureUniqueIndex(*properties) + } + + suspend fun indexIf(condition: Bson, vararg properties: KProperty1) { + collection().ensureIndex(*properties, indexOptions = IndexOptions().partialFilterExpression(condition)) + } + + suspend fun uniqueIf(condition: Bson, vararg properties: KProperty1) { + 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) { + 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, set: Bson): Boolean { + return collection().updateOneById(id, set).matchedCount != 0L + } + + suspend fun get(id: Id): T? { + return collection().findOneById(id) + } + + suspend fun del(id: Id) { + collection().deleteOneById(id) + } + + suspend fun all(): Flow { + return collection().find().toFlow() + } + + suspend fun filter(where: Bson): Flow { + return collection().find(where).toFlow() + } + + suspend fun sorted(order: Bson): Flow { + return collection().find().sort(order).toFlow() + } + + suspend fun select(where: Bson, order: Bson): Flow { + 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 > DocumentTable() = DocumentTable(T::class) + +interface TableHolder> { + @Suppress("PropertyName") + val Table: DocumentTable + + 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 index 0000000..582dd03 --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/data/data_utils.kt @@ -0,0 +1,15 @@ +package info.mechyrdia.data + +suspend inline fun > DocumentTable.getOrPut(id: Id, 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 index 0000000..5fd9bdb --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/data/nations.kt @@ -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, + val name: String, + val flag: String, + + val isBanned: Boolean = false +) : DataDocument { + companion object : TableHolder { + override val Table = DocumentTable() + + override suspend fun initialize() { + Table.index(NationData::name) + } + + fun unknown(id: Id): 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 = 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, NationData>.getNation(id: Id): NationData { + return getOrPut(id) { + NationData.get(id) + } +} + +val CallCurrentNationAttribute = AttributeKey("CurrentNation") + +suspend fun ApplicationCall.currentNation(): NationData? { + attributes.getOrNull(CallCurrentNationAttribute)?.let { return it } + + return sessions.get()?.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 index 0000000..78c7044 --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/data/view_comments.kt @@ -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, + + 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, nations: MutableMap, NationData> = mutableMapOf()): List { + 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?, 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 index 0000000..9f0762d --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/data/views_comment.kt @@ -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(parameters["id"]!!) + + val comment = Comment.Table.get(commentId)!! + + redirect("/lore/${comment.submittedIn}#comment-$commentId") +} + +suspend fun ApplicationCall.editCommentRoute(): Nothing { + val commentId = Id(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(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(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 index 0000000..6bdf218 --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/data/views_user.kt @@ -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}") +} diff --git a/src/main/kotlin/info/mechyrdia/lore/html_utils.kt b/src/main/kotlin/info/mechyrdia/lore/html_utils.kt index a446df1..5d4e59f 100644 --- a/src/main/kotlin/info/mechyrdia/lore/html_utils.kt +++ b/src/main/kotlin/info/mechyrdia/lore/html_utils.kt @@ -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()}" + } +} diff --git a/src/main/kotlin/info/mechyrdia/lore/http_utils.kt b/src/main/kotlin/info/mechyrdia/lore/http_utils.kt index b3daef9..6ded4da 100644 --- a/src/main/kotlin/info/mechyrdia/lore/http_utils.kt +++ b/src/main/kotlin/info/mechyrdia/lore/http_utils.kt @@ -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) +} diff --git a/src/main/kotlin/info/mechyrdia/lore/parser.kt b/src/main/kotlin/info/mechyrdia/lore/parser.kt index 8737a05..51f416e 100644 --- a/src/main/kotlin/info/mechyrdia/lore/parser.kt +++ b/src/main/kotlin/info/mechyrdia/lore/parser.kt @@ -133,11 +133,11 @@ sealed class TextParserState( override fun processCharacter(char: Char): TextParserState { return if (char == ']') { val tagType = scope.tags[tag] - if (tagType is TextParserTagType.Direct && insideDirectTags.last().equals(tag, ignoreCase = true)) { + if (tagType is TextParserTagType.Direct && 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]") diff --git a/src/main/kotlin/info/mechyrdia/lore/parser_tags.kt b/src/main/kotlin/info/mechyrdia/lore/parser_tags.kt index b9ac1f8..9b4b3f2 100644 --- a/src/main/kotlin/info/mechyrdia/lore/parser_tags.kt +++ b/src/main/kotlin/info/mechyrdia/lore/parser_tags.kt @@ -79,8 +79,8 @@ enum class TextParserFormattingTag(val type: TextParserTagType) { ), CODE( TextParserTagType.Direct( - { _, _ -> "" }, - { "" }, + { _, _ -> "
" },
+			{ "
" }, ) ), H1( @@ -90,31 +90,31 @@ enum class TextParserFormattingTag(val type: TextParserTagType) { ), H2( TextParserTagType.Indirect { _, content, _ -> - val anchor = TextParserToCBuilderTag.headerContentToAnchor(content) - "

$content

${TextParserToCBuilderTag.RETURN_TO_TOP}" + val anchor = headerContentToAnchor(content) + "

$content

" } ), H3( TextParserTagType.Indirect { _, content, _ -> - val anchor = TextParserToCBuilderTag.headerContentToAnchor(content) + val anchor = headerContentToAnchor(content) "

$content

" } ), H4( TextParserTagType.Indirect { _, content, _ -> - val anchor = TextParserToCBuilderTag.headerContentToAnchor(content) + val anchor = headerContentToAnchor(content) "

$content

" } ), H5( TextParserTagType.Indirect { _, content, _ -> - val anchor = TextParserToCBuilderTag.headerContentToAnchor(content) + val anchor = headerContentToAnchor(content) "
$content
" } ), H6( TextParserTagType.Indirect { _, content, _ -> - val anchor = TextParserToCBuilderTag.headerContentToAnchor(content) + val anchor = headerContentToAnchor(content) "
$content
" } ), @@ -204,12 +204,10 @@ enum class TextParserFormattingTag(val type: TextParserTagType) { TD( TextParserTagType.Direct( { tagParam, _ -> - val (width, height) = getSizeParam(tagParam) val sizeAttrs = getTableSizeAttributes(width, height) "" - }, { "" }, ) @@ -217,12 +215,10 @@ enum class TextParserFormattingTag(val type: TextParserTagType) { TH( TextParserTagType.Direct( { tagParam, _ -> - val (width, height) = getSizeParam(tagParam) val sizeAttrs = getTableSizeAttributes(width, height) "" - }, { "" }, ) @@ -286,28 +282,52 @@ enum class TextParserFormattingTag(val type: TextParserTagType) { } else content } ), + ; - // DANGER ZONE - RAWHTML( + companion object { + val asTags: TextParserTags + get() = TextParserTags(values().associate { it.name to it.type }) + } +} + +enum class TextParserCommentTags(val type: TextParserTagType) { + 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) + + ">>$id" } ), + + LANG(TextParserFormattingTag.LANG.type) ; companion object { val asTags: TextParserTags 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 = 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 get() = TextParserTags(values().associate { it.name to it.type }) - - val RETURN_TO_TOP = "

Back to Top

" } } + +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 = 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, "-") diff --git a/src/main/kotlin/info/mechyrdia/lore/parser_toc.kt b/src/main/kotlin/info/mechyrdia/lore/parser_toc.kt index 9aad1df..119b34d 100644 --- a/src/main/kotlin/info/mechyrdia/lore/parser_toc.kt +++ b/src/main/kotlin/info/mechyrdia/lore/parser_toc.kt @@ -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 = links.toList() + fun toNavBar(): List = listOf(NavLink("#page-top", title!!, aClasses = "left")) + links.toList() } diff --git a/src/main/kotlin/info/mechyrdia/lore/preparser.kt b/src/main/kotlin/info/mechyrdia/lore/preparser.kt index 0a1b17f..8cfe070 100644 --- a/src/main/kotlin/info/mechyrdia/lore/preparser.kt +++ b/src/main/kotlin/info/mechyrdia/lore/preparser.kt @@ -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) { + 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) { + fun resolve(): Pair { + 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() - 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}" + } } } diff --git a/src/main/kotlin/info/mechyrdia/lore/view_nav.kt b/src/main/kotlin/info/mechyrdia/lore/view_nav.kt index 7eb6846..7ea5db1 100644 --- a/src/main/kotlin/info/mechyrdia/lore/view_nav.kt +++ b/src/main/kotlin/info/mechyrdia/lore/view_nav.kt @@ -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? = null) = listOf( +suspend fun ApplicationCall.standardNavBar(path: List? = null) = listOf( NavLink("/lore", "Lore Index") ) + path?.let { pathParts -> pathParts.dropLast(1).mapIndexed { i, part -> @@ -13,10 +32,22 @@ fun standardNavBar(path: List? = 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 = emptyMap()) : NavItem() { override fun DIV.display() { a(href = to, classes = aClasses) { + for ((attrName, attrValue) in attributes) + attributes[attrName] = attrValue + +text } } diff --git a/src/main/kotlin/info/mechyrdia/lore/views_error.kt b/src/main/kotlin/info/mechyrdia/lore/views_error.kt index 6b37ea2..6c6771d 100644 --- a/src/main/kotlin/info/mechyrdia/lore/views_error.kt +++ b/src/main/kotlin/info/mechyrdia/lore/views_error.kt @@ -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." } } } diff --git a/src/main/kotlin/info/mechyrdia/lore/views_lore.kt b/src/main/kotlin/info/mechyrdia/lore/views_lore.kt index 9c42923..d4743c2 100644 --- a/src/main/kotlin/info/mechyrdia/lore/views_lore.kt +++ b/src/main/kotlin/info/mechyrdia/lore/views_lore.kt @@ -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) + } } } } diff --git a/src/main/kotlin/info/mechyrdia/lore/views_prefs.kt b/src/main/kotlin/info/mechyrdia/lore/views_prefs.kt index fc707d6..cd03f1a 100644 --- a/src/main/kotlin/info/mechyrdia/lore/views_prefs.kt +++ b/src/main/kotlin/info/mechyrdia/lore/views_prefs.kt @@ -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") +} diff --git a/src/main/resources/static/init.js b/src/main/resources/static/init.js index b816e8d..ed9a1fe 100644 --- a/src/main/resources/static/init.js +++ b/src/main/resources/static/init.js @@ -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) { @@ -34,4 +38,177 @@ }); } }); + + 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 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(); + }); + } + }); })(); diff --git a/src/main/resources/static/style.css b/src/main/resources/static/style.css index 023c307..25c7bfa 100644 --- a/src/main/resources/static/style.css +++ b/src/main/resources/static/style.css @@ -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; +}