exception<ForbiddenException> { call, _ ->
call.respondHtml(HttpStatusCode.Forbidden, call.error403())
}
- exception<CsrfFailedException> { call, _ ->
- call.respondHtml(HttpStatusCode.Forbidden, call.error403PageExpired())
+ exception<CsrfFailedException> { call, (_, params) ->
+ call.respondHtml(HttpStatusCode.Forbidden, call.error403PageExpired(params))
}
exception<NullPointerException> { call, _ ->
call.respondHtml(HttpStatusCode.NotFound, call.error404())
call.respondHtml(HttpStatusCode.NotFound, call.error404())
}
- exception<Throwable> { call, ex ->
+ exception<Exception> { call, ex ->
call.application.log.error("Got uncaught exception from serving call ${call.callId}", ex)
call.respondHtml(HttpStatusCode.InternalServerError, call.error500())
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 io.ktor.server.util.*
import kotlinx.html.FORM
import kotlinx.html.hiddenInput
val route: String,
val remoteAddress: String,
val userAgent: String?,
- val expires: Instant = Instant.now().plusSeconds(3600)
+ val userAccount: Id<NationData>?,
+ val expires: Instant
)
-fun ApplicationCall.csrfPayload(route: String, withExpiration: Instant = Instant.now().plusSeconds(3600)) =
+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
)
val params = receive<Parameters>()
val token = params.getOrFail("csrf-token")
- val check = csrfMap.remove(token) ?: throw CsrfFailedException("CSRF token does not exist")
+ 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("CSRF token does not match")
+ throw CsrfFailedException("The submitted CSRF token does not match", params)
if (payload.expires < Instant.now())
- throw CsrfFailedException("CSRF token has expired")
+ throw CsrfFailedException("The submitted CSRF token has expired", params)
return params
}
-class CsrfFailedException(override val message: String) : RuntimeException(message)
+data class CsrfFailedException(override val message: String, val formData: Parameters) : RuntimeException(message)
val nation = postParams.getOrFail("nation").toNationId()
val checksum = postParams.getOrFail("checksum")
- val token = nsTokenMap[postParams.getOrFail("token")]
+ val token = nsTokenMap.remove(postParams.getOrFail("token"))
?: throw MissingRequestParameterException("token")
val result = NSAPI
val contents: String
) : DataDocument<Comment> {
companion object : TableHolder<Comment> {
- override val Table: DocumentTable<Comment> = DocumentTable()
+ override val Table = DocumentTable<Comment>()
override suspend fun initialize() {
Table.index(Comment::submittedBy, Comment::submittedAt)
val repliedAt: @Contextual Instant = Instant.now(),
) : DataDocument<CommentReplyLink> {
companion object : TableHolder<CommentReplyLink> {
- override val Table: DocumentTable<CommentReplyLink> = DocumentTable()
+ override val Table = DocumentTable<CommentReplyLink>()
override suspend fun initialize() {
Table.index(CommentReplyLink::originalPost)
)
}
- suspend fun deleteComment(deletedReply: Id<Comment>) = updateComment(deletedReply, emptySet())
+ suspend fun deleteComment(deletedReply: Id<Comment>) {
+ Table.remove(CommentReplyLink::replyingPost eq deletedReply)
+ }
suspend fun getReplies(original: Id<Comment>): List<Id<Comment>> {
return Table.filter(CommentReplyLink::originalPost eq original)
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
-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
}
}
-fun <T, U> Id<T>.reinterpret() = Id<U>(id)
-
private val secureRandom = SecureRandom.getInstanceStrong()
private val alphabet = "ABCDEFGHILMNOPQRSTVXYZ0123456789".toCharArray()
fun <T> Id() = Id<T>(NanoIdUtils.randomNanoId(secureRandom, alphabet, 24))
object IdSerializer : KSerializer<Id<*>> {
- private val inner = String.serializer()
-
- override val descriptor: SerialDescriptor
- get() = inner.descriptor
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Id", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Id<*>) {
- inner.serialize(encoder, value.id)
+ encoder.encodeString(value.id)
}
override fun deserialize(decoder: Decoder): Id<*> {
- return Id<Any>(inner.deserialize(decoder))
+ return Id<Any>(decoder.decodeString())
}
}
val id: Id<T>
}
-class DocumentTable<T : DataDocument<T>>(val kclass: KClass<T>) {
- private suspend fun collection() = ConnectionHolder.getDatabase().database.getCollection(kclass.simpleName!!, kclass.java).coroutine
+class DocumentTable<T : DataDocument<T>>(val kClass: KClass<T>) {
+ private suspend fun collection() = ConnectionHolder.getDatabase().database.getCollection(kClass.simpleName!!, kClass.java).coroutine
suspend fun index(vararg properties: KProperty1<T, *>) {
collection().ensureIndex(*properties)
}
}
+val CallNationCacheAttribute = AttributeKey<MutableMap<Id<NationData>, NationData>>("NationCache")
+
+val ApplicationCall.nationCache: MutableMap<Id<NationData>, NationData>
+ get() = attributes.getOrNull(CallNationCacheAttribute)
+ ?: mutableMapOf<Id<NationData>, NationData>().also {
+ attributes.put(CallNationCacheAttribute, it)
+ }
+
suspend fun MutableMap<Id<NationData>, NationData>.getNation(id: Id<NationData>): NationData {
return getOrPut(id) {
NationData.get(id)
attributes.getOrNull(CallCurrentNationAttribute)?.let { return it }
return sessions.get<UserSession>()?.nationId?.let { id ->
- NationData.get(id)
+ nationCache.getNation(id)
}?.also { attributes.put(CallCurrentNationAttribute, it) }
}
import info.mechyrdia.lore.dateTime
import io.ktor.server.application.*
import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.html.*
import java.time.Instant
replyLinks = CommentReplyLink.getReplies(comment.id),
)
}
- }.map { it.await() }
+ }.awaitAll()
}
}
}
).formUrlEncode()
)
- val comments = CommentRenderData(Comment.Table.sorted(descending(Comment::submittedAt)).take(limit).toList())
+ val comments = CommentRenderData(
+ Comment.Table
+ .sorted(descending(Comment::submittedAt))
+ .take(limit)
+ .toList(),
+ nationCache
+ )
return page("Recent Comments", standardNavBar()) {
section {
val comment = Comment.Table.get(commentId)!!
+ val currentNation = currentNation()
+ val submitter = nationCache.getNation(comment.submittedBy)
+
+ if (submitter.isBanned && currentNation?.id != comment.submittedBy && currentNation?.id != OwnerNationId)
+ redirectWithError("/lore/${comment.submittedIn}", "The user who posted that comment is banned from commenting")
+
redirect("/lore/${comment.submittedIn}#comment-$commentId")
}
if (currNation.id != comment.submittedBy && currNation.id != OwnerNationId)
throw ForbiddenException("Illegal attempt by ${currNation.id} to delete comment by ${comment.submittedBy}")
- val commentDisplay = CommentRenderData(listOf(comment), mutableMapOf(currNation.id to currNation)).single()
+ val commentDisplay = CommentRenderData(listOf(comment), nationCache).single()
return page("Confirm Deletion of Commment", standardNavBar()) {
section {
import info.mechyrdia.OwnerNationId
import info.mechyrdia.auth.createCsrfToken
import info.mechyrdia.auth.verifyCsrfToken
+import info.mechyrdia.lore.NationProfileSidebar
import info.mechyrdia.lore.page
import info.mechyrdia.lore.redirect
import info.mechyrdia.lore.standardNavBar
suspend fun ApplicationCall.userPage(): HTML.() -> Unit {
val currNation = currentNation()
- val viewingNation = NationData.get(Id(parameters["id"]!!))
+ val viewingNation = nationCache.getNation(Id(parameters["id"]!!))
val comments = CommentRenderData(
Comment.getCommentsBy(viewingNation.id).toList(),
- mutableMapOf(viewingNation.id to viewingNation)
+ nationCache
)
- return page(viewingNation.name, standardNavBar()) {
+ return page(viewingNation.name, standardNavBar(), NationProfileSidebar(viewingNation)) {
section {
a { id = "page-top" }
h1 { +viewingNation.name }
verifyCsrfToken()
- val bannedNation = NationData.get(Id(parameters["id"]!!))
+ val bannedNation = nationCache.getNation(Id(parameters["id"]!!))
- NationData.Table.set(bannedNation.id, setValue(NationData::isBanned, true))
+ if (!bannedNation.isBanned)
+ NationData.Table.set(bannedNation.id, setValue(NationData::isBanned, true))
redirect("/user/${bannedNation.id}")
}
verifyCsrfToken()
- val bannedNation = NationData.get(Id(parameters["id"]!!))
+ val bannedNation = nationCache.getNation(Id(parameters["id"]!!))
- NationData.Table.set(bannedNation.id, setValue(NationData::isBanned, false))
+ if (bannedNation.isBanned)
+ NationData.Table.set(bannedNation.id, setValue(NationData::isBanned, false))
redirect("/user/${bannedNation.id}")
}
}
}
}
+
+fun String.toFriendlyIndexTitle() = split('/')
+ .joinToString(separator = " - ") { part ->
+ part.toFriendlyPageTitle()
+ }
+
+fun String.toFriendlyPageTitle() = split('-')
+ .joinToString(separator = " ") { word ->
+ word.lowercase().replaceFirstChar { it.titlecase() }
+ }
enum class TextParserReplyCounterTag(val type: TextParserTagType<CommentRepliesBuilder>) {
REPLY(
TextParserTagType.Indirect { _, content, builder ->
- builder.addReplyTag(Id(sanitizeLink(content)))
+ sanitizeId(content)?.let { id ->
+ builder.addReplyTag(Id(id))
+ }
"[reply]$content[/reply]"
}
);
REPLY(
TextParserTagType.Indirect { _, content, _ ->
- val id = sanitizeLink(content)
-
- "<a href=\"/comment/view/$id\">>>$id</a>"
+ sanitizeId(content)?.let { id ->
+ "<a href=\"/comment/view/$id\">>>$id</a>"
+ } ?: "[reply]$content[/reply]"
}
),
val DOT_CHARS = Regex("\\.+")
fun sanitizeLink(html: String) = html.replace(NON_LINK_CHAR, "").replace(DOT_CHARS, ".")
+val ID_REGEX = Regex("[A-IL-TVX-Z0-9]{24}")
+fun sanitizeId(html: String) = ID_REGEX.matchEntire(html)?.value
+
fun getSizeParam(tagParam: String?): Pair<Int?, Int?> = tagParam?.let { resolution ->
val parts = resolution.split('x')
parts.getOrNull(0)?.toIntOrNull() to parts.getOrNull(1)?.toIntOrNull()
import com.samskivert.mustache.Escapers
import com.samskivert.mustache.Mustache
-import com.samskivert.mustache.Template
import info.mechyrdia.Configuration
import info.mechyrdia.JsonFileCodec
import io.ktor.util.*
import kotlinx.serialization.json.*
import java.io.File
-import java.security.MessageDigest
@JvmInline
value class JsonPath private constructor(private val pathElements: List<String>) {
.defaultValue("{{ MISSING VALUE \"{{name}}\" }}")
.withLoader { File(Configuration.CurrentConfiguration.templateDir, "$it.tpl").bufferedReader() }
- private val cache = mutableMapOf<String, Template>()
-
private fun convertJson(json: JsonElement, currentFile: File): Any? = when (json) {
JsonNull -> null
is JsonPrimitive -> if (json.isString) {
convertJson(JsonFileCodec.parseToJsonElement(file.readText()), file)
}
- private val msgDigest = ThreadLocal.withInitial { MessageDigest.getInstance("SHA-256") }
-
fun preparse(name: String, content: String): String {
return try {
- val contentHash = hex(msgDigest.get().digest(content.toByteArray()))
- val template = cache[contentHash] ?: compiler.compile(content)
-
+ val template = compiler.compile(content)
val context = loadJsonContext(name)
template.execute(context)
} catch (ex: RuntimeException) {
package info.mechyrdia.lore
-import kotlinx.html.ASIDE
-import kotlinx.html.TagConsumer
-import kotlinx.html.div
+import info.mechyrdia.data.NationData
+import kotlinx.html.*
abstract class Sidebar {
protected abstract fun TagConsumer<*>.display()
}
}
}
+
+data class NationProfileSidebar(val nationData: NationData) : Sidebar() {
+ override fun TagConsumer<*>.display() {
+ img(src = nationData.flag, alt = "Flag of ${nationData.name}", classes = "flag-icon")
+ p {
+ style = "text-align:center"
+ +nationData.name
+ }
+ }
+}
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
-import kotlinx.html.HTML
-import kotlinx.html.a
-import kotlinx.html.h1
-import kotlinx.html.p
+import kotlinx.html.*
suspend fun ApplicationCall.error400(): HTML.() -> Unit = page("400 Bad Request", standardNavBar()) {
section {
}
}
-suspend fun ApplicationCall.error403PageExpired(): HTML.() -> Unit = page("Page Expired", standardNavBar()) {
+suspend fun ApplicationCall.error403PageExpired(formData: Parameters): HTML.() -> Unit = page("Page Expired", standardNavBar()) {
section {
h1 { +"Page Expired" }
+ formData["comment"]?.let { commentData ->
+ p { +"The comment you tried to submit had been preserved here:" }
+ textArea {
+ readonly = true
+ +commentData
+ }
+ }
p {
+"The page you were on has expired."
request.header(HttpHeaders.Referrer)?.let { referrer ->
suspend fun ApplicationCall.error404(): HTML.() -> Unit = page("404 Not Found", standardNavBar()) {
section {
h1 { +"404 Not Found" }
- p { +"Unfortunately, we could not find what you were looking for." }
+ p {
+ +"Unfortunately, we could not find what you were looking for. Would you like to "
+ a(href = "/") { +"return to the index page" }
+ +"?"
+ }
}
}
val pageFile = File(Configuration.CurrentConfiguration.articleDir).combineSafe(pagePath)
val pageNode = pageFile.toArticleNode()
+ val (canCommentAs, comments) = coroutineScope {
+ val canCommentAs = async { currentNation() }
+ val comments = async {
+ CommentRenderData(Comment.getCommentsIn(pagePath).toList(), nationCache)
+ }
+
+ canCommentAs.await() to comments.await()
+ }
+
if (pageFile.isDirectory) {
val navbar = standardNavBar(pagePathParts.takeIf { it.isNotEmpty() })
- val title = pagePath.takeIf { it.isNotEmpty() } ?: "Mechyrdia Infobase"
+ val title = pagePath.takeIf { it.isNotEmpty() }?.toFriendlyIndexTitle() ?: "Mechyrdia Infobase"
+
+ val sidebar = PageNavSidebar(
+ listOf(
+ NavLink("#page-top", title, aClasses = "left"),
+ NavLink("#comments", "Comments", aClasses = "left")
+ )
+ )
- return page(title, navbar, null) {
+ return page(title, navbar, sidebar) {
section {
+ a { id = "page-top" }
h1 { +title }
ul {
pageNode.subNodes.renderInto(this, pagePath.takeIf { it.isNotEmpty() })
}
}
+ section {
+ h2 {
+ a { id = "comments" }
+ +"Comments"
+ }
+ commentInput(pagePath, canCommentAs)
+ for (comment in comments)
+ commentBox(comment, canCommentAs?.id)
+ }
}
- } else {
+ } else if (pageFile.isFile) {
val pageTemplate = pageFile.readText()
val pageMarkup = PreParser.preparse(pagePath, pageTemplate)
val pageHtml = TextParserState.parseText(pageMarkup, TextParserFormattingTag.asTags, Unit)
val pageNav = pageToC.toNavBar() + NavLink("#comments", "Comments", aClasses = "left")
- val (canCommentAs, comments) = coroutineScope {
- val canCommentAs = async { currentNation() }
- val comments = async {
- CommentRenderData(Comment.getCommentsIn(pagePath).toList())
- }
-
- canCommentAs.await() to comments.await()
- }
-
val navbar = standardNavBar(pagePathParts)
val sidebar = PageNavSidebar(pageNav)
commentBox(comment, canCommentAs?.id)
}
}
+ } else {
+ val title = pagePathParts.last().toFriendlyPageTitle()
+
+ val navbar = standardNavBar(pagePathParts)
+
+ val sidebar = PageNavSidebar(
+ listOf(
+ NavLink("#page-top", title, aClasses = "left"),
+ NavLink("#comments", "Comments", aClasses = "left")
+ )
+ )
+
+ return page(title, navbar, sidebar) {
+ section {
+ a { id = "page-top" }
+ h1 { +title }
+ p {
+ +"This factbook does not exist. Would you like to "
+ a(href = "/") { +"return to the index page" }
+ +"?"
+ }
+ }
+ section {
+ h2 {
+ a { id = "comments" }
+ +"Comments"
+ }
+ commentInput(pagePath, canCommentAs)
+ for (comment in comments)
+ commentBox(comment, canCommentAs?.id)
+ }
+ }
}
}
else -> null
}
- return page("Client Preferences", standardNavBar(), null) {
+ return page("Client Preferences", standardNavBar()) {
section {
h1 { +"Client Preferences" }
form(action = "/change-theme", method = FormMethod.post) {
aside.mobile img {
margin: auto;
display: block;
+ width: 50%;
}
@media only screen and (min-width: 8in) {
max-height: calc(92vh - 2.5rem);
}
+ aside.desktop img {
+ width: 100%;
+ }
+
aside.desktop div.list {
max-height: calc(92vh - 7.5rem);
overflow-y: auto;
font-family: 'Noto Sans Gothic', sans-serif;
}
+.flag-icon {
+ object-fit: cover;
+ aspect-ratio: 1;
+ border-radius: 50%;
+}
+
.comment-input {
border: 0.25em solid var(--comment-stroke);
background-color: var(--comment-fill);
}
.comment-box > .comment-author > .flag-icon {
- object-fit: cover;
width: 2em;
- height: 2em;
- border-radius: 1em;
flex-grow: 0;
flex-shrink: 0;