Switch to type-safe resource routing
authorLanius Trolling <lanius@laniustrolling.dev>
Fri, 29 Mar 2024 15:15:53 +0000 (11:15 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Fri, 29 Mar 2024 15:15:53 +0000 (11:15 -0400)
29 files changed:
build.gradle.kts
src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt
src/jvmMain/kotlin/info/mechyrdia/auth/csrf.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/auth/views_login.kt
src/jvmMain/kotlin/info/mechyrdia/data/view_comments.kt
src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt
src/jvmMain/kotlin/info/mechyrdia/data/views_user.kt
src/jvmMain/kotlin/info/mechyrdia/data/visits.kt
src/jvmMain/kotlin/info/mechyrdia/lore/april_1st.kt
src/jvmMain/kotlin/info/mechyrdia/lore/article_listing.kt
src/jvmMain/kotlin/info/mechyrdia/lore/asset_compression.kt
src/jvmMain/kotlin/info/mechyrdia/lore/file_data.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/fonts.kt
src/jvmMain/kotlin/info/mechyrdia/lore/http_utils.kt
src/jvmMain/kotlin/info/mechyrdia/lore/parser_plain.kt
src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt
src/jvmMain/kotlin/info/mechyrdia/lore/parser_tags.kt
src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt
src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt
src/jvmMain/kotlin/info/mechyrdia/lore/views_error.kt
src/jvmMain/kotlin/info/mechyrdia/lore/views_lore.kt
src/jvmMain/kotlin/info/mechyrdia/lore/views_prefs.kt
src/jvmMain/kotlin/info/mechyrdia/lore/views_quote.kt
src/jvmMain/kotlin/info/mechyrdia/route/resource_bodies.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/route/resource_csrf.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/route/resource_handler.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt [new file with mode: 0644]
src/jvmMain/resources/static/init.js
src/jvmMain/resources/static/raw.css

index 488b63b8bcda816ade719ba5f99b4b7489dd29d0..5fc9a9d66a475a1e91bcb07b386e7f74d2a233b7 100644 (file)
@@ -29,8 +29,8 @@ buildscript {
 
 plugins {
        java
-       kotlin("multiplatform") version "1.9.22"
-       kotlin("plugin.serialization") version "1.9.22"
+       kotlin("multiplatform") version "1.9.23"
+       kotlin("plugin.serialization") version "1.9.23"
        id("com.github.johnrengelman.shadow") version "7.1.2"
        application
 }
@@ -124,11 +124,15 @@ kotlin {
                                implementation("io.ktor:ktor-server-call-id:2.3.9")
                                implementation("io.ktor:ktor-server-call-logging:2.3.9")
                                implementation("io.ktor:ktor-server-conditional-headers:2.3.9")
+                               implementation("io.ktor:ktor-server-content-negotiation:2.3.9")
                                implementation("io.ktor:ktor-server-forwarded-header:2.3.9")
                                implementation("io.ktor:ktor-server-html-builder:2.3.9")
+                               implementation("io.ktor:ktor-server-resources:2.3.9")
                                implementation("io.ktor:ktor-server-sessions-jvm:2.3.9")
                                implementation("io.ktor:ktor-server-status-pages:2.3.9")
                                
+                               implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.9")
+                               
                                implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0")
                                
                                implementation("io.pebbletemplates:pebble:3.2.2")
index f8884340876f852dccd466ad501ef26fff4e5b2b..89b777f01a09422ca2a9c82dd409c64f1fc70324 100644 (file)
@@ -5,8 +5,10 @@ package info.mechyrdia
 import info.mechyrdia.auth.*
 import info.mechyrdia.data.*
 import info.mechyrdia.lore.*
+import info.mechyrdia.route.*
 import io.ktor.http.*
 import io.ktor.http.content.*
+import io.ktor.serialization.kotlinx.*
 import io.ktor.server.application.*
 import io.ktor.server.cio.*
 import io.ktor.server.engine.*
@@ -18,18 +20,16 @@ import io.ktor.server.plugins.cachingheaders.*
 import io.ktor.server.plugins.callid.*
 import io.ktor.server.plugins.callloging.*
 import io.ktor.server.plugins.conditionalheaders.*
+import io.ktor.server.plugins.contentnegotiation.*
 import io.ktor.server.plugins.forwardedheaders.*
 import io.ktor.server.plugins.statuspages.*
 import io.ktor.server.request.*
+import io.ktor.server.resources.*
 import io.ktor.server.response.*
 import io.ktor.server.routing.*
 import io.ktor.server.sessions.*
 import io.ktor.server.sessions.serialization.*
-import io.ktor.util.*
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runInterruptible
 import org.slf4j.event.Level
-import java.io.File
 import java.io.IOException
 import java.util.concurrent.atomic.AtomicLong
 
@@ -52,6 +52,11 @@ fun Application.factbooks() {
        install(AutoHeadResponse)
        install(IgnoreTrailingSlash)
        
+       val resourcesPlugin = install(Resources)
+       install(ContentNegotiation) {
+               register(ContentType.Application.FormUrlEncoded, KotlinxSerializationConverter(FormUrlEncodedFormat(resourcesPlugin.resourcesFormat)))
+       }
+       
        install(XForwardedHeaders) {
                useLastProxy()
        }
@@ -99,9 +104,9 @@ fun Application.factbooks() {
                        
                        serializer = KotlinxSessionSerializer(UserSession.serializer(), JsonStorageCodec)
                        
+                       cookie.secure = true
                        cookie.httpOnly = true
                        cookie.extensions["SameSite"] = "Lax"
-                       cookie.extensions["Secure"] = null
                }
        }
        
@@ -113,17 +118,14 @@ fun Application.factbooks() {
                exception<HttpRedirectException> { call, (url, permanent) ->
                        call.respondRedirect(url, permanent)
                }
-               exception<AprilFoolsStaticFileRedirectException> { call, (replacement) ->
-                       call.respondCompressedFile(replacement)
-               }
                exception<MissingRequestParameterException> { call, _ ->
                        call.respondHtml(HttpStatusCode.BadRequest, call.error400())
                }
                exception<ForbiddenException> { call, _ ->
                        call.respondHtml(HttpStatusCode.Forbidden, call.error403())
                }
-               exception<CsrfFailedException> { call, (_, params) ->
-                       call.respondHtml(HttpStatusCode.Forbidden, call.error403PageExpired(params))
+               exception<CsrfFailedException> { call, (_, payload) ->
+                       call.respondHtml(HttpStatusCode.Forbidden, call.error403PageExpired(payload))
                }
                exception<NullPointerException> { call, _ ->
                        call.respondHtml(HttpStatusCode.NotFound, call.error404())
@@ -141,178 +143,37 @@ fun Application.factbooks() {
        }
        
        routing {
-               get("/") {
-                       call.respondHtml(HttpStatusCode.OK, call.loreIntroPage())
-               }
-               
-               // Factbooks and assets
-               
                staticResources("/static", "static", index = null) {
                        preCompressed(CompressedFileType.BROTLI, CompressedFileType.GZIP)
                }
                
-               get("/lore/{path...}") {
-                       call.respondHtml(HttpStatusCode.OK, call.loreArticlePage())
-               }
-               
-               get("/lore.raw") {
-                       call.respondHtml(HttpStatusCode.OK, call.loreRawArticlePage(""))
-               }
-               
-               get("/assets/{path...}") {
-                       val assetPath = call.parameters.getAll("path")?.joinToString(separator = File.separator) ?: return@get
-                       val assetFile = File(Configuration.CurrentConfiguration.assetDir).combineSafe(assetPath)
-                       
-                       redirectAssetOnApril1st(assetFile)
-                       call.respondCompressedFile(assetFile)
-               }
-               
-               get("/map") {
-                       call.respondFile(call.galaxyMapPage())
-               }
-               
-               // Random quote
-               
-               get("/quote") {
-                       with(call) { respondHtml(HttpStatusCode.OK, randomQuote().toHtml("Random Quote")) }
-               }
-               
-               get("/quote.raw") {
-                       with(call) { respondHtml(HttpStatusCode.OK, randomQuote().toRawHtml("Random Quote")) }
-               }
-               
-               get("/quote.json") {
-                       call.respondText(randomQuote().toJson(), ContentType.Application.Json)
-               }
-               
-               get("/quote.xml") {
-                       call.respondText(randomQuote().toXml(), ContentType.Application.Xml)
-               }
-               
-               // Routes for robots
-               
-               get("/robots.txt") {
-                       call.respondFile(File(Configuration.CurrentConfiguration.rootDir).combineSafe("robots.txt"))
-               }
-               
-               get("/sitemap.xml") {
-                       call.respondText(buildString { generateSitemap() }, ContentType.Application.Xml)
-               }
-               
-               // Routes for cyborgs
-               
-               get("/edits.rss") {
-                       call.respondText(buildString { generateRecentPageEdits() }, ContentType.Application.Rss)
-               }
-               
-               get("/comments.rss") {
-                       call.respondText(buildString(call.recentCommentsRssFeedGenerator()), ContentType.Application.Rss)
-               }
-               
-               // Client settings
-               
-               get("/change-theme") {
-                       call.respondHtml(HttpStatusCode.OK, call.changeThemePage())
-               }
-               
-               post("/change-theme") {
-                       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())
-               }
-               
-               get("/comment/recent") {
-                       call.respondHtml(HttpStatusCode.OK, call.recentCommentsPage())
-               }
-               
-               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("/mechyrdia-sans") {
-                       val queryString = call.request.queryParameters
-                       
-                       val isBold = "true".equals(queryString["bold"], ignoreCase = true)
-                       val isItalic = "true".equals(queryString["italic"], ignoreCase = true)
-                       
-                       val alignArg = queryString["align"]
-                       val align = MechyrdiaSansFont.Alignment.entries.singleOrNull {
-                               it.name.equals(alignArg, ignoreCase = true)
-                       } ?: MechyrdiaSansFont.Alignment.LEFT
-                       
-                       val text = call.receiveText()
-                       val svg = runInterruptible(Dispatchers.Default) {
-                               MechyrdiaSansFont.renderTextToSvg(text.trim(), isBold, isItalic, align)
-                       }
-                       
-                       call.respondText(svg, ContentType.Image.SVG)
-               }
-               
-               post("/tylan-lang") {
-                       call.respondText(TylanAlphabetFont.tylanToFontAlphabet(call.receiveText()))
-               }
-               
-               post("/pokhwal-lang") {
-                       call.respondText(PokhwalishAlphabetFont.pokhwalToFontAlphabet(call.receiveText()))
-               }
-               
-               post("/preview-comment") {
-                       call.respondText(
-                               text = TextParserState.parseText(call.receiveText(), TextParserCommentTags.asTags, Unit),
-                               contentType = ContentType.Text.Html
-                       )
-               }
+               get<Root>()
+               get<Root.AssetFile>()
+               get<Root.LorePage>()
+               get<Root.GalaxyMap>()
+               get<Root.RandomQuote>()
+               get<Root.RobotsTxt>()
+               get<Root.SitemapXml>()
+               get<Root.RecentEditsRss>()
+               get<Root.RecentCommentsRss>()
+               get<Root.ClientPreferences>()
+               get<Root.Auth.LoginPage>()
+               post<Root.Auth.LoginPost, _>()
+               post<Root.Auth.LogoutPost, _>()
+               get<Root.Comments.HelpPage>()
+               get<Root.Comments.RecentPage>()
+               post<Root.Comments.NewPost, _>()
+               get<Root.Comments.ViewPage>()
+               post<Root.Comments.EditPost, _>()
+               get<Root.Comments.DeleteConfirmPage>()
+               post<Root.Comments.DeleteConfirmPost, _>()
+               get<Root.User>()
+               get<Root.User.ById>()
+               post<Root.Admin.Ban, _>()
+               post<Root.Admin.Unban, _>()
+               post<Root.Utils.MechyrdiaSans, _>()
+               post<Root.Utils.TylanLanguage, _>()
+               post<Root.Utils.PokhwalishLanguage, _>()
+               post<Root.Utils.PreviewComment, _>()
        }
 }
diff --git a/src/jvmMain/kotlin/info/mechyrdia/auth/csrf.kt b/src/jvmMain/kotlin/info/mechyrdia/auth/csrf.kt
deleted file mode 100644 (file)
index cea7811..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-package info.mechyrdia.auth
-
-import info.mechyrdia.data.Id
-import info.mechyrdia.data.NationData
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.server.plugins.*
-import io.ktor.server.request.*
-import io.ktor.server.sessions.*
-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 userAccount: Id<NationData>?,
-       val expires: Instant
-)
-
-fun ApplicationCall.csrfPayload(route: String, withExpiration: Instant = Instant.now().plusSeconds(7200)) =
-       CsrfPayload(
-               route = route,
-               remoteAddress = request.origin.remoteAddress,
-               userAgent = request.userAgent(),
-               userAccount = sessions.get<UserSession>()?.nationId,
-               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["csrf-token"] ?: throw CsrfFailedException("No CSRF token was provided", params)
-       
-       val check = csrfMap.remove(token) ?: throw CsrfFailedException("The submitted CSRF token does not exist", params)
-       val payload = csrfPayload(route, check.expires)
-       if (check != payload)
-               throw CsrfFailedException("The submitted CSRF token does not match", params)
-       if (payload.expires < Instant.now())
-               throw CsrfFailedException("The submitted CSRF token has expired", params)
-       
-       return params
-}
-
-data class CsrfFailedException(override val message: String, val formData: Parameters) : RuntimeException(message)
index 3cd4df130066348d4c40ea8b333b5dddfec3aa11..e34871475614db709d4954d02f3889f5802cd266 100644 (file)
@@ -4,16 +4,18 @@ 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.redirectHref
 import info.mechyrdia.lore.standardNavBar
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.href
+import info.mechyrdia.route.installCsrfToken
 import io.ktor.server.application.*
 import io.ktor.server.plugins.*
 import io.ktor.server.sessions.*
-import io.ktor.server.util.*
 import io.ktor.util.*
 import kotlinx.html.*
 import java.util.concurrent.ConcurrentHashMap
+import kotlin.collections.set
 
 val PageDoNotCacheAttributeKey = AttributeKey<Boolean>("Mechyrdia.PageDoNotCache")
 
@@ -30,8 +32,8 @@ suspend fun ApplicationCall.loginPage(): HTML.() -> Unit {
                
                section {
                        h1 { +"Log In With NationStates" }
-                       form(method = FormMethod.post, action = "/auth/login") {
-                               installCsrfToken(createCsrfToken())
+                       form(method = FormMethod.post, action = href(Root.Auth.LoginPost())) {
+                               installCsrfToken()
                                
                                hiddenInput {
                                        name = "token"
@@ -67,38 +69,33 @@ suspend fun ApplicationCall.loginPage(): HTML.() -> Unit {
        }
 }
 
-suspend fun ApplicationCall.loginRoute(): Nothing {
-       val postParams = verifyCsrfToken()
-       
-       val nation = postParams.getOrFail("nation").toNationId()
-       val checksum = postParams.getOrFail("checksum")
-       val nsToken = nsTokenMap.remove(postParams.getOrFail("token"))
+suspend fun ApplicationCall.loginRoute(nation: String, checksum: String, token: String): Nothing {
+       val nationId = nation.toNationId()
+       val nsToken = nsTokenMap.remove(token)
                ?: throw MissingRequestParameterException("token")
        
        val result = NSAPI
-               .verifyAndGetNation(nation, checksum)
+               .verifyAndGetNation(nationId, checksum)
                .token("mechyrdia_$nsToken")
                .shards(NationShard.NAME, NationShard.FLAG_URL)
                .executeSuspend()
-               ?: redirectWithError("/auth/login", "That nation does not exist.")
+               ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "That nation does not exist."))))
        
        if (!result.isVerified)
-               redirectWithError("/auth/login", "Checksum failed verification.")
+               redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "Checksum failed verification."))))
        
        val nationData = NationData(Id(result.id), result.name, result.flagUrl)
        NationData.Table.put(nationData)
        
        sessions.set(UserSession(nationData.id))
        
-       redirect("/")
+       redirectHref(Root.User())
 }
 
 suspend fun ApplicationCall.logoutRoute(): Nothing {
-       verifyCsrfToken()
-       
        val sessId = sessionId<UserSession>()
        sessions.clear<UserSession>()
        sessId?.let { id -> SessionStorageDoc.Table.del(Id(id)) }
        
-       redirect("/")
+       redirectHref(Root())
 }
