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
}
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")
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.*
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
install(AutoHeadResponse)
install(IgnoreTrailingSlash)
+ val resourcesPlugin = install(Resources)
+ install(ContentNegotiation) {
+ register(ContentType.Application.FormUrlEncoded, KotlinxSerializationConverter(FormUrlEncodedFormat(resourcesPlugin.resourcesFormat)))
+ }
+
install(XForwardedHeaders) {
useLastProxy()
}
serializer = KotlinxSessionSerializer(UserSession.serializer(), JsonStorageCodec)
+ cookie.secure = true
cookie.httpOnly = true
cookie.extensions["SameSite"] = "Lax"
- cookie.extensions["Secure"] = null
}
}
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())
}
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, _>()
}
}
+++ /dev/null
-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)
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")
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"
}
}
-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())
}
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
val id: Id<Comment>,
val submittedBy: NationData,
- val submittedIn: String,
+ val submittedIn: List<String>,
val submittedAt: Instant,
val numEdits: Int,
CommentRenderData(
id = comment.id,
submittedBy = nationData,
- submittedIn = comment.submittedIn,
+ submittedIn = comment.submittedIn.split('/'),
submittedAt = comment.submittedAt,
numEdits = comment.numEdits,
lastEdit = comment.lastEdit,
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 = "/")
}
}
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
}
}
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)"
}
+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"
}
}
+"Replies:"
for (reply in comment.replyLinks) {
+" "
- a(href = "/comment/view/$reply") {
+ a(href = href(Root.Comments.ViewPage(reply))) {
+">>$reply"
}
}
}
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")
name = "comment"
+comment.contentsRaw
}
- installCsrfToken(createCsrfToken(formPath))
+ installCsrfToken()
submitInput { value = "Edit Comment" }
button(classes = "comment-cancel-edit evil") {
+"Cancel Editing"
}
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" }
}
}
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
+Entities.nbsp
if (limit == validLimit)
- +"$validLimit"
+ strong {
+ +"$validLimit"
+ }
else
- a(href = "/comment/recent?limit=$validLimit") {
+ a(href = href(Root.Comments.RecentPage(limit = validLimit))) {
+"$validLimit"
}
}
}
}
-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,
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()
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,
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)
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()
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()) {
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 {
td {
+"Writes text in the Pokhwalish alphabet: "
span(classes = "lang-pokhwal") {
- +PokhwalishAlphabetFont.pokhwalToFontAlphabet("pokhvalsqo jargo")
+ +PokhwalishAlphabetFont.pokhwalToFontAlphabet("pokhvalsqo jaargo")
}
}
}
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(),
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"
}
}
}
}
-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))
}
@Serializable
data class PageVisitTotals(
- val total: Long,
- val totalUnique: Long,
+ val total: Int,
+ val totalUnique: Int,
val mostRecent: @Serializable(with = InstantNullableSerializer::class) Instant?
)
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> {
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)
}
}
}
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 {
return zonedDateTime.month == Month.APRIL && zonedDateTime.dayOfMonth == 1
}
-data class AprilFoolsStaticFileRedirectException(val replacement: File) : RuntimeException()
-
fun redirectFileOnApril1st(requestedFile: File): File? {
if (!isApril1st()) return null
return funnyFile.takeIf { it.exists() }
}
-fun redirectAssetOnApril1st(requestedFile: File) {
- redirectFileOnApril1st(requestedFile)?.let { throw AprilFoolsStaticFileRedirectException(it) }
+fun getAssetFile(requestedFile: File): File {
+ return redirectFileOnApril1st(requestedFile) ?: requestedFile
}
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
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 ->
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
}
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
}
}
}
-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())
+ }
}
}
--- /dev/null
+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
+ }
+ }
+}
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
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")
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 {
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)
LANG(plainTextFormattingTag),
- IMGUR(embeddedFormattingTag),
IMGBB(embeddedFormattingTag),
REPLY(
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(
),
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>"
}
),
;
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
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)
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.*
}
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 {
+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)
+ )
+ )
+ }
+ }
}
package info.mechyrdia.lore
+import info.mechyrdia.route.ErrorMessageAttributeKey
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
}
}
- request.queryParameters["error"]?.let { errorMessage ->
+ this@page.attributes.getOrNull(ErrorMessageAttributeKey)?.let { errorMessage ->
div {
id = "error-popup"
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()
}
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 ->
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" }
+"?"
}
}
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
}
}
-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) {
+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()
}
}
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)
}
}
}
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()
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(
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)
}
}
unsafe { raw(pageHtml) }
}
- finalSection(pagePath, canCommentAs, comments, totalsData)
+ finalSection(pagePathParts, canCommentAs, comments, totalsData)
}
}
}
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)
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"]) {
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")
-}
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
"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 {
context(ApplicationCall)
fun Quote.toRawHtml(title: String): HTML.() -> Unit {
return rawPage(title) {
- a { id = "page-top" }
h1 { +title }
blockQuote {
+quote
--- /dev/null
+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())
+ }
+}
--- /dev/null
+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) }
+ }
+}
--- /dev/null
+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)
--- /dev/null
+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
+ )
+ }
+ }
+ }
+}
"</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;
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;
+:root {
+ background-color: #fff;
+}
+
img {
filter: drop-shadow(0 0 0.5rem rgba(0, 0, 0, 50%));
padding: 0.75rem;