From 9a459b7ec3fd553121d0a698f5f24802851e8efa Mon Sep 17 00:00:00 2001 From: Lanius Trolling Date: Fri, 29 Mar 2024 11:15:53 -0400 Subject: [PATCH] Switch to type-safe resource routing --- build.gradle.kts | 8 +- .../kotlin/info/mechyrdia/Factbooks.kt | 219 +++--------- .../kotlin/info/mechyrdia/auth/csrf.kt | 59 ---- .../kotlin/info/mechyrdia/auth/views_login.kt | 33 +- .../info/mechyrdia/data/view_comments.kt | 39 +-- .../info/mechyrdia/data/views_comment.kt | 105 ++---- .../kotlin/info/mechyrdia/data/views_user.kt | 50 +-- .../kotlin/info/mechyrdia/data/visits.kt | 12 +- .../kotlin/info/mechyrdia/lore/april_1st.kt | 6 +- .../info/mechyrdia/lore/article_listing.kt | 18 +- .../info/mechyrdia/lore/asset_compression.kt | 42 +-- .../kotlin/info/mechyrdia/lore/file_data.kt | 29 ++ .../kotlin/info/mechyrdia/lore/fonts.kt | 105 +++--- .../kotlin/info/mechyrdia/lore/http_utils.kt | 11 +- .../info/mechyrdia/lore/parser_plain.kt | 1 - .../kotlin/info/mechyrdia/lore/parser_raw.kt | 6 +- .../kotlin/info/mechyrdia/lore/parser_tags.kt | 9 - .../kotlin/info/mechyrdia/lore/view_nav.kt | 44 ++- .../kotlin/info/mechyrdia/lore/view_tpl.kt | 3 +- .../kotlin/info/mechyrdia/lore/views_error.kt | 17 +- .../kotlin/info/mechyrdia/lore/views_lore.kt | 63 ++-- .../kotlin/info/mechyrdia/lore/views_prefs.kt | 77 ++--- .../kotlin/info/mechyrdia/lore/views_quote.kt | 52 ++- .../info/mechyrdia/route/resource_bodies.kt | 71 ++++ .../info/mechyrdia/route/resource_csrf.kt | 71 ++++ .../info/mechyrdia/route/resource_handler.kt | 66 ++++ .../info/mechyrdia/route/resource_types.kt | 325 ++++++++++++++++++ src/jvmMain/resources/static/init.js | 61 ++-- src/jvmMain/resources/static/raw.css | 4 + 29 files changed, 990 insertions(+), 616 deletions(-) delete mode 100644 src/jvmMain/kotlin/info/mechyrdia/auth/csrf.kt create mode 100644 src/jvmMain/kotlin/info/mechyrdia/lore/file_data.kt create mode 100644 src/jvmMain/kotlin/info/mechyrdia/route/resource_bodies.kt create mode 100644 src/jvmMain/kotlin/info/mechyrdia/route/resource_csrf.kt create mode 100644 src/jvmMain/kotlin/info/mechyrdia/route/resource_handler.kt create mode 100644 src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt diff --git a/build.gradle.kts b/build.gradle.kts index 488b63b..5fc9a9d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") diff --git a/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt b/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt index f888434..89b777f 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt @@ -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 { call, (url, permanent) -> call.respondRedirect(url, permanent) } - exception { call, (replacement) -> - call.respondCompressedFile(replacement) - } exception { call, _ -> call.respondHtml(HttpStatusCode.BadRequest, call.error400()) } exception { call, _ -> call.respondHtml(HttpStatusCode.Forbidden, call.error403()) } - exception { call, (_, params) -> - call.respondHtml(HttpStatusCode.Forbidden, call.error403PageExpired(params)) + exception { call, (_, payload) -> + call.respondHtml(HttpStatusCode.Forbidden, call.error403PageExpired(payload)) } exception { 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() + get() + get() + get() + get() + get() + get() + get() + get() + get() + get() + post() + post() + get() + get() + post() + get() + post() + get() + post() + get() + get() + post() + post() + post() + post() + post() + post() } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/auth/csrf.kt b/src/jvmMain/kotlin/info/mechyrdia/auth/csrf.kt deleted file mode 100644 index cea7811..0000000 --- a/src/jvmMain/kotlin/info/mechyrdia/auth/csrf.kt +++ /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?, - 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()?.nationId, - expires = withExpiration - ) - -private val csrfMap = ConcurrentHashMap() - -fun ApplicationCall.createCsrfToken(route: String = request.origin.uri): String { - return token().also { csrfMap[it] = csrfPayload(route) } -} - -fun FORM.installCsrfToken(token: String) { - hiddenInput { - name = "csrf-token" - value = token - } -} - -suspend fun ApplicationCall.verifyCsrfToken(route: String = request.origin.uri): Parameters { - val params = receive() - val token = params["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) diff --git a/src/jvmMain/kotlin/info/mechyrdia/auth/views_login.kt b/src/jvmMain/kotlin/info/mechyrdia/auth/views_login.kt index 3cd4df1..e348714 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/auth/views_login.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/auth/views_login.kt @@ -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("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() sessions.clear() sessId?.let { id -> SessionStorageDoc.Table.del(Id(id)) } - redirect("/") + redirectHref(Root()) } diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/view_comments.kt b/src/jvmMain/kotlin/info/mechyrdia/data/view_comments.kt index 7328859..631706b 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/data/view_comments.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/data/view_comments.kt @@ -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, val submittedBy: NationData, - val submittedIn: String, + val submittedIn: List, 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 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>$reply" } } @@ -156,7 +156,7 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id, 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" } } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt b/src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt index 35cedfc..1544645 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt @@ -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, 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(parameters.getOrFail("id")) - +suspend fun ApplicationCall.viewCommentRoute(commentId: Id): 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(parameters.getOrFail("id")) - +suspend fun ApplicationCall.editCommentRoute(commentId: Id, 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 { - val currNation = currentNation() ?: redirectWithError("/auth/login", "You must be logged in to delete comments") - - val commentId = Id(parameters.getOrFail("id")) +private suspend fun ApplicationCall.getCommentForDeletion(commentId: Id): Pair { + 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 Unit { - val (currNation, comment) = getCommentForDeletion() +suspend fun ApplicationCall.deleteCommentPage(commentId: Id): 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): 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") } } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/views_user.kt b/src/jvmMain/kotlin/info/mechyrdia/data/views_user.kt index eefd542..4b72023 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/data/views_user.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/data/views_user.kt @@ -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()?.nationId + if (currNationId == null) + redirectHref(Root.Auth.LoginPage()) + else + redirectHref(Root.User.ById(currNationId)) +} + +suspend fun ApplicationCall.userPage(userId: Id): 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): 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): 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)) } diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/visits.kt b/src/jvmMain/kotlin/info/mechyrdia/data/visits.kt index 335b7b8..2f1fa10 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/data/visits.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/data/visits.kt @@ -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 { companion object : TableHolder { @@ -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 { diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/april_1st.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/april_1st.kt index b178ad3..b0dece9 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/april_1st.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/april_1st.kt @@ -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 } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/article_listing.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/article_listing.kt index 5ae6e26..77e77d3 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/article_listing.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/article_listing.kt @@ -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.renderInto(list: UL, base: String? = null, suffix: String = "") { - val prefix by lazy(LazyThreadSafetyMode.NONE) { base?.let { "$it/" }.orEmpty() } +context(ApplicationCall) +fun List.renderInto(list: UL, base: List = 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 -> diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/asset_compression.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/asset_compression.kt index e933ea4..c09ec21 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/asset_compression.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/asset_compression.kt @@ -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() 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 index 0000000..399bfc2 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/file_data.kt @@ -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 fileData(file: File, loader: (File) -> T): ReadOnlyProperty = object : ReadOnlyProperty { + 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 + } + } +} diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/fonts.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/fonts.kt index 336676c..6fc9f79 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/fonts.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/fonts.kt @@ -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.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 { + 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> { - return object : ReadOnlyProperty> { - 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 { - 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 { - 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 { diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/http_utils.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/http_utils.kt index 11d5998..263a715 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/http_utils.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/http_utils.kt @@ -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 redirectHref(resource: T, permanent: Boolean = false, hash: String? = null): Nothing = redirect(href(resource, hash), permanent) diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_plain.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_plain.kt index b64af73..91d60bc 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_plain.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_plain.kt @@ -102,7 +102,6 @@ enum class TextParserCommentTagsPlainText(val type: TextParserTagType) { LANG(plainTextFormattingTag), - IMGUR(embeddedFormattingTag), IMGBB(embeddedFormattingTag), REPLY( diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt index 7f7bfde..dad72e7 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt @@ -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) { B( @@ -210,12 +210,12 @@ enum class TextParserRawPageTag(val type: TextParserTagType) { ), ALPHABET( TextParserTagType.Indirect(true) { _, _, _ -> - "

Unfortunately, raw view does not support interactive conscript previews

" + "

Unfortunately, raw view does not support interactive constructed script previews

" } ), VOCAB( TextParserTagType.Indirect(true) { _, _, _ -> - "

Unfortunately, raw view does not support interactive conlang dictionaries

" + "

Unfortunately, raw view does not support interactive constructed language dictionaries

" } ), ; diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_tags.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_tags.kt index aec52ae..e37dc19 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_tags.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_tags.kt @@ -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) { LANG(TextParserFormattingTag.LANG.type), - IMGUR( - TextParserTagType.Indirect(false) { tagParam, content, _ -> - val imageUrl = sanitizeExtLink(content) - val (width, height) = getSizeParam(tagParam) - - "" - } - ), IMGBB( TextParserTagType.Indirect(false) { tagParam, content, _ -> val imageUrl = sanitizeExtLink(content) diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt index 199ac0b..f4eac83 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt @@ -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 { } suspend fun ApplicationCall.standardNavBar(path: List? = 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 = 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) + ) + ) + } + } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt index 6d718e3..2ffeffb 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt @@ -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? = null, sideb } } - request.queryParameters["error"]?.let { errorMessage -> + this@page.attributes.getOrNull(ErrorMessageAttributeKey)?.let { errorMessage -> div { id = "error-popup" diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/views_error.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/views_error.kt index 4760549..728d770 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/views_error.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/views_error.kt @@ -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" } +"?" } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/views_lore.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/views_lore.kt index ea5fe36..2afad13 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/views_lore.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/views_lore.kt @@ -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>) = p { +context(ApplicationCall) +private fun FlowContent.breadCrumbs(links: List>) = p { var isNext = false for ((url, text) in links) { if (isNext) { @@ -48,23 +52,34 @@ private fun FlowContent.breadCrumbs(links: List>) = 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.entries, LoreArticleFormat::format) + +fun ApplicationCall.loreRawArticlePage(pagePathParts: List): 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, 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, totalsData: PageVisitTotals) { +private fun SECTIONS.finalSection(pagePathParts: List, canCommentAs: NationData?, comments: List, totalsData: PageVisitTotals) { section { h2 { a { id = "comments" } +"Comments" } - commentInput(pagePath, canCommentAs) + commentInput(pagePathParts, canCommentAs) for (comment in comments) commentBox(comment, canCommentAs?.id) diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/views_prefs.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/views_prefs.kt index 6e8894f..f6f983d 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/views_prefs.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/views_prefs.kt @@ -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") -} diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/views_quote.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/views_quote.kt index 1602f40..c5e0e62 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/views_quote.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/views_quote.kt @@ -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 { - 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.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 index 0000000..105e110 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/route/resource_bodies.kt @@ -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) + +@Serializable +class TylanLanguagePayload(val lines: List) + +@Serializable +class PokhwalishLanguagePayload(val lines: List) + +@Serializable +class PreviewCommentPayload(val lines: List) + +class FormUrlEncodedFormat(private val resourcesFormat: ResourcesFormat) : StringFormat { + override val serializersModule: SerializersModule = resourcesFormat.serializersModule + + override fun encodeToString(serializer: SerializationStrategy, value: T): String { + return resourcesFormat.encodeToParameters(serializer as KSerializer, value).formUrlEncode() + } + + override fun decodeFromString(deserializer: DeserializationStrategy, string: String): T { + return resourcesFormat.decodeFromParameters(deserializer as KSerializer, 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 index 0000000..6b4da06 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/route/resource_csrf.kt @@ -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?, + 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()?.nationId, + expires = withExpiration + ) + +private val csrfMap = ConcurrentHashMap() + +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 index 0000000..df16f5e --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/route/resource_handler.kt @@ -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.handleCall() +} + +interface ResourceReceiver

{ + suspend fun PipelineContext.handleCall(payload: P) +} + +interface ResourceFilter { + suspend fun PipelineContext.filterCall() +} + +inline fun Route.get() { + get { resource -> + with(resource) { + handleCall() + } + } +} + +inline fun , reified P : Any> Route.post() { + post { resource, payload -> + with(resource) { handleCall(payload) } + } +} + +abstract class KeyedEnumSerializer>(val entries: EnumEntries, val getKey: (E) -> String? = { it.name }) : KSerializer { + 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 Application.href(resource: T, hash: String? = null): String = URLBuilder().also { href(resource, it) }.build().fullPath + hash?.let { "#$it" }.orEmpty() +inline fun ApplicationCall.href(resource: T, hash: String? = null) = application.href(resource, hash) +inline fun PipelineContext.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 index 0000000..a3886ab --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt @@ -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("ErrorMessage") + +@Resource("/") +class Root(val error: String? = null) : ResourceHandler, ResourceFilter { + override suspend fun PipelineContext.filterCall() { + error?.let { call.attributes.put(ErrorMessageAttributeKey, it) } + } + + override suspend fun PipelineContext.handleCall() { + filterCall() + call.respondHtml(HttpStatusCode.OK, call.loreIntroPage()) + } + + @Resource("assets/{path...}") + class AssetFile(val path: List, val root: Root = Root()) : ResourceHandler { + override suspend fun PipelineContext.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, val format: LoreArticleFormat = LoreArticleFormat.HTML, val root: Root = Root()) : ResourceHandler { + override suspend fun PipelineContext.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.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.handleCall() { + with(root) { filterCall() } + + with(format) { call.respondQuote(randomQuote()) } + } + } + + @Resource("robots.txt") + class RobotsTxt(val root: Root = Root()) : ResourceHandler { + override suspend fun PipelineContext.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.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.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.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.handleCall() { + with(root) { filterCall() } + + call.respondHtml(HttpStatusCode.OK, call.clientSettingsPage()) + } + } + + @Resource("auth") + class Auth(val root: Root = Root()) : ResourceFilter { + override suspend fun PipelineContext.filterCall() { + with(root) { filterCall() } + } + + @Resource("login") + class LoginPage(val auth: Auth = Auth()) : ResourceHandler { + override suspend fun PipelineContext.handleCall() { + with(auth) { filterCall() } + + call.respondHtml(HttpStatusCode.OK, call.loginPage()) + } + } + + @Resource("login") + class LoginPost(val auth: Auth = Auth()) : ResourceReceiver { + override suspend fun PipelineContext.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 { + override suspend fun PipelineContext.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.filterCall() { + with(root) { filterCall() } + } + + @Resource("help") + class HelpPage(val comments: Comments = Comments()) : ResourceHandler { + override suspend fun PipelineContext.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.handleCall() { + with(comments) { filterCall() } + + call.respondHtml(HttpStatusCode.OK, call.recentCommentsPage(limit)) + } + } + + @Resource("new/{path...}") + class NewPost(val path: List, val comments: Comments = Comments()) : ResourceReceiver { + override suspend fun PipelineContext.handleCall(payload: NewCommentPayload) { + with(comments) { filterCall() } + with(payload) { call.verifyCsrfToken() } + + call.newCommentRoute(path, payload.comment) + } + } + + @Resource("view/{id}") + class ViewPage(val id: Id, val comments: Comments = Comments()) : ResourceHandler { + override suspend fun PipelineContext.handleCall() { + with(comments) { filterCall() } + + call.viewCommentRoute(id) + } + } + + @Resource("edit/{id}") + class EditPost(val id: Id, val comments: Comments = Comments()) : ResourceReceiver { + override suspend fun PipelineContext.handleCall(payload: EditCommentPayload) { + with(comments) { filterCall() } + with(payload) { call.verifyCsrfToken() } + + call.editCommentRoute(id, payload.comment) + } + } + + @Resource("delete/{id}") + class DeleteConfirmPage(val id: Id, val comments: Comments = Comments()) : ResourceHandler { + override suspend fun PipelineContext.handleCall() { + with(comments) { filterCall() } + + call.respondHtml(HttpStatusCode.OK, call.deleteCommentPage(id)) + } + } + + @Resource("delete/{id}") + class DeleteConfirmPost(val id: Id, val comments: Comments = Comments()) : ResourceReceiver { + override suspend fun PipelineContext.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.filterCall() { + with(root) { filterCall() } + } + + override suspend fun PipelineContext.handleCall() { + filterCall() + call.currentUserPage() + } + + @Resource("{id}") + class ById(val id: Id, val user: User = User()) : ResourceHandler { + override suspend fun PipelineContext.handleCall() { + with(user) { filterCall() } + + call.respondHtml(HttpStatusCode.OK, call.userPage(id)) + } + } + } + + @Resource("admin") + class Admin(val root: Root = Root()) : ResourceFilter { + override suspend fun PipelineContext.filterCall() { + with(root) { filterCall() } + call.ownerNationOnly() + } + + @Resource("ban/{id}") + class Ban(val id: Id, val admin: Admin = Admin()) : ResourceReceiver { + override suspend fun PipelineContext.handleCall(payload: AdminBanUserPayload) { + with(admin) { filterCall() } + with(payload) { call.verifyCsrfToken() } + + call.adminBanUserRoute(id) + } + } + + @Resource("unban/{id}") + class Unban(val id: Id, val admin: Admin = Admin()) : ResourceReceiver { + override suspend fun PipelineContext.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.filterCall() { + with(root) { filterCall() } + + delay(250L) + } + + @Resource("mechyrdia-sans") + class MechyrdiaSans(val utils: Utils = Utils()) : ResourceReceiver { + override suspend fun PipelineContext.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 { + override suspend fun PipelineContext.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 { + override suspend fun PipelineContext.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 { + override suspend fun PipelineContext.handleCall(payload: PreviewCommentPayload) { + with(utils) { filterCall() } + + call.respondText( + text = TextParserState.parseText(payload.lines.joinToString(separator = "\n"), TextParserCommentTags.asTags, Unit), + contentType = ContentType.Text.Html + ) + } + } + } +} diff --git a/src/jvmMain/resources/static/init.js b/src/jvmMain/resources/static/init.js index 388ab35..1fc25ef 100644 --- a/src/jvmMain/resources/static/init.js +++ b/src/jvmMain/resources/static/init.js @@ -33,17 +33,20 @@ "\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); @@ -77,12 +80,17 @@ // 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) @@ -128,12 +136,17 @@ // 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) @@ -150,11 +163,13 @@ }); 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(); }); } }); @@ -311,7 +326,7 @@ 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); @@ -624,12 +639,16 @@ 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; diff --git a/src/jvmMain/resources/static/raw.css b/src/jvmMain/resources/static/raw.css index b769432..4405fda 100644 --- a/src/jvmMain/resources/static/raw.css +++ b/src/jvmMain/resources/static/raw.css @@ -1,3 +1,7 @@ +:root { + background-color: #fff; +} + img { filter: drop-shadow(0 0 0.5rem rgba(0, 0, 0, 50%)); padding: 0.75rem; -- 2.25.1