index 732885953985d5d86de55f70ffab2dd35ffb1c8b..631706bc15888b06f9c5df78a3051b14ec85db16 100644 (file)
@@ -1,11 +1,12 @@
 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 info.mechyrdia.route.Root
+import info.mechyrdia.route.href
+import info.mechyrdia.route.installCsrfToken
 import io.ktor.server.application.*
 import kotlinx.coroutines.async
 import kotlinx.coroutines.awaitAll
@@ -17,7 +18,7 @@ data class CommentRenderData(
        val id: Id<Comment>,
        
        val submittedBy: NationData,
-       val submittedIn: String,
+       val submittedIn: List<String>,
        val submittedAt: Instant,
        
        val numEdits: Int,
@@ -39,7 +40,7 @@ data class CommentRenderData(
                                                CommentRenderData(
                                                        id = comment.id,
                                                        submittedBy = nationData,
-                                                       submittedIn = comment.submittedIn,
+                                                       submittedIn = comment.submittedIn.split('/'),
                                                        submittedAt = comment.submittedAt,
                                                        numEdits = comment.numEdits,
                                                        lastEdit = comment.lastEdit,
@@ -63,8 +64,8 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData
                p {
                        style = "font-size:1.5em;margin-top:2.5em"
                        +"On factbook "
-                       a(href = "/lore/${comment.submittedIn}") {
-                               +comment.submittedIn
+                       a(href = href(Root.LorePage(comment.submittedIn))) {
+                               +comment.submittedIn.joinToString(separator = "/")
                        }
                }
        
@@ -74,7 +75,7 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData
                        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}") {
+                               a(href = href(Root.User.ById(comment.submittedBy.id))) {
                                        +comment.submittedBy.name
                                }
                        }
@@ -88,19 +89,18 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData
                        comment.lastEdit?.let { lastEdit ->
                                p {
                                        style = "font-size:0.8em"
-                                       val nounSuffix = if (comment.numEdits != 1) "s" else ""
-                                       +"Edited ${comment.numEdits} time$nounSuffix, last edited at "
+                                       +"Edited ${comment.numEdits} ${comment.numEdits.pluralize("time")}, last edited at "
                                        dateTime(lastEdit)
                                }
                        }
                        p {
                                style = "font-size:0.8em"
-                               a(href = "/comment/view/${comment.id}") {
+                               a(href = href(Root.Comments.ViewPage(comment.id))) {
                                        +"Permalink"
                                }
                                +Entities.nbsp
                                a(href = "#", classes = "copy-text") {
-                                       attributes["data-text"] = "https://mechyrdia.info/comment/view/${comment.id}"
+                                       attributes["data-text"] = "https://mechyrdia.info${href(Root.Comments.ViewPage(comment.id))}"
                                        +"(Copy)"
                                }
                                
@@ -136,7 +136,7 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData
                                        +Entities.nbsp
                                        +"\u2022"
                                        +Entities.nbsp
-                                       a(href = "/comment/delete/${comment.id}", classes = "comment-delete-link") {
+                                       a(href = href(Root.Comments.DeleteConfirmPage(comment.id)), classes = "comment-delete-link") {
                                                +"Delete"
                                        }
                                }
@@ -147,7 +147,7 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData
                                        +"Replies:"
                                        for (reply in comment.replyLinks) {
                                                +" "
-                                               a(href = "/comment/view/$reply") {
+                                               a(href = href(Root.Comments.ViewPage(reply))) {
                                                        +">>$reply"
                                                }
                                        }
@@ -156,7 +156,7 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData
        }
        
        if (loggedInAs == comment.submittedBy.id) {
-               val formPath = "/comment/edit/${comment.id}"
+               val formPath = href(Root.Comments.EditPost(comment.id))
                form(action = formPath, method = FormMethod.post, classes = "comment-input comment-edit-box") {
                        id = "comment-edit-box-${comment.id}"
                        div(classes = "comment-preview")
@@ -164,7 +164,7 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData
                                name = "comment"
                                +comment.contentsRaw
                        }
-                       installCsrfToken(createCsrfToken(formPath))
+                       installCsrfToken()
                        submitInput { value = "Edit Comment" }
                        button(classes = "comment-cancel-edit evil") {
                                +"Cancel Editing"
@@ -174,22 +174,21 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData
 }
 
 context(ApplicationCall)
-fun FlowContent.commentInput(commentingOn: String, commentingAs: NationData?) {
+fun FlowContent.commentInput(pagePathParts: List<String>, commentingAs: NationData?) {
        if (commentingAs == null) {
                p {
-                       a(href = "/auth/login") { +"Log in" }
+                       a(href = href(Root.Auth.LoginPage())) { +"Log in" }
                        +" to comment"
                }
                return
        }
        
-       val formPath = "/comment/new/$commentingOn"
-       form(action = formPath, method = FormMethod.post, classes = "comment-input") {
+       form(action = href(Root.Comments.NewPost(path = pagePathParts)), method = FormMethod.post, classes = "comment-input") {
                div(classes = "comment-preview")
                textArea(classes = "comment-markup") {
                        name = "comment"
                }
-               installCsrfToken(createCsrfToken(formPath))
+               installCsrfToken()
                submitInput { value = "Submit Comment" }
        }
 }
index 35cedfc7ae9f3f372f0c8313c579b1316ecf72a2..154464507bb92bc8603f3827ce9b7cc3f8226e38 100644 (file)
@@ -3,33 +3,27 @@ package info.mechyrdia.data
 import com.mongodb.client.model.Sorts
 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.http.*
+import info.mechyrdia.route.ErrorMessageAttributeKey
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.href
+import info.mechyrdia.route.installCsrfToken
 import io.ktor.server.application.*
-import io.ktor.server.util.*
 import kotlinx.coroutines.flow.filterNot
 import kotlinx.coroutines.flow.take
 import kotlinx.coroutines.flow.toList
 import kotlinx.html.*
 import java.time.Instant
 
-suspend fun ApplicationCall.recentCommentsPage(): HTML.() -> Unit {
-       val currNation = currentNation()
+suspend fun ApplicationCall.recentCommentsPage(limit: Int?): HTML.() -> Unit {
+       limit ?: redirectHref(Root.Comments.RecentPage(10))
        
-       val limit = request.queryParameters["limit"]?.toIntOrNull() ?: redirect("/comment/recent?limit=10")
+       val currNation = currentNation()
        
        val validLimits = listOf(10, 20, 50, 80, 100)
        
        if (limit !in validLimits)
-               redirect(
-                       "/comment/recent?" + listOf(
-                               "limit" to "10",
-                               "error" to "Invalid limit $limit, must be one of ${validLimits.joinToString()}"
-                       ).formUrlEncode()
-               )
+               redirectHref(Root.Comments.RecentPage(limit = 10))
        
        val comments = CommentRenderData(
                Comment.Table
@@ -53,9 +47,11 @@ suspend fun ApplicationCall.recentCommentsPage(): HTML.() -> Unit {
                                                +Entities.nbsp
                                        
                                        if (limit == validLimit)
-                                               +"$validLimit"
+                                               strong {
+                                                       +"$validLimit"
+                                               }
                                        else
-                                               a(href = "/comment/recent?limit=$validLimit") {
+                                               a(href = href(Root.Comments.RecentPage(limit = validLimit))) {
                                                        +"$validLimit"
                                                }
                                }
@@ -67,22 +63,16 @@ suspend fun ApplicationCall.recentCommentsPage(): HTML.() -> Unit {
        }
 }
 
-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", "comments")
-       
-       val contents = formParams.getOrFail("comment")
+suspend fun ApplicationCall.newCommentRoute(pagePathParts: List<String>, contents: String): Nothing {
+       val loggedInAs = currentNation() ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "You must be logged in to write comments"))))
        
        if (contents.isBlank())
-               redirectWithError("/lore/$pagePath", "Comments may not be blank", "comments")
+               redirectHref(Root.LorePage(pagePathParts, root = Root(error = "Comments may not be blank")))
        
        val comment = Comment(
                id = Id(),
                submittedBy = loggedInAs.id,
-               submittedIn = pagePath,
+               submittedIn = pagePathParts.joinToString("/"),
                submittedAt = Instant.now(),
                
                numEdits = 0,
@@ -94,12 +84,10 @@ suspend fun ApplicationCall.newCommentRoute(): Nothing {
        Comment.Table.put(comment)
        CommentReplyLink.updateComment(comment.id, getReplies(contents))
        
-       redirect("/lore/$pagePath#comment-${comment.id}")
+       redirectHref(Root.LorePage(pagePathParts), hash = "comment-${comment.id}")
 }
 
-suspend fun ApplicationCall.viewCommentRoute(): Nothing {
-       val commentId = Id<Comment>(parameters.getOrFail("id"))
-       
+suspend fun ApplicationCall.viewCommentRoute(commentId: Id<Comment>): Nothing {
        val comment = Comment.Table.get(commentId)!!
        
        val currentNation = currentNation()
@@ -108,32 +96,25 @@ suspend fun ApplicationCall.viewCommentRoute(): Nothing {
        if (submitter.isBanned && currentNation?.id != comment.submittedBy && currentNation?.id != OwnerNationId)
                throw NullPointerException("Shadowbanned comment")
        
-       val queryParams = if (request.queryParameters.isEmpty())
-               ""
-       else "?${request.queryParameters.formUrlEncode()}"
-       
-       redirect("/lore/${comment.submittedIn}$queryParams#comment-$commentId")
+       val pagePathParts = comment.submittedIn.split('/')
+       val errorMessage = attributes.getOrNull(ErrorMessageAttributeKey)
+       redirectHref(Root.LorePage(pagePathParts, root = Root(errorMessage)), hash = "comment-$commentId")
 }
 
-suspend fun ApplicationCall.editCommentRoute(): Nothing {
-       val commentId = Id<Comment>(parameters.getOrFail("id"))
-       
+suspend fun ApplicationCall.editCommentRoute(commentId: Id<Comment>, newContents: String): Nothing {
        val oldComment = Comment.Table.get(commentId)!!
        
-       val formParams = verifyCsrfToken()
-       val currNation = currentNation() ?: redirectWithError("/auth/login", "You must be logged in to edit comments")
+       val currNation = currentNation() ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root("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")
-       
        if (newContents.isBlank())
-               redirectWithError("/comment/view/$commentId", "Comments may not be blank")
+               redirectHref(Root.Comments.ViewPage(oldComment.id, Root.Comments(Root("Comments may not be blank"))))
        
        // Check for null edits, i.e. edits that don't change anything
        if (newContents == oldComment.contents)
-               redirect("/comment/view/$commentId")
+               redirectHref(Root.Comments.ViewPage(oldComment.id))
        
        val newComment = oldComment.copy(
                numEdits = oldComment.numEdits + 1,
@@ -144,13 +125,11 @@ suspend fun ApplicationCall.editCommentRoute(): Nothing {
        Comment.Table.put(newComment)
        CommentReplyLink.updateComment(commentId, getReplies(newContents))
        
-       redirect("/comment/view/$commentId")
+       redirectHref(Root.Comments.ViewPage(oldComment.id))
 }
 
-private suspend fun ApplicationCall.getCommentForDeletion(): Pair<NationData, Comment> {
-       val currNation = currentNation() ?: redirectWithError("/auth/login", "You must be logged in to delete comments")
-       
-       val commentId = Id<Comment>(parameters.getOrFail("id"))
+private suspend fun ApplicationCall.getCommentForDeletion(commentId: Id<Comment>): Pair<NationData, Comment> {
+       val currNation = currentNation() ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root("You must be logged in to delete comments"))))
        val comment = Comment.Table.get(commentId)!!
        
        if (currNation.id != comment.submittedBy && currNation.id != OwnerNationId)
@@ -159,8 +138,8 @@ private suspend fun ApplicationCall.getCommentForDeletion(): Pair<NationData, Co
        return currNation to comment
 }
 
-suspend fun ApplicationCall.deleteCommentPage(): HTML.() -> Unit {
-       val (currNation, comment) = getCommentForDeletion()
+suspend fun ApplicationCall.deleteCommentPage(commentId: Id<Comment>): HTML.() -> Unit {
+       val (currNation, comment) = getCommentForDeletion(commentId)
        
        val commentDisplay = CommentRenderData(listOf(comment), nationCache).single()
        
@@ -173,24 +152,25 @@ suspend fun ApplicationCall.deleteCommentPage(): HTML.() -> Unit {
                        
                        commentBox(commentDisplay, currNation.id)
                        
-                       form(method = FormMethod.get, action = "/comment/view/${comment.id}") {
+                       form(method = FormMethod.get, action = href(Root.Comments.ViewPage(comment.id))) {
                                submitInput { value = "No, take me back" }
                        }
-                       form(method = FormMethod.post, action = "/comment/delete/$comment.id") {
-                               installCsrfToken(createCsrfToken())
+                       form(method = FormMethod.post, action = href(Root.Comments.DeleteConfirmPost(comment.id))) {
+                               installCsrfToken()
                                submitInput(classes = "evil") { value = "Yes, delete it" }
                        }
                }
        }
 }
 
-suspend fun ApplicationCall.deleteCommentRoute(): Nothing {
-       val (_, comment) = getCommentForDeletion()
+suspend fun ApplicationCall.deleteCommentRoute(commentId: Id<Comment>): Nothing {
+       val (_, comment) = getCommentForDeletion(commentId)
        
        Comment.Table.del(comment.id)
        CommentReplyLink.deleteComment(comment.id)
        
-       redirect("/lore/${comment.submittedIn}#comments")
+       val pagePathParts = comment.submittedIn.split('/')
+       redirectHref(Root.LorePage(pagePathParts), hash = "comments")
 }
 
 suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commenting Help", standardNavBar()) {
@@ -471,15 +451,6 @@ suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commentin
                                                        br
                                                        +"The tag param controls the width and height, much like a table cell. The size unit is viewport-responsive and has no correlation with pixels."
                                                }
-                                               p {
-                                                       +"A similar tag is used to embed images that are hosted on Imgur, e.g. the image at https://i.imgur.com/dd0mmQ1.png"
-                                                       br
-                                                       img(src = "https://i.imgur.com/dd0mmQ1.png") {
-                                                               style = getImageSizeStyleValue(250, 323)
-                                                       }
-                                                       br
-                                                       +"can be embedded using [imgur=250x323]dd0mmQ1.png[/imgur]"
-                                               }
                                        }
                                }
                                tr {
@@ -518,7 +489,7 @@ suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commentin
                                        td {
                                                +"Writes text in the Pokhwalish alphabet: "
                                                span(classes = "lang-pokhwal") {
-                                                       +PokhwalishAlphabetFont.pokhwalToFontAlphabet("pokhvalsqo jargo")
+                                                       +PokhwalishAlphabetFont.pokhwalToFontAlphabet("pokhvalsqo jaargo")
                                                }
                                        }
                                }
index eefd542d80ee28e7d287d48ff7145caa45ed39a9..4b720232b336dd6d62d4840ea5c7fe78655c2e5e 100644 (file)
@@ -2,20 +2,30 @@ package info.mechyrdia.data
 
 import com.mongodb.client.model.Updates
 import info.mechyrdia.OwnerNationId
-import info.mechyrdia.auth.createCsrfToken
-import info.mechyrdia.auth.verifyCsrfToken
+import info.mechyrdia.auth.UserSession
 import info.mechyrdia.lore.NationProfileSidebar
 import info.mechyrdia.lore.page
-import info.mechyrdia.lore.redirect
+import info.mechyrdia.lore.redirectHref
 import info.mechyrdia.lore.standardNavBar
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.href
+import info.mechyrdia.route.installCsrfToken
 import io.ktor.server.application.*
-import io.ktor.server.util.*
+import io.ktor.server.sessions.*
 import kotlinx.coroutines.flow.toList
 import kotlinx.html.*
 
-suspend fun ApplicationCall.userPage(): HTML.() -> Unit {
+fun ApplicationCall.currentUserPage(): Nothing {
+       val currNationId = sessions.get<UserSession>()?.nationId
+       if (currNationId == null)
+               redirectHref(Root.Auth.LoginPage())
+       else
+               redirectHref(Root.User.ById(currNationId))
+}
+
+suspend fun ApplicationCall.userPage(userId: Id<NationData>): HTML.() -> Unit {
        val currNation = currentNation()
-       val viewingNation = nationCache.getNation(Id(parameters.getOrFail("id")))
+       val viewingNation = nationCache.getNation(userId)
        
        val comments = CommentRenderData(
                Comment.getCommentsBy(viewingNation.id).toList(),
@@ -29,17 +39,15 @@ suspend fun ApplicationCall.userPage(): HTML.() -> Unit {
                        if (currNation?.id == OwnerNationId) {
                                if (viewingNation.isBanned) {
                                        p { +"This user is banned" }
-                                       val unbanLink = "/admin/unban/${viewingNation.id}"
+                                       val unbanLink = href(Root.Admin.Unban(viewingNation.id))
                                        a(href = unbanLink) {
-                                               attributes["data-method"] = "post"
-                                               attributes["data-csrf-token"] = createCsrfToken(unbanLink)
+                                               installCsrfToken(unbanLink)
                                                +"Unban"
                                        }
                                } else {
-                                       val banLink = "/admin/ban/${viewingNation.id}"
+                                       val banLink = href(Root.Admin.Ban(viewingNation.id))
                                        a(href = banLink) {
-                                               attributes["data-method"] = "post"
-                                               attributes["data-csrf-token"] = createCsrfToken(banLink)
+                                               installCsrfToken(banLink)
                                                +"Ban"
                                        }
                                }
@@ -50,26 +58,20 @@ suspend fun ApplicationCall.userPage(): HTML.() -> Unit {
        }
 }
 
-suspend fun ApplicationCall.adminBanUserRoute(): Nothing {
-       ownerNationOnly()
-       verifyCsrfToken()
-       
-       val bannedNation = nationCache.getNation(Id(parameters.getOrFail("id")))
+suspend fun ApplicationCall.adminBanUserRoute(userId: Id<NationData>): Nothing {
+       val bannedNation = nationCache.getNation(userId)
        
        if (!bannedNation.isBanned)
                NationData.Table.set(bannedNation.id, Updates.set(NationData::isBanned.serialName, true))
        
-       redirect("/user/${bannedNation.id}")
+       redirectHref(Root.User.ById(userId))
 }
 
-suspend fun ApplicationCall.adminUnbanUserRoute(): Nothing {
-       ownerNationOnly()
-       verifyCsrfToken()
-       
-       val bannedNation = nationCache.getNation(Id(parameters.getOrFail("id")))
+suspend fun ApplicationCall.adminUnbanUserRoute(userId: Id<NationData>): Nothing {
+       val bannedNation = nationCache.getNation(userId)
        
        if (bannedNation.isBanned)
                NationData.Table.set(bannedNation.id, Updates.set(NationData::isBanned.serialName, false))
        
-       redirect("/user/${bannedNation.id}")
+       redirectHref(Root.User.ById(userId))
 }
index 335b7b81456dde53850213ed2de3de7ccac856e8..2f1fa1024e9514dc5748fd086aa48b1b5a97e321 100644 (file)
@@ -22,8 +22,8 @@ import java.time.Instant
 
 @Serializable
 data class PageVisitTotals(
-       val total: Long,
-       val totalUnique: Long,
+       val total: Int,
+       val totalUnique: Int,
        val mostRecent: @Serializable(with = InstantNullableSerializer::class) Instant?
 )
 
@@ -34,7 +34,7 @@ data class PageVisitData(
        
        val path: String,
        val visitor: String,
-       val visits: Long = 0L,
+       val visits: Int = 0,
        val lastVisit: @Serializable(with = InstantSerializer::class) Instant = Instant.now()
 ) : DataDocument<PageVisitData> {
        companion object : TableHolder<PageVisitData> {
@@ -67,11 +67,11 @@ data class PageVisitData(
                                        Aggregates.group(
                                                null,
                                                Accumulators.sum(PageVisitTotals::total.serialName, "\$${PageVisitData::visits.serialName}"),
-                                               Accumulators.sum(PageVisitTotals::totalUnique.serialName, 1L),
+                                               Accumulators.sum(PageVisitTotals::totalUnique.serialName, 1),
                                                Accumulators.max(PageVisitTotals::mostRecent.serialName, "\$${PageVisitData::lastVisit.serialName}"),
                                        )
                                )
-                       ).firstOrNull() ?: PageVisitTotals(0L, 0L, null)
+                       ).firstOrNull() ?: PageVisitTotals(0, 0, null)
                }
        }
 }
@@ -98,7 +98,7 @@ suspend fun ApplicationCall.processGuestbook(): PageVisitTotals {
        return totals
 }
 
-fun Long.pluralize(singular: String, plural: String = singular + "s") = if (this == 1L) singular else plural
+fun Int.pluralize(singular: String, plural: String = singular + "s") = if (this == 1) singular else plural
 
 fun FlowContent.guestbook(totalsData: PageVisitTotals) {
        p {
index b178ad35208e1fae03f8f4a4381a6f346885024e..b0dece931eac99794ab5937e3c8bef2b0cda08ec 100644 (file)
@@ -14,8 +14,6 @@ fun isApril1st(time: Instant = Instant.now()): Boolean {
        return zonedDateTime.month == Month.APRIL && zonedDateTime.dayOfMonth == 1
 }
 
-data class AprilFoolsStaticFileRedirectException(val replacement: File) : RuntimeException()
-
 fun redirectFileOnApril1st(requestedFile: File): File? {
        if (!isApril1st()) return null
        
@@ -25,6 +23,6 @@ fun redirectFileOnApril1st(requestedFile: File): File? {
        return funnyFile.takeIf { it.exists() }
 }
 
-fun redirectAssetOnApril1st(requestedFile: File) {
-       redirectFileOnApril1st(requestedFile)?.let { throw AprilFoolsStaticFileRedirectException(it) }
+fun getAssetFile(requestedFile: File): File {
+       return redirectFileOnApril1st(requestedFile) ?: requestedFile
 }
index 5ae6e26fc198dcbf9ea71e3df715c59945b724d0..77e77d3a3de36e6addb7745193960a81a9634516 100644 (file)
@@ -1,6 +1,9 @@
 package info.mechyrdia.lore
 
 import info.mechyrdia.Configuration
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.href
+import io.ktor.server.application.*
 import kotlinx.html.UL
 import kotlinx.html.a
 import kotlinx.html.li
@@ -31,24 +34,23 @@ val ArticleNode.isViewable: Boolean
 val File.isViewable: Boolean
        get() = name.isViewable
 
-fun List<ArticleNode>.renderInto(list: UL, base: String? = null, suffix: String = "") {
-       val prefix by lazy(LazyThreadSafetyMode.NONE) { base?.let { "$it/" }.orEmpty() }
+context(ApplicationCall)
+fun List<ArticleNode>.renderInto(list: UL, base: List<String> = emptyList(), format: LoreArticleFormat = LoreArticleFormat.HTML) {
        for (node in this) {
                if (node.isViewable)
                        list.li {
-                               a(href = "/lore/$prefix${node.name}$suffix") { +node.name }
+                               a(href = href(Root.LorePage(base + node.name, format))) { +node.name }
                                if (node.subNodes.isNotEmpty())
                                        ul {
-                                               node.subNodes.renderInto(this, "$prefix${node.name}", suffix)
+                                               node.subNodes.renderInto(this, base + node.name, format)
                                        }
                        }
        }
 }
 
-fun String.toFriendlyIndexTitle() = split('/')
-       .joinToString(separator = " - ") { part ->
-               part.toFriendlyPageTitle()
-       }
+fun String.toFriendlyIndexTitle() = split('/').joinToString(separator = " - ") { part ->
+       part.toFriendlyPageTitle()
+}
 
 fun String.toFriendlyPageTitle() = split('-')
        .joinToString(separator = " ") { word ->
index e933ea425f55aa00a4b3da41932241fdbf8fd69c..c09ec21dc781b43219d1ac389da84562ac8a7eb2 100644 (file)
@@ -23,7 +23,7 @@ private fun getCacheByEncoding(encoding: String) = when (encoding) {
 
 private fun ApplicationCall.compressedCache(): CompressedCache? {
        return request.acceptEncodingItems()
-               .mapNotNull { value -> getCacheByEncoding(value.value)?.let { it to value.quality } }
+               .mapNotNull { item -> getCacheByEncoding(item.value)?.let { it to item.quality } }
                .maxByOrNull { it.second }
                ?.first
 }
@@ -38,11 +38,10 @@ private class CompressedCache(val encoding: String, private val compressor: (Byt
        private val cache = ConcurrentHashMap<File, CompressedCacheEntry>()
        
        fun getCompressed(file: File): ByteArray {
-               val lastModified = file.lastModified()
                return cache.compute(file) { _, prevEntry ->
-                       if (prevEntry == null || prevEntry.lastModified < lastModified)
-                               CompressedCacheEntry(lastModified, compressor(file.readBytes()))
-                       else prevEntry
+                       prevEntry?.apply {
+                               updateIfNeeded(file, compressor)
+                       } ?: CompressedCacheEntry(file, compressor)
                }!!.compressedData
        }
        
@@ -58,23 +57,24 @@ private class CompressedCache(val encoding: String, private val compressor: (Byt
        }
 }
 
-private data class CompressedCacheEntry(
-       val lastModified: Long,
-       val compressedData: ByteArray,
+private class CompressedCacheEntry private constructor(
+       lastModified: Long,
+       compressedData: ByteArray,
 ) {
-       override fun equals(other: Any?): Boolean {
-               if (this === other) return true
-               if (other !is CompressedCacheEntry) return false
-               
-               if (lastModified != other.lastModified) return false
-               if (!compressedData.contentEquals(other.compressedData)) return false
-               
-               return true
-       }
+       constructor(file: File, compressor: (ByteArray) -> ByteArray) : this(file.lastModified(), compressor(file.readBytes()))
+       
+       var lastModified: Long = lastModified
+               private set
        
-       override fun hashCode(): Int {
-               var result = lastModified.hashCode()
-               result = 31 * result + compressedData.contentHashCode()
-               return result
+       var compressedData: ByteArray = compressedData
+               private set
+       
+       fun updateIfNeeded(file: File, compressor: (ByteArray) -> ByteArray) {
+               val fileLastModified = file.lastModified()
+               if (lastModified < fileLastModified) {
+                       lastModified = fileLastModified
+                       
+                       compressedData = compressor(file.readBytes())
+               }
        }
 }
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/file_data.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/file_data.kt
new file mode 100644 (file)
index 0000000..399bfc2
--- /dev/null
@@ -0,0 +1,29 @@
+package info.mechyrdia.lore
+
+import java.io.File
+import java.util.concurrent.locks.ReentrantLock
+import kotlin.concurrent.withLock
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+
+fun <T> fileData(file: File, loader: (File) -> T): ReadOnlyProperty<Any?, T> = object : ReadOnlyProperty<Any?, T> {
+       private var loadedValue: T? = null
+       private var lastChanged = Long.MIN_VALUE
+       
+       private val lock = ReentrantLock(true)
+       
+       override fun getValue(thisRef: Any?, property: KProperty<*>): T {
+               return lock.withLock {
+                       val cached = loadedValue
+                       val lastMod = file.lastModified()
+                       
+                       @Suppress("UNCHECKED_CAST")
+                       if (lastChanged < lastMod) {
+                               lastChanged = lastMod
+                               loader(file).also {
+                                       loadedValue = it
+                               }
+                       } else cached as T
+               }
+       }
+}
index 336676c3199fc2aac9e5394233bd71a952886f9f..6fc9f79c0bcd6c7c5f1927f8c06e2361a3e7a1f7 100644 (file)
@@ -5,9 +5,11 @@ import com.jaredrummler.fontreader.truetype.TTFFile
 import com.jaredrummler.fontreader.util.GlyphSequence
 import info.mechyrdia.Configuration
 import info.mechyrdia.application
+import info.mechyrdia.route.KeyedEnumSerializer
 import info.mechyrdia.yieldThread
 import io.ktor.server.application.*
 import io.ktor.util.*
+import kotlinx.serialization.Serializable
 import java.awt.Font
 import java.awt.Shape
 import java.awt.geom.AffineTransform
@@ -18,79 +20,54 @@ import java.io.ByteArrayInputStream
 import java.io.File
 import java.io.IOException
 import java.nio.IntBuffer
-import java.util.concurrent.locks.ReentrantLock
-import kotlin.concurrent.withLock
 import kotlin.properties.ReadOnlyProperty
-import kotlin.reflect.KProperty
 
-object MechyrdiaSansFont {
-       enum class Alignment {
-               LEFT {
-                       override fun processWidth(widthDiff: Int): Int {
-                               return 0
-                       }
-               },
-               CENTER {
-                       override fun processWidth(widthDiff: Int): Int {
-                               return widthDiff / 2
-                       }
-               },
-               RIGHT {
-                       override fun processWidth(widthDiff: Int): Int {
-                               return widthDiff
-                       }
-               };
-               
-               abstract fun processWidth(widthDiff: Int): Int
-       }
+@Serializable(with = TextAlignmentSerializer::class)
+enum class TextAlignment {
+       LEFT {
+               override fun processWidth(widthDiff: Int): Int {
+                       return 0
+               }
+       },
+       CENTER {
+               override fun processWidth(widthDiff: Int): Int {
+                       return widthDiff / 2
+               }
+       },
+       RIGHT {
+               override fun processWidth(widthDiff: Int): Int {
+                       return widthDiff
+               }
+       };
        
-       fun renderTextToSvg(text: String, bold: Boolean, italic: Boolean, align: Alignment): String {
+       abstract fun processWidth(widthDiff: Int): Int
+}
+
+object TextAlignmentSerializer : KeyedEnumSerializer<TextAlignment>(TextAlignment.entries)
+
+object MechyrdiaSansFont {
+       fun renderTextToSvg(text: String, bold: Boolean, italic: Boolean, align: TextAlignment): String {
                val (file, font) = getFont(bold, italic)
                return layoutText(text, file, font, align).toSvgDocument(80.0 / file.unitsPerEm, 12.0)
        }
        
        private val fontsRoot = File(Configuration.CurrentConfiguration.rootDir, "fonts")
+       private fun fontFile(name: String) = fontsRoot.combineSafe("$name.ttf")
+       private fun loadFont(fontFile: File): Pair<TTFFile, Font> {
+               val bytes = fontFile.readBytes()
+               
+               val file = TTFFile(true, true)
+               file.readFont(FontFileReader(ByteArrayInputStream(bytes)))
+               
+               val font = Font
+                       .createFont(Font.TRUETYPE_FONT, ByteArrayInputStream(bytes))
+                       .deriveFont(file.unitsPerEm.toFloat())
+               
+               return file to font
+       }
        
        private fun loadedFont(fontName: String): ReadOnlyProperty<Any?, Pair<TTFFile, Font>> {
-               return object : ReadOnlyProperty<Any?, Pair<TTFFile, Font>> {
-                       private var loadedFile: TTFFile? = null
-                       private var loadedFont: Font? = null
-                       private var lastLoaded = Long.MIN_VALUE
-                       
-                       private val fontFile = fontsRoot.combineSafe("$fontName.ttf")
-                       
-                       private fun loadFont(): Pair<TTFFile, Font> {
-                               val bytes = fontFile.readBytes()
-                               
-                               val file = TTFFile(true, true).apply {
-                                       readFont(FontFileReader(ByteArrayInputStream(bytes)))
-                               }
-                               
-                               val font = Font
-                                       .createFont(Font.TRUETYPE_FONT, ByteArrayInputStream(bytes))
-                                       .deriveFont(file.unitsPerEm.toFloat())
-                               
-                               return file to font
-                       }
-                       
-                       private val getValueLock = ReentrantLock(true)
-                       
-                       override fun getValue(thisRef: Any?, property: KProperty<*>): Pair<TTFFile, Font> {
-                               return getValueLock.withLock {
-                                       val file = loadedFile
-                                       val font = loadedFont
-                                       val lastMod = fontFile.lastModified()
-                                       
-                                       if (file == null || font == null || lastLoaded < lastMod)
-                                               loadFont().also { (file, font) ->
-                                                       loadedFile = file
-                                                       loadedFont = font
-                                                       lastLoaded = lastMod
-                                               }
-                                       else file to font
-                               }
-                       }
-               }
+               return fileData(fontFile(fontName), ::loadFont)
        }
        
        private val mechyrdiaSans by loadedFont("mechyrdia-sans")
@@ -169,7 +146,7 @@ object MechyrdiaSansFont {
                return widths.zip(glyphPositions) { width, pos -> width + pos[2] }.sum()
        }
        
-       private fun layoutText(text: String, file: TTFFile, font: Font, align: Alignment): Shape {
+       private fun layoutText(text: String, file: TTFFile, font: Font, align: TextAlignment): Shape {
                val img = BufferedImage(256, 160, BufferedImage.TYPE_INT_ARGB)
                val g2d = img.createGraphics()
                try {
index 11d59984f4eb5757a8f98bddb38d2bf5f95a8f11..263a71583aa5ffe3bc7ff797bd13214debbcaf2a 100644 (file)
@@ -1,14 +1,11 @@
 package info.mechyrdia.lore
 
-import io.ktor.http.*
+import info.mechyrdia.route.href
+import io.ktor.server.application.*
 
 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, hash: String? = null): Nothing {
-       val parameters = parametersOf("error", error).formUrlEncode()
-       val markedHash = hash?.let { "#$it" }.orEmpty()
-       val urlWithError = "$url?$parameters$markedHash"
-       redirect(urlWithError, false)
-}
+context(ApplicationCall)
+inline fun <reified T : Any> redirectHref(resource: T, permanent: Boolean = false, hash: String? = null): Nothing = redirect(href(resource, hash), permanent)
index b64af7319c51714710989096f1b63040d74397b7..91d60bcacc0d45c675e6b5069b8b082838f57e15 100644 (file)
@@ -102,7 +102,6 @@ enum class TextParserCommentTagsPlainText(val type: TextParserTagType<Unit>) {
        
        LANG(plainTextFormattingTag),
        
-       IMGUR(embeddedFormattingTag),
        IMGBB(embeddedFormattingTag),
        
        REPLY(
index 7f7bfde4ff5595bc919e0d8098ff9e3244de2f76..dad72e70e6f811f0b6326e1321dcf4b31d302531 100644 (file)
@@ -4,7 +4,7 @@ import info.mechyrdia.Configuration
 import io.ktor.util.*
 import java.io.File
 
-fun String.toRawLink() = substringBeforeLast('#') + ".raw"
+fun String.toRawLink() = substringBeforeLast('#') + "?format=raw"
 
 enum class TextParserRawPageTag(val type: TextParserTagType<Unit>) {
        B(
@@ -210,12 +210,12 @@ enum class TextParserRawPageTag(val type: TextParserTagType<Unit>) {
        ),
        ALPHABET(
                TextParserTagType.Indirect(true) { _, _, _ ->
-                       "<p>Unfortunately, raw view does not support interactive conscript previews</p>"
+                       "<p>Unfortunately, raw view does not support interactive constructed script previews</p>"
                }
        ),
        VOCAB(
                TextParserTagType.Indirect(true) { _, _, _ ->
-                       "<p>Unfortunately, raw view does not support interactive conlang dictionaries</p>"
+                       "<p>Unfortunately, raw view does not support interactive constructed language dictionaries</p>"
                }
        ),
        ;
index aec52ae870d9c9a9d8210c00558f08946e52081c..e37dc197797c6b05cda182c37eee7592529dc7fa 100644 (file)
@@ -3,7 +3,6 @@ package info.mechyrdia.lore
 import info.mechyrdia.Configuration
 import info.mechyrdia.JsonStorageCodec
 import io.ktor.util.*
-import kotlinx.serialization.builtins.serializer
 import kotlinx.serialization.json.JsonPrimitive
 import java.io.File
 
@@ -482,14 +481,6 @@ enum class TextParserCommentTags(val type: TextParserTagType<Unit>) {
        
        LANG(TextParserFormattingTag.LANG.type),
        
-       IMGUR(
-               TextParserTagType.Indirect(false) { tagParam, content, _ ->
-                       val imageUrl = sanitizeExtLink(content)
-                       val (width, height) = getSizeParam(tagParam)
-                       
-                       "<img src=\"https://i.imgur.com/$imageUrl\"${getImageSizeAttributes(width, height)}/>"
-               }
-       ),
        IMGBB(
                TextParserTagType.Indirect(false) { tagParam, content, _ ->
                        val imageUrl = sanitizeExtLink(content)
index 199ac0bbdabef13333d1475f108271c1499b113c..f4eac83b44c130cbbfea8fcfc470a2ebb4206616 100644 (file)
@@ -2,8 +2,10 @@ package info.mechyrdia.lore
 
 import info.mechyrdia.Configuration
 import info.mechyrdia.JsonFileCodec
-import info.mechyrdia.auth.createCsrfToken
 import info.mechyrdia.data.currentNation
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.createCsrfToken
+import info.mechyrdia.route.href
 import io.ktor.server.application.*
 import io.ktor.util.*
 import kotlinx.html.*
@@ -29,30 +31,28 @@ fun loadExternalLinks(): List<NavItem> {
 }
 
 suspend fun ApplicationCall.standardNavBar(path: List<String>? = null) = listOf(
-       NavLink("/", "Lore Intro"),
-       NavLink("/lore", "Table of Contents"),
+       NavLink(href(Root()), "Lore Intro"),
+       NavLink(href(Root.LorePage(emptyList())), TOC_TITLE),
 ) + path?.let { pathParts ->
        pathParts.dropLast(1).mapIndexed { i, part ->
-               val subPath = pathParts.take(i + 1).joinToString("/")
-               NavLink("/lore/$subPath", part)
+               val subPath = pathParts.take(i + 1)
+               NavLink(href(Root.LorePage(subPath)), part)
        }
-}.orEmpty() + listOf(
-       NavHead("Client Preferences"),
-       NavLink("/change-theme", "Light/Dark Mode"),
-) + (currentNation()?.let { data ->
+}.orEmpty() + (currentNation()?.let { data ->
        listOf(
                NavHead(data.name),
-               NavLink("/user/${data.id}", "Your User Page"),
+               NavLink(href(Root.User()), "Your User Page"),
                NavLink("https://www.nationstates.net/nation=${data.id}", "Your NationStates Page"),
-               NavLink("/auth/logout", "Log Out", linkAttributes = mapOf("data-method" to "post", "data-csrf-token" to createCsrfToken("/auth/logout"))),
+               NavLink.withCsrf(href(Root.Auth.LogoutPost()), "Log Out"),
        )
 } ?: listOf(
        NavHead("Log In"),
-       NavLink("/auth/login", "Log In with NationStates"),
+       NavLink(href(Root.Auth.LoginPage()), "Log In with NationStates"),
 )) + listOf(
+       NavLink(href(Root.ClientPreferences()), "Client Preferences"),
        NavHead("Useful Links"),
-       NavLink("/comment/help", "Commenting Help"),
-       NavLink("/comment/recent", "Recent Comments"),
+       NavLink(href(Root.Comments.HelpPage()), "Commenting Help"),
+       NavLink(href(Root.Comments.RecentPage()), "Recent Comments"),
 ) + loadExternalLinks()
 
 sealed class NavItem {
@@ -87,4 +87,20 @@ data class NavLink(
                                +text
                }
        }
+       
+       companion object {
+               context(ApplicationCall)
+               fun withCsrf(to: String, text: String, textIsHtml: Boolean = false, aClasses: String? = null, extraAttributes: Map<String, String> = emptyMap): NavLink {
+                       return NavLink(
+                               to = to,
+                               text = text,
+                               textIsHtml = textIsHtml,
+                               aClasses = aClasses,
+                               linkAttributes = extraAttributes + mapOf(
+                                       "data-method" to "post",
+                                       "data-csrf-token" to createCsrfToken(to)
+                               )
+                       )
+               }
+       }
 }
index 6d718e3f77cf7d6b2fbe27dbf820aa78d7340c43..2ffeffb37d10f4dcda9fb3295e6da9011baa198d 100644 (file)
@@ -1,5 +1,6 @@
 package info.mechyrdia.lore
 
+import info.mechyrdia.route.ErrorMessageAttributeKey
 import io.ktor.http.*
 import io.ktor.server.application.*
 import io.ktor.server.request.*
@@ -149,7 +150,7 @@ fun ApplicationCall.page(pageTitle: String, navBar: List<NavItem>? = null, sideb
                                }
                        }
                        
-                       request.queryParameters["error"]?.let { errorMessage ->
+                       this@page.attributes.getOrNull(ErrorMessageAttributeKey)?.let { errorMessage ->
                                div {
                                        id = "error-popup"
                                        
index 4760549d49ec8e410eb1935d78cd059e6a0ec45a..728d77005af8618ff6b10ba25fe1369cbfa0cf44 100644 (file)
@@ -1,12 +1,15 @@
 package info.mechyrdia.lore
 
+import info.mechyrdia.route.CsrfProtectedResourcePayload
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.href
 import io.ktor.http.*
 import io.ktor.server.application.*
 import io.ktor.server.request.*
 import kotlinx.html.*
 
 suspend fun ApplicationCall.errorPage(title: String, body: FlowContent.() -> Unit): HTML.() -> Unit {
-       return if (request.path().endsWith(".raw"))
+       return if (request.queryParameters["format"] == "raw")
                rawPage(title) {
                        body()
                }
@@ -28,15 +31,9 @@ suspend fun ApplicationCall.error403(): HTML.() -> Unit = errorPage("403 Forbidd
        p { +"You are not allowed to do that." }
 }
 
-suspend fun ApplicationCall.error403PageExpired(formData: Parameters): HTML.() -> Unit = errorPage("Page Expired") {
+suspend fun ApplicationCall.error403PageExpired(payload: CsrfProtectedResourcePayload): HTML.() -> Unit = errorPage("Page Expired") {
        h1 { +"Page Expired" }
-       formData["comment"]?.let { commentData ->
-               p { +"The comment you tried to submit had been preserved here:" }
-               textArea {
-                       readonly = true
-                       +commentData
-               }
-       }
+       with(payload) { displayRetryData() }
        p {
                +"The page you were on has expired."
                request.header(HttpHeaders.Referrer)?.let { referrer ->
@@ -51,7 +48,7 @@ suspend fun ApplicationCall.error404(): HTML.() -> Unit = errorPage("404 Not Fou
        h1 { +"404 Not Found" }
        p {
                +"Unfortunately, we could not find what you were looking for. Would you like to "
-               a(href = "/") { +"return to the index page" }
+               a(href = href(Root())) { +"return to the index page" }
                +"?"
        }
 }
index ea5fe36d3705450e07e13feff48bdc4ef1333300..2afad1389d6de376bf9e54f7275157a091cfc1fe 100644 (file)
@@ -3,6 +3,9 @@ package info.mechyrdia.lore
 import info.mechyrdia.Configuration
 import info.mechyrdia.JsonFileCodec
 import info.mechyrdia.data.*
+import info.mechyrdia.route.KeyedEnumSerializer
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.href
 import io.ktor.server.application.*
 import io.ktor.util.*
 import kotlinx.coroutines.async
@@ -39,7 +42,8 @@ suspend fun ApplicationCall.loreIntroPage(): HTML.() -> Unit {
        }
 }
 
-private fun FlowContent.breadCrumbs(links: List<Pair<String, String>>) = p {
+context(ApplicationCall)
+private fun FlowContent.breadCrumbs(links: List<Pair<Root.LorePage, String>>) = p {
        var isNext = false
        for ((url, text) in links) {
                if (isNext) {
@@ -48,23 +52,34 @@ private fun FlowContent.breadCrumbs(links: List<Pair<String, String>>) = p {
                        +Entities.nbsp
                } else isNext = true
                
-               a(href = url) { +text }
+               a(href = href(url)) { +text }
        }
 }
 
-fun ApplicationCall.loreRawArticlePage(rawPagePath: String): HTML.() -> Unit {
+const val TOC_TITLE = "Table of Contents"
+
+@Serializable(with = LoreArticleFormatSerializer::class)
+enum class LoreArticleFormat(val format: String? = null) {
+       HTML(null),
+       RAW_HTML("raw"),
+       ;
+}
+
+object LoreArticleFormatSerializer : KeyedEnumSerializer<LoreArticleFormat>(LoreArticleFormat.entries, LoreArticleFormat::format)
+
+fun ApplicationCall.loreRawArticlePage(pagePathParts: List<String>): HTML.() -> Unit {
        val articleDir = File(Configuration.CurrentConfiguration.articleDir)
        
-       val pagePath = rawPagePath.removeSuffix(".raw")
+       val pagePath = pagePathParts.joinToString(separator = "/")
        val pageFile = if (pagePath.isEmpty()) articleDir else articleDir.combineSafe(pagePath)
        val pageNode = pageFile.toArticleNode()
        
-       val parentPaths = if (pagePath.isEmpty())
+       val parentPaths = if (pagePathParts.isEmpty())
                emptyList()
        else {
-               val pathParts = pagePath.split('/').dropLast(1)
-               listOf("/lore.raw" to "Table of Contents") + pathParts.mapIndexed { i, part ->
-                       pathParts.take(i + 1).joinToString(separator = "/", prefix = "/lore/", postfix = ".raw") to part.toFriendlyPageTitle()
+               val pathDirs = pagePathParts.dropLast(1)
+               listOf(Root.LorePage(emptyList(), LoreArticleFormat.RAW_HTML) to TOC_TITLE) + pathDirs.mapIndexed { i, part ->
+                       Root.LorePage(pathDirs.take(i + 1), LoreArticleFormat.RAW_HTML) to part.toFriendlyPageTitle()
                }
        }
        
@@ -72,13 +87,13 @@ fun ApplicationCall.loreRawArticlePage(rawPagePath: String): HTML.() -> Unit {
        
        if (isValid) {
                if (pageFile.isDirectory) {
-                       val title = pagePath.takeIf { it.isNotEmpty() }?.toFriendlyIndexTitle() ?: "Table of Contents"
+                       val title = pagePath.takeIf { it.isNotEmpty() }?.toFriendlyIndexTitle() ?: TOC_TITLE
                        
                        return rawPage(title) {
                                breadCrumbs(parentPaths)
                                h1 { +title }
                                ul {
-                                       pageNode.subNodes.renderInto(this, pagePath.takeIf { it.isNotEmpty() }, ".raw")
+                                       pageNode.subNodes.renderInto(this, pagePathParts, LoreArticleFormat.RAW_HTML)
                                }
                        }
                }
@@ -105,21 +120,19 @@ fun ApplicationCall.loreRawArticlePage(rawPagePath: String): HTML.() -> Unit {
                h1 { +title }
                p {
                        +"This factbook does not exist. Would you like to "
-                       a(href = "/lore.raw") { +"return to the table of contents" }
+                       a(href = href(Root.LorePage(emptyList(), LoreArticleFormat.RAW_HTML))) { +"return to the table of contents" }
                        +"?"
                }
        }
 }
 
-suspend fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
+suspend fun ApplicationCall.loreArticlePage(pagePathParts: List<String>, format: LoreArticleFormat = LoreArticleFormat.HTML): HTML.() -> Unit {
        val totalsData = processGuestbook()
        
-       val pagePathParts = parameters.getAll("path")!!
-       val pagePath = pagePathParts.joinToString("/")
-       
-       if (pagePath.endsWith(".raw"))
-               return loreRawArticlePage(pagePath)
+       if (format == LoreArticleFormat.RAW_HTML)
+               return loreRawArticlePage(pagePathParts)
        
+       val pagePath = pagePathParts.joinToString("/")
        val pageFile = File(Configuration.CurrentConfiguration.articleDir).combineSafe(pagePath)
        val pageNode = pageFile.toArticleNode()
        
@@ -138,7 +151,7 @@ suspend fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
                if (pageFile.isDirectory) {
                        val navbar = standardNavBar(pagePathParts.takeIf { it.isNotEmpty() })
                        
-                       val title = pagePath.takeIf { it.isNotEmpty() }?.toFriendlyIndexTitle() ?: "Table of Contents"
+                       val title = pagePath.takeIf { it.isNotEmpty() }?.toFriendlyIndexTitle() ?: TOC_TITLE
                        
                        val sidebar = PageNavSidebar(
                                listOf(
@@ -152,11 +165,11 @@ suspend fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
                                        a { id = "page-top" }
                                        h1 { +title }
                                        ul {
-                                               pageNode.subNodes.renderInto(this, pagePath.takeIf { it.isNotEmpty() })
+                                               pageNode.subNodes.renderInto(this, pagePathParts, format = format)
                                        }
                                }
                                
-                               finalSection(pagePath, canCommentAs, comments, totalsData)
+                               finalSection(pagePathParts, canCommentAs, comments, totalsData)
                        }
                }
                
@@ -179,7 +192,7 @@ suspend fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
                                        unsafe { raw(pageHtml) }
                                }
                                
-                               finalSection(pagePath, canCommentAs, comments, totalsData)
+                               finalSection(pagePathParts, canCommentAs, comments, totalsData)
                        }
                }
        }
@@ -199,23 +212,23 @@ suspend fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
                        h1 { +title }
                        p {
                                +"This factbook does not exist. Would you like to "
-                               a(href = "/") { +"return to the index page" }
+                               a(href = href(Root())) { +"return to the index page" }
                                +"?"
                        }
                }
                
-               finalSection(pagePath, canCommentAs, comments, totalsData)
+               finalSection(pagePathParts, canCommentAs, comments, totalsData)
        }
 }
 
 context(ApplicationCall)
-private fun SECTIONS.finalSection(pagePath: String, canCommentAs: NationData?, comments: List<CommentRenderData>, totalsData: PageVisitTotals) {
+private fun SECTIONS.finalSection(pagePathParts: List<String>, canCommentAs: NationData?, comments: List<CommentRenderData>, totalsData: PageVisitTotals) {
        section {
                h2 {
                        a { id = "comments" }
                        +"Comments"
                }
-               commentInput(pagePath, canCommentAs)
+               commentInput(pagePathParts, canCommentAs)
                for (comment in comments)
                        commentBox(comment, canCommentAs?.id)
                
index 6e8894fa7b0929d57e83654c81411e9dce3707bb..f6f983dd4e936c044374909795178a5fffb538f6 100644 (file)
@@ -1,13 +1,10 @@
 package info.mechyrdia.lore
 
 import info.mechyrdia.auth.PageDoNotCacheAttributeKey
-import info.mechyrdia.auth.createCsrfToken
-import info.mechyrdia.auth.installCsrfToken
-import info.mechyrdia.auth.verifyCsrfToken
 import io.ktor.server.application.*
 import kotlinx.html.*
 
-suspend fun ApplicationCall.changeThemePage(): HTML.() -> Unit {
+suspend fun ApplicationCall.clientSettingsPage(): HTML.() -> Unit {
        attributes.put(PageDoNotCacheAttributeKey, true)
        
        val theme = when (request.cookies["FACTBOOK_THEME"]) {
@@ -19,54 +16,38 @@ suspend fun ApplicationCall.changeThemePage(): HTML.() -> Unit {
        return page("Client Preferences", standardNavBar()) {
                section {
                        h1 { +"Client Preferences" }
-                       form(action = "/change-theme", method = FormMethod.post) {
-                               installCsrfToken(createCsrfToken())
-                               label {
-                                       radioInput(name = "theme") {
-                                               id = "system-theme"
-                                               value = "system"
-                                               required = true
-                                               checked = theme == null
-                                       }
-                                       +Entities.nbsp
-                                       +"System Choice"
+                       label {
+                               radioInput(name = "theme") {
+                                       id = "system-theme"
+                                       value = "system"
+                                       required = true
+                                       checked = theme == null
                                }
-                               br
-                               label {
-                                       radioInput(name = "theme") {
-                                               id = "light-theme"
-                                               value = "light"
-                                               required = true
-                                               checked = theme == "light"
-                                       }
-                                       +Entities.nbsp
-                                       +"Light Theme"
+                               +Entities.nbsp
+                               +"System Choice"
+                       }
+                       br
+                       label {
+                               radioInput(name = "theme") {
+                                       id = "light-theme"
+                                       value = "light"
+                                       required = true
+                                       checked = theme == "light"
                                }
-                               br
-                               label {
-                                       radioInput(name = "theme") {
-                                               id = "dark-theme"
-                                               value = "dark"
-                                               required = true
-                                               checked = theme == "dark"
-                                       }
-                                       +Entities.nbsp
-                                       +"Dark Theme"
+                               +Entities.nbsp
+                               +"Light Theme"
+                       }
+                       br
+                       label {
+                               radioInput(name = "theme") {
+                                       id = "dark-theme"
+                                       value = "dark"
+                                       required = true
+                                       checked = theme == "dark"
                                }
-                               br
-                               submitInput { value = "Accept Changes" }
+                               +Entities.nbsp
+                               +"Dark Theme"
                        }
                }
        }
 }
-
-suspend fun ApplicationCall.changeThemeRoute(): Nothing {
-       val newTheme = when (verifyCsrfToken()["theme"]) {
-               "light" -> "light"
-               "dark" -> "dark"
-               else -> "system"
-       }
-       response.cookies.append("FACTBOOK_THEME", newTheme, maxAge = Int.MAX_VALUE.toLong())
-       
-       redirect("/change-theme")
-}
index 1602f406a8847697f1814710b7d9630905dc62c0..c5e0e623f288ff3688db2546a9ffeea63f0c9b51 100644 (file)
@@ -2,7 +2,11 @@ package info.mechyrdia.lore
 
 import info.mechyrdia.Configuration
 import info.mechyrdia.JsonFileCodec
+import info.mechyrdia.route.KeyedEnumSerializer
+import io.ktor.http.*
 import io.ktor.server.application.*
+import io.ktor.server.html.*
+import io.ktor.server.response.*
 import io.ktor.util.*
 import kotlinx.html.*
 import kotlinx.serialization.Serializable
@@ -31,12 +35,51 @@ data class Quote(
                        "https://mechyrdia.info/lore/$link"
 }
 
-fun loadQuotes(): List<Quote> {
-       val quotesJsonFile = File(Configuration.CurrentConfiguration.rootDir).combineSafe("quotes.json")
-       return JsonFileCodec.decodeFromString(ListSerializer(Quote.serializer()), quotesJsonFile.readText())
+val quotesList by fileData(File(Configuration.CurrentConfiguration.rootDir).combineSafe("quotes.json")) { jsonFile ->
+       JsonFileCodec.decodeFromString(ListSerializer(Quote.serializer()), jsonFile.readText())
 }
 
-fun randomQuote(): Quote = loadQuotes().random()
+fun randomQuote(): Quote = quotesList.random()
+
+@Serializable(with = QuoteFormatSerializer::class)
+enum class QuoteFormat(val format: String?) {
+       HTML(null) {
+               override suspend fun ApplicationCall.respondQuote(quote: Quote) {
+                       respondHtml(HttpStatusCode.OK, block = quote.toHtml(RANDOM_QUOTE_HTML_TITLE))
+               }
+       },
+       RAW_HTML("raw") {
+               override suspend fun ApplicationCall.respondQuote(quote: Quote) {
+                       respondHtml(HttpStatusCode.OK, block = quote.toRawHtml(RANDOM_QUOTE_HTML_TITLE))
+               }
+       },
+       JSON("json") {
+               override suspend fun ApplicationCall.respondQuote(quote: Quote) {
+                       respondText(quote.toJson())
+               }
+       },
+       XML("xml") {
+               override suspend fun ApplicationCall.respondQuote(quote: Quote) {
+                       respondText(quote.toXml())
+               }
+       },
+       ;
+       
+       abstract suspend fun ApplicationCall.respondQuote(quote: Quote)
+       
+       companion object {
+               init {
+                       assert(entries.toSet().size == entries.distinctBy { it.format }.size) { "Got duplicate QuoteFormat names" }
+                       assert(entries.any { it.format == null }) { "Did not get default QuoteFormat" }
+               }
+               
+               fun byFormat(format: String? = null) = entries.singleOrNull { format.equals(it.format, ignoreCase = true) } ?: entries.single { it.format == null }
+       }
+}
+
+object QuoteFormatSerializer : KeyedEnumSerializer<QuoteFormat>(QuoteFormat.entries, QuoteFormat::format)
+
+const val RANDOM_QUOTE_HTML_TITLE = "Random Quote"
 
 fun Quote.toXml(standalone: Boolean = true): String {
        return buildString {
@@ -82,7 +125,6 @@ suspend fun Quote.toHtml(title: String): HTML.() -> Unit {
 context(ApplicationCall)
 fun Quote.toRawHtml(title: String): HTML.() -> Unit {
        return rawPage(title) {
-               a { id = "page-top" }
                h1 { +title }
                blockQuote {
                        +quote
diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/resource_bodies.kt b/src/jvmMain/kotlin/info/mechyrdia/route/resource_bodies.kt
new file mode 100644 (file)
index 0000000..105e110
--- /dev/null
@@ -0,0 +1,71 @@
+package info.mechyrdia.route
+
+import info.mechyrdia.lore.TextAlignment
+import io.ktor.http.*
+import io.ktor.resources.serialization.*
+import kotlinx.html.FlowContent
+import kotlinx.html.p
+import kotlinx.html.textArea
+import kotlinx.serialization.*
+import kotlinx.serialization.modules.SerializersModule
+
+@Serializable
+class LoginPayload(override val csrfToken: String, val nation: String, val checksum: String, val token: String) : CsrfProtectedResourcePayload
+
+@Serializable
+class LogoutPayload(override val csrfToken: String) : CsrfProtectedResourcePayload
+
+@Serializable
+class NewCommentPayload(override val csrfToken: String, val comment: String) : CsrfProtectedResourcePayload {
+       override fun FlowContent.displayRetryData() {
+               p { +"The comment you tried to submit had been preserved here:" }
+               textArea {
+                       readonly = true
+                       +comment
+               }
+       }
+}
+
+@Serializable
+class EditCommentPayload(override val csrfToken: String, val comment: String) : CsrfProtectedResourcePayload {
+       override fun FlowContent.displayRetryData() {
+               p { +"The comment you tried to submit had been preserved here:" }
+               textArea {
+                       readonly = true
+                       +comment
+               }
+       }
+}
+
+@Serializable
+class DeleteCommentPayload(override val csrfToken: String) : CsrfProtectedResourcePayload
+
+@Serializable
+class AdminBanUserPayload(override val csrfToken: String) : CsrfProtectedResourcePayload
+
+@Serializable
+class AdminUnbanUserPayload(override val csrfToken: String) : CsrfProtectedResourcePayload
+
+@Serializable
+class MechyrdiaSansPayload(val bold: Boolean = false, val italic: Boolean = false, val align: TextAlignment = TextAlignment.LEFT, val lines: List<String>)
+
+@Serializable
+class TylanLanguagePayload(val lines: List<String>)
+
+@Serializable
+class PokhwalishLanguagePayload(val lines: List<String>)
+
+@Serializable
+class PreviewCommentPayload(val lines: List<String>)
+
+class FormUrlEncodedFormat(private val resourcesFormat: ResourcesFormat) : StringFormat {
+       override val serializersModule: SerializersModule = resourcesFormat.serializersModule
+       
+       override fun <T> encodeToString(serializer: SerializationStrategy<T>, value: T): String {
+               return resourcesFormat.encodeToParameters(serializer as KSerializer<T>, value).formUrlEncode()
+       }
+       
+       override fun <T> decodeFromString(deserializer: DeserializationStrategy<T>, string: String): T {
+               return resourcesFormat.decodeFromParameters(deserializer as KSerializer<T>, string.replace("+", "%20").parseUrlEncodedParameters())
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/resource_csrf.kt b/src/jvmMain/kotlin/info/mechyrdia/route/resource_csrf.kt
new file mode 100644 (file)
index 0000000..6b4da06
--- /dev/null
@@ -0,0 +1,71 @@
+package info.mechyrdia.route
+
+import info.mechyrdia.auth.UserSession
+import info.mechyrdia.auth.token
+import info.mechyrdia.data.Id
+import info.mechyrdia.data.NationData
+import io.ktor.server.application.*
+import io.ktor.server.plugins.*
+import io.ktor.server.request.*
+import io.ktor.server.sessions.*
+import kotlinx.html.A
+import kotlinx.html.FORM
+import kotlinx.html.FlowContent
+import kotlinx.html.hiddenInput
+import java.time.Instant
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.collections.set
+
+data class CsrfPayload(
+       val route: String,
+       val remoteAddress: String,
+       val userAgent: String?,
+       val userAccount: Id<NationData>?,
+       val expires: Instant
+)
+
+fun ApplicationCall.csrfPayload(route: String, withExpiration: Instant = Instant.now().plusSeconds(7200)) =
+       CsrfPayload(
+               route = route,
+               remoteAddress = request.origin.remoteAddress,
+               userAgent = request.userAgent(),
+               userAccount = sessions.get<UserSession>()?.nationId,
+               expires = withExpiration
+       )
+
+private val csrfMap = ConcurrentHashMap<String, CsrfPayload>()
+
+data class CsrfFailedException(override val message: String, val payload: CsrfProtectedResourcePayload) : RuntimeException(message)
+
+interface CsrfProtectedResourcePayload {
+       val csrfToken: String
+       
+       suspend fun ApplicationCall.verifyCsrfToken(route: String = request.uri) {
+               val check = csrfMap.remove(csrfToken) ?: throw CsrfFailedException("The submitted CSRF token is not valid", this@CsrfProtectedResourcePayload)
+               val payload = csrfPayload(route, check.expires)
+               if (check != payload)
+                       throw CsrfFailedException("The submitted CSRF token does not match", this@CsrfProtectedResourcePayload)
+               if (payload.expires < Instant.now())
+                       throw CsrfFailedException("The submitted CSRF token has expired", this@CsrfProtectedResourcePayload)
+       }
+       
+       fun FlowContent.displayRetryData() {}
+}
+
+fun ApplicationCall.createCsrfToken(route: String = request.origin.uri): String {
+       return token().also { csrfMap[it] = csrfPayload(route) }
+}
+
+context(ApplicationCall)
+fun A.installCsrfToken(route: String = href) {
+       attributes["data-method"] = "post"
+       attributes["data-csrf-token"] = token().also { csrfMap[it] = csrfPayload(route) }
+}
+
+context(ApplicationCall)
+fun FORM.installCsrfToken(route: String = action) {
+       hiddenInput {
+               name = "csrfToken"
+               value = token().also { csrfMap[it] = csrfPayload(route) }
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/resource_handler.kt b/src/jvmMain/kotlin/info/mechyrdia/route/resource_handler.kt
new file mode 100644 (file)
index 0000000..df16f5e
--- /dev/null
@@ -0,0 +1,66 @@
+package info.mechyrdia.route
+
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.resources.*
+import io.ktor.server.routing.*
+import io.ktor.util.pipeline.*
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.builtins.nullable
+import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlin.enums.EnumEntries
+
+interface ResourceHandler {
+       suspend fun PipelineContext<Unit, ApplicationCall>.handleCall()
+}
+
+interface ResourceReceiver<P : Any> {
+       suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: P)
+}
+
+interface ResourceFilter {
+       suspend fun PipelineContext<Unit, ApplicationCall>.filterCall()
+}
+
+inline fun <reified T : ResourceHandler> Route.get() {
+       get<T> { resource ->
+               with(resource) {
+                       handleCall()
+               }
+       }
+}
+
+inline fun <reified T : ResourceReceiver<P>, reified P : Any> Route.post() {
+       post<T, P> { resource, payload ->
+               with(resource) { handleCall(payload) }
+       }
+}
+
+abstract class KeyedEnumSerializer<E : Enum<E>>(val entries: EnumEntries<E>, val getKey: (E) -> String? = { it.name }) : KSerializer<E> {
+       override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("EnumSerializer<${entries.first()::class.qualifiedName}>", PrimitiveKind.STRING)
+       
+       private val inner = String.serializer().nullable
+       private val keyMap = entries.associateBy { getKey(it)?.lowercase() }
+       private val default = keyMap[null] ?: entries.first()
+       
+       init {
+               assert(keyMap.size == entries.size)
+       }
+       
+       override fun serialize(encoder: Encoder, value: E) {
+               inner.serialize(encoder, getKey(value))
+       }
+       
+       override fun deserialize(decoder: Decoder): E {
+               return keyMap[inner.deserialize(decoder)?.lowercase()] ?: default
+       }
+}
+
+inline fun <reified T : Any> Application.href(resource: T, hash: String? = null): String = URLBuilder().also { href(resource, it) }.build().fullPath + hash?.let { "#$it" }.orEmpty()
+inline fun <reified T : Any> ApplicationCall.href(resource: T, hash: String? = null) = application.href(resource, hash)
+inline fun <reified T : Any> PipelineContext<Unit, ApplicationCall>.href(resource: T, hash: String? = null) = application.href(resource, hash)
diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt b/src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt
new file mode 100644 (file)
index 0000000..a3886ab
--- /dev/null
@@ -0,0 +1,325 @@
+package info.mechyrdia.route
+
+import info.mechyrdia.Configuration
+import info.mechyrdia.auth.loginPage
+import info.mechyrdia.auth.loginRoute
+import info.mechyrdia.auth.logoutRoute
+import info.mechyrdia.data.*
+import info.mechyrdia.lore.*
+import io.ktor.http.*
+import io.ktor.resources.*
+import io.ktor.server.application.*
+import io.ktor.server.html.*
+import io.ktor.server.response.*
+import io.ktor.util.*
+import io.ktor.util.pipeline.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runInterruptible
+import java.io.File
+
+val ErrorMessageAttributeKey = AttributeKey<String>("ErrorMessage")
+
+@Resource("/")
+class Root(val error: String? = null) : ResourceHandler, ResourceFilter {
+       override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
+               error?.let { call.attributes.put(ErrorMessageAttributeKey, it) }
+       }
+       
+       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+               filterCall()
+               call.respondHtml(HttpStatusCode.OK, call.loreIntroPage())
+       }
+       
+       @Resource("assets/{path...}")
+       class AssetFile(val path: List<String>, val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       val assetPath = path.joinToString(separator = File.separator)
+                       val assetFile = File(Configuration.CurrentConfiguration.assetDir).combineSafe(assetPath)
+                       
+                       call.respondCompressedFile(getAssetFile(assetFile))
+               }
+       }
+       
+       @Resource("lore/{path...}")
+       class LorePage(val path: List<String>, val format: LoreArticleFormat = LoreArticleFormat.HTML, val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondHtml(HttpStatusCode.OK, call.loreArticlePage(path, format))
+               }
+       }
+       
+       @Resource("map")
+       class GalaxyMap(val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondFile(call.galaxyMapPage())
+               }
+       }
+       
+       @Resource("quote")
+       class RandomQuote(val format: QuoteFormat = QuoteFormat.HTML, val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       with(format) { call.respondQuote(randomQuote()) }
+               }
+       }
+       
+       @Resource("robots.txt")
+       class RobotsTxt(val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondFile(File(Configuration.CurrentConfiguration.rootDir).combineSafe("robots.txt"))
+               }
+       }
+       
+       @Resource("sitemap.xml")
+       class SitemapXml(val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondText(buildString { generateSitemap() }, ContentType.Application.Xml)
+               }
+       }
+       
+       @Resource("edits.rss")
+       class RecentEditsRss(val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondText(buildString { generateRecentPageEdits() }, ContentType.Application.Rss)
+               }
+       }
+       
+       @Resource("comments.rss")
+       class RecentCommentsRss(val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondText(buildString(call.recentCommentsRssFeedGenerator()), ContentType.Application.Rss)
+               }
+       }
+       
+       @Resource("preferences")
+       class ClientPreferences(val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondHtml(HttpStatusCode.OK, call.clientSettingsPage())
+               }
+       }
+       
+       @Resource("auth")
+       class Auth(val root: Root = Root()) : ResourceFilter {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
+                       with(root) { filterCall() }
+               }
+               
+               @Resource("login")
+               class LoginPage(val auth: Auth = Auth()) : ResourceHandler {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                               with(auth) { filterCall() }
+                               
+                               call.respondHtml(HttpStatusCode.OK, call.loginPage())
+                       }
+               }
+               
+               @Resource("login")
+               class LoginPost(val auth: Auth = Auth()) : ResourceReceiver<LoginPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: LoginPayload) {
+                               with(auth) { filterCall() }
+                               with(payload) { call.verifyCsrfToken() }
+                               
+                               call.loginRoute(payload.nation, payload.checksum, payload.token)
+                       }
+               }
+               
+               @Resource("logout")
+               class LogoutPost(val auth: Auth = Auth()) : ResourceReceiver<LogoutPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: LogoutPayload) {
+                               with(auth) { filterCall() }
+                               with(payload) { call.verifyCsrfToken() }
+                               
+                               call.logoutRoute()
+                       }
+               }
+       }
+       
+       @Resource("comment")
+       class Comments(val root: Root = Root()) : ResourceFilter {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
+                       with(root) { filterCall() }
+               }
+               
+               @Resource("help")
+               class HelpPage(val comments: Comments = Comments()) : ResourceHandler {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                               with(comments) { filterCall() }
+                               
+                               call.respondHtml(HttpStatusCode.OK, call.commentHelpPage())
+                       }
+               }
+               
+               @Resource("recent")
+               class RecentPage(val limit: Int? = null, val comments: Comments = Comments()) : ResourceHandler {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                               with(comments) { filterCall() }
+                               
+                               call.respondHtml(HttpStatusCode.OK, call.recentCommentsPage(limit))
+                       }
+               }
+               
+               @Resource("new/{path...}")
+               class NewPost(val path: List<String>, val comments: Comments = Comments()) : ResourceReceiver<NewCommentPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: NewCommentPayload) {
+                               with(comments) { filterCall() }
+                               with(payload) { call.verifyCsrfToken() }
+                               
+                               call.newCommentRoute(path, payload.comment)
+                       }
+               }
+               
+               @Resource("view/{id}")
+               class ViewPage(val id: Id<Comment>, val comments: Comments = Comments()) : ResourceHandler {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                               with(comments) { filterCall() }
+                               
+                               call.viewCommentRoute(id)
+                       }
+               }
+               
+               @Resource("edit/{id}")
+               class EditPost(val id: Id<Comment>, val comments: Comments = Comments()) : ResourceReceiver<EditCommentPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: EditCommentPayload) {
+                               with(comments) { filterCall() }
+                               with(payload) { call.verifyCsrfToken() }
+                               
+                               call.editCommentRoute(id, payload.comment)
+                       }
+               }
+               
+               @Resource("delete/{id}")
+               class DeleteConfirmPage(val id: Id<Comment>, val comments: Comments = Comments()) : ResourceHandler {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                               with(comments) { filterCall() }
+                               
+                               call.respondHtml(HttpStatusCode.OK, call.deleteCommentPage(id))
+                       }
+               }
+               
+               @Resource("delete/{id}")
+               class DeleteConfirmPost(val id: Id<Comment>, val comments: Comments = Comments()) : ResourceReceiver<DeleteCommentPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: DeleteCommentPayload) {
+                               with(comments) { filterCall() }
+                               with(payload) { call.verifyCsrfToken() }
+                               
+                               call.deleteCommentRoute(id)
+                       }
+               }
+       }
+       
+       @Resource("user")
+       class User(val root: Root = Root()) : ResourceHandler, ResourceFilter {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
+                       with(root) { filterCall() }
+               }
+               
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       filterCall()
+                       call.currentUserPage()
+               }
+               
+               @Resource("{id}")
+               class ById(val id: Id<NationData>, val user: User = User()) : ResourceHandler {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                               with(user) { filterCall() }
+                               
+                               call.respondHtml(HttpStatusCode.OK, call.userPage(id))
+                       }
+               }
+       }
+       
+       @Resource("admin")
+       class Admin(val root: Root = Root()) : ResourceFilter {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
+                       with(root) { filterCall() }
+                       call.ownerNationOnly()
+               }
+               
+               @Resource("ban/{id}")
+               class Ban(val id: Id<NationData>, val admin: Admin = Admin()) : ResourceReceiver<AdminBanUserPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminBanUserPayload) {
+                               with(admin) { filterCall() }
+                               with(payload) { call.verifyCsrfToken() }
+                               
+                               call.adminBanUserRoute(id)
+                       }
+               }
+               
+               @Resource("unban/{id}")
+               class Unban(val id: Id<NationData>, val admin: Admin = Admin()) : ResourceReceiver<AdminUnbanUserPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminUnbanUserPayload) {
+                               with(admin) { filterCall() }
+                               with(payload) { call.verifyCsrfToken() }
+                               
+                               call.adminUnbanUserRoute(id)
+                       }
+               }
+       }
+       
+       @Resource("utils")
+       class Utils(val root: Root = Root()) : ResourceFilter {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
+                       with(root) { filterCall() }
+                       
+                       delay(250L)
+               }
+               
+               @Resource("mechyrdia-sans")
+               class MechyrdiaSans(val utils: Utils = Utils()) : ResourceReceiver<MechyrdiaSansPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: MechyrdiaSansPayload) {
+                               with(utils) { filterCall() }
+                               
+                               call.respondText(runInterruptible(Dispatchers.Default) {
+                                       MechyrdiaSansFont.renderTextToSvg(payload.lines.joinToString(separator = "\n") { it.trim() }, payload.bold, payload.italic, payload.align)
+                               }, ContentType.Image.SVG)
+                       }
+               }
+               
+               @Resource("tylan-lang")
+               class TylanLanguage(val utils: Utils = Utils()) : ResourceReceiver<TylanLanguagePayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: TylanLanguagePayload) {
+                               with(utils) { filterCall() }
+                               
+                               call.respondText(TylanAlphabetFont.tylanToFontAlphabet(payload.lines.joinToString(separator = "\n")))
+                       }
+               }
+               
+               @Resource("pokhwal-lang")
+               class PokhwalishLanguage(val utils: Utils = Utils()) : ResourceReceiver<PokhwalishLanguagePayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: PokhwalishLanguagePayload) {
+                               with(utils) { filterCall() }
+                               
+                               call.respondText(PokhwalishAlphabetFont.pokhwalToFontAlphabet(payload.lines.joinToString(separator = "\n")))
+                       }
+               }
+               
+               @Resource("preview-comment")
+               class PreviewComment(val utils: Utils = Utils()) : ResourceReceiver<PreviewCommentPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: PreviewCommentPayload) {
+                               with(utils) { filterCall() }
+                               
+                               call.respondText(
+                                       text = TextParserState.parseText(payload.lines.joinToString(separator = "\n"), TextParserCommentTags.asTags, Unit),
+                                       contentType = ContentType.Text.Html
+                               )
+                       }
+               }
+       }
+}
index 388ab355108fa76a0b47e035ed7ade258d7a6743..1fc25efebf9123b71bb484f3a8586bae99798835 100644 (file)
                                        "</svg>\n"
                                ], {type: "image/svg+xml"});
                        } else {
-                               let queryString = "?";
-                               queryString += boldOpt.checked ? "bold=true&" : "";
-                               queryString += italicOpt.checked ? "italic=true&" : "";
-                               queryString += "align=" + alignOpt.value;
+                               const urlParams = new URLSearchParams();
+                               if (boldOpt.checked) urlParams.set("bold", "true");
+                               if (italicOpt.checked) urlParams.set("italic", "true");
+                               urlParams.set("align", alignOpt.value);
 
-                               outBlob = await (await fetch('/mechyrdia-sans' + queryString, {
+                               for (const line of inText.split("\n"))
+                                       urlParams.append("lines", line.trim());
+
+                               outBlob = await (await fetch('/utils/mechyrdia-sans', {
                                        method: 'POST',
                                        headers: {
-                                               'Content-Type': 'text/plain',
+                                               'Content-Type': 'application/x-www-form-urlencoded',
                                        },
-                                       body: inText,
+                                       body: urlParams,
                                })).blob();
 
                                if (inText !== input.value) return;
@@ -64,8 +67,8 @@
                        const alignOpt = mechyrdiaSansBox.getElementsByClassName("align-opts")[0];
                        const outputBox = mechyrdiaSansBox.getElementsByClassName("output-img")[0];
 
-                       const inputListener = () => mechyrdianToFont(inputBox, boldOpt, italicOpt, alignOpt, outputBox, 1250);
-                       const optChangeListener = () => mechyrdianToFont(inputBox, boldOpt, italicOpt, alignOpt, outputBox, 500);
+                       const inputListener = () => mechyrdianToFont(inputBox, boldOpt, italicOpt, alignOpt, outputBox, 750);
+                       const optChangeListener = () => mechyrdianToFont(inputBox, boldOpt, italicOpt, alignOpt, outputBox, 250);
                        inputBox.addEventListener("input", inputListener);
                        boldOpt.addEventListener("change", optChangeListener);
                        italicOpt.addEventListener("change", optChangeListener);
                // Tylan alphabet
                async function tylanToFont(input, output) {
                        const inText = input.value;
-                       const outText = await (await fetch('/tylan-lang', {
+
+                       const urlParams = new URLSearchParams();
+                       for (const line of inText.split("\n"))
+                               urlParams.append("lines", line.trim());
+
+                       const outText = await (await fetch('/utils/tylan-lang', {
                                method: 'POST',
                                headers: {
-                                       'Content-Type': 'text/plain',
+                                       'Content-Type': 'application/x-www-form-urlencoded',
                                },
-                               body: inText,
+                               body: urlParams,
                        })).text();
 
                        if (inText === input.value)
                // Pokhwalish alphabet
                async function pokhwalToFont(input, output) {
                        const inText = input.value;
-                       const outText = await (await fetch('/pokhwal-lang', {
+
+                       const urlParams = new URLSearchParams();
+                       for (const line of inText.split("\n"))
+                               urlParams.append("lines", line.trim());
+
+                       const outText = await (await fetch('/utils/pokhwal-lang', {
                                method: 'POST',
                                headers: {
-                                       'Content-Type': 'text/plain',
+                                       'Content-Type': 'application/x-www-form-urlencoded',
                                },
-                               body: inText,
+                               body: urlParams,
                        })).text();
 
                        if (inText === input.value)
        });
 
        window.addEventListener("load", function () {
-               // Preview themes
+               // Set client theme when selected
                const themeChoices = document.getElementsByName("theme");
                for (const themeChoice of themeChoices) {
                        themeChoice.addEventListener("click", e => {
-                               document.documentElement.setAttribute("data-theme", e.currentTarget.value);
+                               const theme = e.currentTarget.value;
+                               document.documentElement.setAttribute("data-theme", theme);
+                               document.cookie = "FACTBOOK_THEME=" + theme + "; secure; max-age=" + (Math.pow(2, 31) - 1).toString();
                        });
                }
        });
                                const csrfToken = e.currentTarget.getAttribute("data-csrf-token");
                                if (csrfToken != null) {
                                        let csrfInput = document.createElement("input");
-                                       csrfInput.name = "csrf-token";
+                                       csrfInput.name = "csrfToken";
                                        csrfInput.type = "hidden";
                                        csrfInput.value = csrfToken;
                                        form.append(csrfInput);
                                return;
                        }
 
-                       const outText = await (await fetch('/preview-comment', {
+                       const urlParams = new URLSearchParams();
+                       for (const line of inText.split("\n"))
+                               urlParams.append("lines", line.trim());
+
+                       const outText = await (await fetch('/utils/preview-comment', {
                                method: 'POST',
                                headers: {
-                                       'Content-Type': 'text/plain',
+                                       'Content-Type': 'application/x-www-form-urlencoded',
                                },
-                               body: inText,
+                               body: urlParams,
                        })).text();
                        if (input.value !== inText)
                                return;
index b7694329e58552edf70039912c383db1ba8eafb0..4405fdada8ab833e4ea1d6762de4b7ec02414e57 100644 (file)
@@ -1,3 +1,7 @@
+:root {
+       background-color: #fff;
+}
+
 img {
        filter: drop-shadow(0 0 0.5rem rgba(0, 0, 0, 50%));
        padding: 0.75rem;