Finally implement Fetch+History factbook loading
authorLanius Trolling <lanius@laniustrolling.dev>
Sat, 24 Aug 2024 17:35:31 +0000 (13:35 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Sat, 24 Aug 2024 17:35:31 +0000 (13:35 -0400)
23 files changed:
build.gradle.kts
src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt
src/jvmMain/kotlin/info/mechyrdia/auth/ViewsLogin.kt
src/jvmMain/kotlin/info/mechyrdia/data/ViewsComment.kt
src/jvmMain/kotlin/info/mechyrdia/data/ViewsFiles.kt
src/jvmMain/kotlin/info/mechyrdia/data/ViewsUser.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ArticleListing.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ArticleTitles.kt
src/jvmMain/kotlin/info/mechyrdia/lore/AssetCaching.kt
src/jvmMain/kotlin/info/mechyrdia/lore/AssetCompression.kt
src/jvmMain/kotlin/info/mechyrdia/lore/AssetHashing.kt
src/jvmMain/kotlin/info/mechyrdia/lore/HttpUtils.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ParserHtml.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ParserRobot.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ViewBar.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ViewNav.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ViewsLore.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRobots.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRss.kt
src/jvmMain/kotlin/info/mechyrdia/route/ResourceCsrf.kt
src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt
src/jvmMain/resources/static/init.js
src/jvmMain/resources/static/style.css

index 61f736768eeb9e262cd9c6793786d6dd343ffde2..72c2a88613c90cdd2a3613473d6a080ed650abca 100644 (file)
@@ -208,11 +208,6 @@ tasks.named<Copy>("jvmProcessResources") {
        }
 }
 
-tasks.named<ShadowJar>("shadowJar") {
-       mergeServiceFiles()
-       exclude { it.name == "module-info.class" }
-}
-
 application {
        mainClass.set("info.mechyrdia.Factbooks")
 }
@@ -224,6 +219,8 @@ fun Task.buildJsAsset(name: String) {
 }
 
 tasks.withType<ShadowJar> {
+       mergeServiceFiles()
+       exclude { it.name == "module-info.class" }
        buildJsAsset("map")
 }
 
index dbbb971fb42331980458f990e76dbfc7b1733b14..2d5eee52a3a5bb316de566f47cd9f1576658c938 100644 (file)
@@ -65,6 +65,8 @@ import io.ktor.server.routing.*
 import io.ktor.server.sessions.*
 import io.ktor.server.sessions.serialization.*
 import io.ktor.server.websocket.*
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
 import org.slf4j.event.Level
 import java.io.IOException
 import java.util.concurrent.atomic.AtomicLong
@@ -170,9 +172,20 @@ fun Application.factbooks() {
                                call.respondHtml(HttpStatusCode.InternalServerError, call.error500())
                }
                
-               exception<HttpRedirectException> { call, (url, permanent) ->
-                       if (!call.isWebDav)
-                               call.respondRedirect(url, permanent)
+               exception<HttpRedirectException> { call, (url, status) ->
+                       if (call.isWebDav) {
+                               call.application.log.error("Attempted to redirect WebDAV request to $url with status $status")
+                               call.respond(HttpStatusCode.InternalServerError)
+                       } else if (call.request.header("X-Redirect-Json").equals("true", ignoreCase = true)) {
+                               call.response.headers.append("X-Redirect-Json", "true")
+                               call.respondText(buildJsonObject {
+                                       put("target", url)
+                                       put("status", status.value)
+                               }.toString(), ContentType.Application.Json)
+                       } else {
+                               call.response.headers.append(HttpHeaders.Location, url)
+                               call.respond(status)
+                       }
                }
                exception<MissingRequestParameterException> { call, _ ->
                        if (call.isWebDav)
index 815d3a486c1535a7550339cbc78276f6b265f408..4408087e311b7d6ba357f6464ff2a5f6451051d7 100644 (file)
@@ -16,26 +16,14 @@ import info.mechyrdia.lore.standardNavBar
 import info.mechyrdia.route.Root
 import info.mechyrdia.route.href
 import info.mechyrdia.route.installCsrfToken
+import io.ktor.http.HttpStatusCode
 import io.ktor.server.application.ApplicationCall
 import io.ktor.server.plugins.MissingRequestParameterException
 import io.ktor.server.sessions.clear
-import io.ktor.server.sessions.sessionId
 import io.ktor.server.sessions.sessions
 import io.ktor.server.sessions.set
 import io.ktor.util.AttributeKey
-import kotlinx.html.FormMethod
-import kotlinx.html.HTML
-import kotlinx.html.br
-import kotlinx.html.button
-import kotlinx.html.form
-import kotlinx.html.h1
-import kotlinx.html.hiddenInput
-import kotlinx.html.label
-import kotlinx.html.p
-import kotlinx.html.section
-import kotlinx.html.style
-import kotlinx.html.submitInput
-import kotlinx.html.textInput
+import kotlinx.html.*
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
 import java.time.Instant
@@ -142,13 +130,11 @@ suspend fun ApplicationCall.loginRoute(nation: String, checksum: String, tokenId
        
        sessions.set(UserSession(nationData.id))
        
-       redirectHref(Root.User())
+       redirectHref(Root.User(), HttpStatusCode.SeeOther)
 }
 
-suspend fun ApplicationCall.logoutRoute(): Nothing {
-       val sessId = sessionId<UserSession>()
+fun ApplicationCall.logoutRoute(): Nothing {
        sessions.clear<UserSession>()
-       sessId?.let { id -> SessionStorageDoc.Table.del(Id(id)) }
        
-       redirectHref(Root())
+       redirectHref(Root(), HttpStatusCode.SeeOther)
 }
index c5a42a581e103de113174e8d360f55530e3b7774..6a3d6503ba3c4809a20ea573baa55d0707d0aee7 100644 (file)
@@ -18,6 +18,7 @@ import info.mechyrdia.lore.toCommentHtml
 import info.mechyrdia.route.Root
 import info.mechyrdia.route.href
 import info.mechyrdia.route.installCsrfToken
+import io.ktor.http.HttpStatusCode
 import io.ktor.server.application.ApplicationCall
 import kotlinx.coroutines.flow.filterNot
 import kotlinx.coroutines.flow.take
@@ -26,14 +27,14 @@ import kotlinx.html.*
 import java.time.Instant
 
 suspend fun ApplicationCall.recentCommentsPage(limit: Int?): HTML.() -> Unit {
-       limit ?: redirectHref(Root.Comments.RecentPage(10))
+       limit ?: redirectHref(Root.Comments.RecentPage(10), HttpStatusCode.Found)
        
        val currNation = currentNation()
        
        val validLimits = listOf(10, 20, 50, 80, 100)
        
        if (limit !in validLimits)
-               redirectHref(Root.Comments.RecentPage(limit = 10))
+               redirectHref(Root.Comments.RecentPage(limit = 10), HttpStatusCode.Found)
        
        val comments = CommentRenderData(
                Comment.Table
@@ -94,7 +95,7 @@ suspend fun ApplicationCall.newCommentRoute(pagePathParts: List<String>, content
        Comment.Table.put(comment)
        CommentReplyLink.updateComment(comment.id, getReplies(contents), now)
        
-       redirectHref(Root.LorePage(pagePathParts), hash = "comment-${comment.id}")
+       redirectHref(Root.LorePage(pagePathParts), HttpStatusCode.SeeOther, hash = "comment-${comment.id}")
 }
 
 suspend fun ApplicationCall.viewCommentRoute(commentId: Id<Comment>): Nothing {
@@ -107,7 +108,7 @@ suspend fun ApplicationCall.viewCommentRoute(commentId: Id<Comment>): Nothing {
                throw NoSuchElementException("Shadowbanned comment")
        
        val pagePathParts = comment.submittedIn.split('/')
-       redirectHref(Root.LorePage(pagePathParts), hash = "comment-$commentId")
+       redirectHref(Root.LorePage(pagePathParts), HttpStatusCode.Found, hash = "comment-$commentId")
 }
 
 suspend fun ApplicationCall.editCommentRoute(commentId: Id<Comment>, newContents: String): Nothing {
@@ -123,7 +124,7 @@ suspend fun ApplicationCall.editCommentRoute(commentId: Id<Comment>, newContents
        
        // Check for null edits, i.e. edits that don't change anything
        if (newContents == oldComment.contents)
-               redirectHref(Root.Comments.ViewPage(oldComment.id))
+               redirectHref(Root.Comments.ViewPage(oldComment.id), HttpStatusCode.SeeOther)
        
        val now = Instant.now()
        val newComment = oldComment.copy(
@@ -135,7 +136,7 @@ suspend fun ApplicationCall.editCommentRoute(commentId: Id<Comment>, newContents
        Comment.Table.put(newComment)
        CommentReplyLink.updateComment(commentId, getReplies(newContents), now)
        
-       redirectHref(Root.Comments.ViewPage(oldComment.id))
+       redirectHref(Root.Comments.ViewPage(oldComment.id), HttpStatusCode.SeeOther)
 }
 
 private suspend fun ApplicationCall.getCommentForDeletion(commentId: Id<Comment>): Pair<NationData, Comment> {
@@ -181,7 +182,7 @@ suspend fun ApplicationCall.deleteCommentRoute(commentId: Id<Comment>): Nothing
        CommentReplyLink.deleteComment(comment.id)
        
        val pagePathParts = comment.submittedIn.split('/')
-       redirectHref(Root.LorePage(pagePathParts), hash = "comments")
+       redirectHref(Root.LorePage(pagePathParts), HttpStatusCode.SeeOther, hash = "comments")
 }
 
 suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commenting Help", standardNavBar()) {
@@ -457,7 +458,7 @@ suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commentin
                                        td {
                                                +"Creates an "
                                                a(href = "https://google.com/") {
-                                                       rel = "nofollow"
+                                                       rel = "nofollow external"
                                                        +"HTML link"
                                                }
                                        }
@@ -496,7 +497,7 @@ suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commentin
                                                strong { +"milliseconds" }
                                                +" counted from "
                                                a(href = "https://en.wikipedia.org/wiki/Unix_time") {
-                                                       rel = "nofollow"
+                                                       rel = "nofollow external"
                                                        +"Unix time"
                                                }
                                                +", and converts it to a client-localized date-time."
index f6e1ee6edf7dc706f685a07c42c6a56ac403993a..5b3663a89855198ff4f6e396a68c4a507d85b6a5 100644 (file)
@@ -279,7 +279,7 @@ suspend fun ApplicationCall.adminDoCopyFile(from: StoragePath, into: StoragePath
        val dest = into / name
        
        if (FileStorage.instance.copyFile(from, dest))
-               redirectHref(Root.Admin.Vfs.View(dest.elements))
+               redirectHref(Root.Admin.Vfs.View(dest.elements), HttpStatusCode.SeeOther)
        else
                respond(HttpStatusCode.Conflict)
 }
@@ -290,14 +290,14 @@ suspend fun ApplicationCall.adminUploadFile(path: StoragePath, part: PartData.Fi
        
        val content = withContext(Dispatchers.IO) { part.streamProvider().readAllBytes() }
        if (FileStorage.instance.writeFile(filePath, content))
-               redirectHref(Root.Admin.Vfs.View(filePath.elements))
+               redirectHref(Root.Admin.Vfs.View(filePath.elements), HttpStatusCode.SeeOther)
        else
                respond(HttpStatusCode.Conflict)
 }
 
 suspend fun ApplicationCall.adminOverwriteFile(path: StoragePath, part: PartData.FileItem) {
        if (FileStorage.instance.writeFile(path, withContext(Dispatchers.IO) { part.streamProvider().readAllBytes() }))
-               redirectHref(Root.Admin.Vfs.View(path.elements))
+               redirectHref(Root.Admin.Vfs.View(path.elements), HttpStatusCode.SeeOther)
        else
                respond(HttpStatusCode.Conflict)
 }
@@ -343,7 +343,7 @@ suspend fun ApplicationCall.adminConfirmDeleteFile(path: StoragePath) {
 
 suspend fun ApplicationCall.adminDeleteFile(path: StoragePath) {
        if (FileStorage.instance.eraseFile(path))
-               redirectHref(Root.Admin.Vfs.View(path.elements.dropLast(1)))
+               redirectHref(Root.Admin.Vfs.View(path.elements.dropLast(1)), HttpStatusCode.SeeOther)
        else
                respond(HttpStatusCode.Conflict)
 }
@@ -352,7 +352,7 @@ suspend fun ApplicationCall.adminMakeDirectory(path: StoragePath, name: String)
        val dirPath = path / name
        
        if (FileStorage.instance.createDir(dirPath))
-               redirectHref(Root.Admin.Vfs.View(dirPath.elements))
+               redirectHref(Root.Admin.Vfs.View(dirPath.elements), HttpStatusCode.SeeOther)
        else
                respond(HttpStatusCode.Conflict)
 }
@@ -396,7 +396,7 @@ suspend fun ApplicationCall.adminConfirmRemoveDirectory(path: StoragePath) {
 
 suspend fun ApplicationCall.adminRemoveDirectory(path: StoragePath) {
        if (FileStorage.instance.deleteDir(path))
-               redirectHref(Root.Admin.Vfs.View(path.elements.dropLast(1)))
+               redirectHref(Root.Admin.Vfs.View(path.elements.dropLast(1)), HttpStatusCode.SeeOther)
        else
                respond(HttpStatusCode.Conflict)
 }
index 456b0af809099721032701072c8a951f7a376884..64c89d784543e1084fd7436f1b7c027b11299cea 100644 (file)
@@ -10,6 +10,7 @@ import info.mechyrdia.lore.standardNavBar
 import info.mechyrdia.route.Root
 import info.mechyrdia.route.href
 import info.mechyrdia.route.installCsrfToken
+import io.ktor.http.HttpStatusCode
 import io.ktor.server.application.ApplicationCall
 import io.ktor.server.sessions.get
 import io.ktor.server.sessions.sessions
@@ -24,9 +25,9 @@ import kotlinx.html.section
 fun ApplicationCall.currentUserPage(): Nothing {
        val currNationId = sessions.get<UserSession>()?.nationId
        if (currNationId == null)
-               redirectHref(Root.Auth.LoginPage())
+               redirectHref(Root.Auth.LoginPage(), HttpStatusCode.Found)
        else
-               redirectHref(Root.User.ById(currNationId))
+               redirectHref(Root.User.ById(currNationId), HttpStatusCode.Found)
 }
 
 suspend fun ApplicationCall.userPage(userId: Id<NationData>): HTML.() -> Unit {
@@ -70,7 +71,7 @@ suspend fun ApplicationCall.adminBanUserRoute(userId: Id<NationData>): Nothing {
        if (!bannedNation.isBanned)
                NationData.Table.set(bannedNation.id, Updates.set(NationData::isBanned.serialName, true))
        
-       redirectHref(Root.User.ById(userId))
+       redirectHref(Root.User.ById(userId), HttpStatusCode.SeeOther)
 }
 
 suspend fun ApplicationCall.adminUnbanUserRoute(userId: Id<NationData>): Nothing {
@@ -79,5 +80,5 @@ suspend fun ApplicationCall.adminUnbanUserRoute(userId: Id<NationData>): Nothing
        if (bannedNation.isBanned)
                NationData.Table.set(bannedNation.id, Updates.set(NationData::isBanned.serialName, false))
        
-       redirectHref(Root.User.ById(userId))
+       redirectHref(Root.User.ById(userId), HttpStatusCode.SeeOther)
 }
index f0670ef11a790f5883598eac059fa3e258346e82..1f19bb27bd815446cab0259100d8d3989c87bc23 100644 (file)
@@ -1,11 +1,15 @@
 package info.mechyrdia.lore
 
 import info.mechyrdia.Configuration
+import info.mechyrdia.OwnerNationId
+import info.mechyrdia.auth.UserSession
 import info.mechyrdia.data.FileStorage
 import info.mechyrdia.data.StoragePath
 import info.mechyrdia.route.Root
 import info.mechyrdia.route.href
 import io.ktor.server.application.ApplicationCall
+import io.ktor.server.sessions.get
+import io.ktor.server.sessions.sessions
 import kotlinx.html.*
 import java.text.Collator
 import java.util.Locale
@@ -33,18 +37,14 @@ fun <T> List<T>.sortedLexically(selector: (T) -> String?) = map { it to collator
 
 private fun List<ArticleNode>.sortedAsArticles() = sortedLexically { it.title.title }.sortedBy { it.subNodes == null }
 
-private val String.isViewable: Boolean
-       get() = Configuration.Current.isDevMode || !(endsWith(".wip") || endsWith(".old"))
+private val String.isPublic: Boolean
+       get() = !endsWith(".wip") && !endsWith(".old")
 
-val ArticleNode.isViewable: Boolean
-       get() = name.isViewable
-
-val StoragePath.isViewable: Boolean
-       get() = name.isViewable
+fun String.isViewableIn(call: ApplicationCall?) = isPublic || Configuration.Current.isDevMode || call?.sessions?.get<UserSession>()?.nationId == OwnerNationId
 
 fun List<ArticleNode>.renderInto(list: UL, base: List<String> = emptyList(), format: LoreArticleFormat = LoreArticleFormat.HTML, call: ApplicationCall) {
        for (node in this)
-               if (node.isViewable)
+               if (node.name.isViewableIn(call))
                        list.li {
                                val nodePath = base + node.name
                                a(href = call.href(Root.LorePage(nodePath, format))) {
index fdcaded447b89d4bd2a4416470a8e317c14031af..985e60bd1ef059d8134bc584d5b8d19138896c72 100644 (file)
@@ -5,28 +5,45 @@ import info.mechyrdia.data.StoragePath
 data class ArticleTitle(val title: String, val css: String = "")
 
 object ArticleTitleCache : FileDependentCache<ArticleTitle>() {
-       override suspend fun processFile(path: StoragePath): ArticleTitle? {
+       private val StoragePath.defaultTitle: String
+               get() = if (elements.size > 1)
+                       elements.lastOrNull()?.split('-')?.joinToString(separator = " ") { word ->
+                               word.lowercase().replaceFirstChar { it.titlecase() }
+                       }.orEmpty()
+               else TOC_TITLE
+       
+       private val StoragePath.defaultCssProps: List<String>
+               get() = listOfNotNull(
+                       if (name.endsWith(".wip")) "opacity:0.675" else null,
+                       if (name.endsWith(".old")) "text-decoration:line-through" else null,
+               )
+       
+       override fun default(path: StoragePath): ArticleTitle {
+               return ArticleTitle(path.defaultTitle, path.defaultCssProps.joinToString(separator = ";"))
+       }
+       
+       override suspend fun processFile(path: StoragePath): ArticleTitle {
                if (path !in StoragePath.articleDir)
-                       return null
+                       error("Invalid path for ArticleTitleCache /$path")
+               
+               val title = path.defaultTitle
+               val cssProps = path.defaultCssProps
                
-               val factbookAst = FactbookLoader.loadFactbook(path.elements.drop(1)) ?: return null
+               val factbookAst = FactbookLoader.loadFactbook(path.elements.drop(1))
+                       ?: return ArticleTitle(title, cssProps.joinToString(separator = ";"))
                
-               val title = factbookAst
+               val factbookTitle = factbookAst
                        .firstNotNullOfOrNull { node ->
                                (node as? ParserTreeNode.Tag)?.takeIf { tag -> tag.tag == "h1" }
                        }
                        ?.subNodes
                        ?.treeToText()
-                       ?: return null
+                       ?: title
                
-               val css = listOfNotNull(
+               val factbookCssProps = cssProps + listOfNotNull(
                        if (factbookAst.any { it is ParserTreeNode.Tag && it.tag == "redirect" }) "font-style:italic" else null,
-                       
-                       // Only used in dev-mode
-                       if (path.name.endsWith(".wip")) "opacity:0.675" else null,
-                       if (path.name.endsWith(".old")) "text-decoration:line-through" else null,
-               ).joinToString(separator = ";")
+               )
                
-               return ArticleTitle(title, css)
+               return ArticleTitle(factbookTitle, factbookCssProps.joinToString(separator = ";"))
        }
 }
index 708b607632d36d9ee45dd2cd525faa9c8dc24c41..65bd140746e5a24bf982827b06b79952b44f9332 100644 (file)
@@ -10,28 +10,25 @@ import java.util.concurrent.ConcurrentHashMap
 
 val StoragePathAttributeKey = AttributeKey<StoragePath>("Mechyrdia.StoragePath")
 
-abstract class FileDependentCache<T : Any> {
-       private inner class Entry(updated: Instant?, data: T?) {
+abstract class FileDependentCache<T> {
+       private inner class Entry(updated: Instant?, data: T) {
                private var updated: Instant = updated ?: Instant.MIN
-               var data: T? = data
+               var data: T = data
                        private set
                
                private val updateLock = Mutex()
                
-               private fun clear() {
-                       updated = Instant.MIN
-                       data = null
-               }
-               
                suspend fun updateIfNeeded(path: StoragePath): Entry {
                        return updateLock.withLock {
-                               FileStorage.instance.statFile(path)?.updated?.let { fileUpdated ->
-                                       if (updated < fileUpdated) {
-                                               updated = fileUpdated
-                                               data = processFile(path)
-                                       }
-                                       this
-                               } ?: apply { clear() }
+                               val fileUpdated = FileStorage.instance.statFile(path)?.updated
+                               if (fileUpdated == null) {
+                                       updated = Instant.MIN
+                                       data = default(path)
+                               } else if (updated < fileUpdated) {
+                                       updated = fileUpdated
+                                       data = processFile(path)
+                               }
+                               this
                        }
                }
        }
@@ -41,13 +38,15 @@ abstract class FileDependentCache<T : Any> {
        
        private suspend fun Entry(path: StoragePath) = cacheLock.withLock {
                cache.getOrPut(path) {
-                       Entry(null, null)
+                       Entry(null, default(path))
                }
        }
        
-       protected abstract suspend fun processFile(path: StoragePath): T?
+       protected abstract fun default(path: StoragePath): T
+       
+       protected abstract suspend fun processFile(path: StoragePath): T
        
-       suspend fun get(path: StoragePath): T? {
+       suspend fun get(path: StoragePath): T {
                return Entry(path).updateIfNeeded(path).data
        }
 }
index 1bb1702f38b307c7472a7678892d63ab81db7670..7aec26996ee9beb4d0c103c2d76bbd7780a23364 100644 (file)
@@ -42,7 +42,9 @@ suspend fun ApplicationCall.respondCompressedFile(path: StoragePath) {
        respondBytes(compressedBytes)
 }
 
-private class CompressedCache(val encoding: String, private val compressorFactory: (OutputStream, Boolean) -> FilterOutputStream) : FileDependentCache<ByteArray>() {
+private class CompressedCache(val encoding: String, private val compressorFactory: (OutputStream, Boolean) -> FilterOutputStream) : FileDependentCache<ByteArray?>() {
+       override fun default(path: StoragePath) = null
+       
        override suspend fun processFile(path: StoragePath): ByteArray? {
                val fileContents = FileStorage.instance.readFile(path) ?: return null
                
index f467a8f0ff0289f9aab2014297f490a11672fe60..b7702b32cf51e1a2ac7345a9a5ed6654d13ddd3f 100644 (file)
@@ -56,9 +56,11 @@ private class DigestingOutputStream(stomach: MessageDigest) : OutputStream() {
        }
 }
 
-private class FileHashCache(val hashAlgo: String) : FileDependentCache<ByteArray>() {
+private class FileHashCache(val hashAlgo: String) : FileDependentCache<ByteArray?>() {
        private val hashinator: ThreadLocal<MessageDigest> = ThreadLocal.withInitial { MessageDigest.getInstance(hashAlgo) }
        
+       override fun default(path: StoragePath) = null
+       
        override suspend fun processFile(path: StoragePath): ByteArray? {
                val fileContents = FileStorage.instance.readFile(path) ?: return null
                
index f6f028cc162c531d313e7ccc4f54f994737f8200..8796f77efb1f847cf4505c6cf151daf1672f12d6 100644 (file)
@@ -2,15 +2,16 @@ package info.mechyrdia.lore
 
 import info.mechyrdia.route.ErrorMessageCookieName
 import info.mechyrdia.route.href
+import io.ktor.http.HttpStatusCode
 import io.ktor.server.application.ApplicationCall
 
-data class HttpRedirectException(val url: String, val permanent: Boolean) : RuntimeException()
+data class HttpRedirectException(val url: String, val status: HttpStatusCode) : RuntimeException()
 
-fun redirect(url: String, permanent: Boolean = false): Nothing = throw HttpRedirectException(url, permanent)
+fun redirect(url: String, status: HttpStatusCode): Nothing = throw HttpRedirectException(url, status)
 
 inline fun <reified T : Any> ApplicationCall.redirectHrefWithError(resource: T, error: String, hash: String? = null): Nothing {
        response.cookies.append(ErrorMessageCookieName, error, secure = true, httpOnly = false, extensions = mapOf("SameSite" to "Lax"))
-       redirect(href(resource, hash), false)
+       redirect(href(resource, hash), HttpStatusCode.Found)
 }
 
-inline fun <reified T : Any> ApplicationCall.redirectHref(resource: T, permanent: Boolean = false, hash: String? = null): Nothing = redirect(href(resource, hash), permanent)
+inline fun <reified T : Any> ApplicationCall.redirectHref(resource: T, status: HttpStatusCode, hash: String? = null): Nothing = redirect(href(resource, hash), status)
index ff4661c65dfaa3ad76aff7d60e090346e3a6f267..0f48b04d272897a963193ff0e7917e41e4e356b2 100644 (file)
@@ -381,10 +381,10 @@ fun processInternalLink(param: String?): Map<String, String> = param
 fun processExternalLink(param: String?): Map<String, String> = param
        ?.sanitizeExtLink()
        ?.toExternalUrl()
-       ?.let { mapOf("href" to it) }
+       ?.let { mapOf("href" to it, "rel" to "external") }
        .orEmpty()
 
-fun processCommentLink(param: String?): Map<String, String> = processExternalLink(param) + mapOf("rel" to "ugc nofollow")
+fun processCommentLink(param: String?): Map<String, String> = processExternalLink(param) + mapOf("rel" to "ugc external nofollow")
 
 fun processCommentImage(url: String, domain: String) = "https://$domain/${url.sanitizeExtImgLink()}"
 
index 8bc0af3c1214f7b2d2af34a5ce4c5de0e7604169..74e21936ff2f6f1407e4d91efefab252d3e57ecc 100644 (file)
@@ -197,7 +197,7 @@ object RobotFactbookLoader {
        }
        
        suspend fun loadAllFactbooks(): Map<String, String> {
-               return allPages().mapSuspend { pathStat ->
+               return allPages(null).mapSuspend { pathStat ->
                        val lorePath = pathStat.path.elements.drop(1)
                        FactbookLoader.loadFactbook(lorePath)?.toFactbookRobotText(lorePath)?.let { robotText ->
                                lorePath.joinToString(separator = "/") to robotText
@@ -206,7 +206,7 @@ object RobotFactbookLoader {
        }
        
        suspend fun loadAllFactbooksSince(lastUpdated: Instant): Map<String, String> {
-               return allPages().mapSuspend { pathStat ->
+               return allPages(null).mapSuspend { pathStat ->
                        if (pathStat.stat.updated >= lastUpdated) {
                                val lorePath = pathStat.path.elements.drop(1)
                                FactbookLoader.loadFactbook(lorePath)?.toFactbookRobotText(lorePath)?.let { robotText ->
index 06b0d151d4d52457ef91f6e71e9fc55d8593cba4..0f415fe39a861f8e7e3999af655abf0c5899f5b4 100644 (file)
@@ -26,6 +26,7 @@ data class NationProfileSidebar(val nationData: NationData) : Sidebar() {
                p {
                        style = "text-align:center"
                        a(href = "https://www.nationstates.net/nation=${nationData.id}") {
+                               rel = "nofollow external"
                                +nationData.name
                        }
                }
index 73c9b57ed4ebc0aa87a760922138a262f9f2903c..865de71a2419a224f79f23e9df6a29c8a9b25885 100644 (file)
@@ -30,7 +30,7 @@ suspend fun loadExternalLinks(): List<NavItem> {
        val extraLinks = JsonFileCodec.decodeFromString(ListSerializer(ExternalLink.serializer()), extraLinksJson)
        return if (extraLinks.isEmpty())
                emptyList()
-       else (listOf(NavHead("See Also")) + extraLinks.map { NavLink(it.url, it.text, textIsHtml = true) })
+       else (listOf(NavHead("See Also")) + extraLinks.map { NavLink.external(it.url, it.text, textIsHtml = true) })
 }
 
 suspend fun ApplicationCall.standardNavBar(path: List<String>? = null) = listOf(
@@ -47,7 +47,7 @@ suspend fun ApplicationCall.standardNavBar(path: List<String>? = null) = listOf(
        else emptyList()) + listOf(
                NavHead(data.name),
                NavLink(href(Root.User()), "Your User Page"),
-               NavLink("https://www.nationstates.net/nation=${data.id}", "Your NationStates Page"),
+               NavLink.external("https://www.nationstates.net/nation=${data.id}", "Your NationStates Page"),
                NavLink.withCsrf(href(Root.Auth.LogoutPost()), "Log Out", call = this),
        )
 } ?: listOf(
@@ -103,6 +103,18 @@ data class NavLink(
        }
        
        companion object {
+               fun external(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(
+                                       "rel" to (extraAttributes["ref"]?.let { "$it " }.orEmpty() + "external")
+                               )
+                       )
+               }
+               
                fun withCsrf(to: String, text: String, textIsHtml: Boolean = false, aClasses: String? = null, extraAttributes: Map<String, String> = emptyMap, call: ApplicationCall): NavLink {
                        return NavLink(
                                to = to,
@@ -110,7 +122,6 @@ data class NavLink(
                                textIsHtml = textIsHtml,
                                aClasses = aClasses,
                                linkAttributes = extraAttributes + mapOf(
-                                       "data-method" to "post",
                                        "data-csrf-token" to call.createCsrfToken(to)
                                )
                        )
index 0ac31c11fb671c326ace9b47bbe10eb6497c34d0..2d936ef8986214a49a687288ed1cc0669ea8338d 100644 (file)
@@ -85,7 +85,7 @@ suspend fun ApplicationCall.loreRawArticlePage(pagePath: List<String>): HTML.()
                Root.LorePage(prefixPath, LoreArticleFormat.RAW_HTML) to (StoragePath.articleDir / prefixPath).toFriendlyPageTitle()
        }
        
-       val isValid = FileStorage.instance.getType(pageFile) != null && pageFile.isViewable
+       val isValid = FileStorage.instance.getType(pageFile) != null && pageFile.name.isViewableIn(this)
        
        if (isValid) {
                if (pageNode.subNodes != null) {
@@ -147,7 +147,7 @@ suspend fun ApplicationCall.loreArticlePage(pagePath: List<String>, format: Lore
                canCommentAs.await() to comments.await()
        }
        
-       val isValid = FileStorage.instance.getType(pageFile) != null && pageFile.isViewable
+       val isValid = FileStorage.instance.getType(pageFile) != null && pageFile.name.isViewableIn(this)
        
        if (isValid) {
                if (pageNode.subNodes != null) {
index 8d9f322ee21791ad7a9e177a735947b2a0f7d945..f97c092c90adfa44ec72639b770127c3f72c11ee 100644 (file)
@@ -8,6 +8,7 @@ import info.mechyrdia.data.XmlTag
 import info.mechyrdia.data.XmlTagConsumer
 import info.mechyrdia.data.declaration
 import info.mechyrdia.data.root
+import io.ktor.server.application.ApplicationCall
 import java.time.Instant
 import java.time.ZoneId
 import java.time.format.DateTimeFormatter
@@ -67,10 +68,9 @@ private suspend fun buildIntroSitemap(): SitemapEntry? {
        )
 }
 
-private suspend fun buildLoreSitemap(): List<SitemapEntry> {
-       return allPages().mapNotNull { page ->
-               if (!page.path.isViewable) null
-               else SitemapEntry(
+private suspend fun buildLoreSitemap(call: ApplicationCall): List<SitemapEntry> {
+       return allPages(call).map { page ->
+               SitemapEntry(
                        loc = "$MainDomainName/${page.path}",
                        lastModified = page.stat.updated,
                        changeFrequency = AVERAGE_FACTBOOK_PAGE_CHANGEFREQ,
@@ -79,7 +79,7 @@ private suspend fun buildLoreSitemap(): List<SitemapEntry> {
        }
 }
 
-suspend fun buildSitemap() = listOfNotNull(buildIntroSitemap()) + buildLoreSitemap()
+suspend fun buildSitemap(call: ApplicationCall) = listOfNotNull(buildIntroSitemap()) + buildLoreSitemap(call)
 
 fun <T, C : XmlTagConsumer<T>> C.sitemap(entries: List<SitemapEntry>) = declaration()
        .root("urlset", namespace = "http://www.sitemaps.org/schemas/sitemap/0.9") {
index 503c61c808809cc9d4f1c5dc5693334302db015b..eef186c022f424aa3ce3549b34a0f1a7d1aec693 100644 (file)
@@ -50,8 +50,8 @@ private suspend fun statAll(paths: Iterable<StoragePath>): StoredFileStats? {
        )
 }
 
-private suspend fun ArticleNode.getPages(base: StoragePath): List<StoragePathWithStat> {
-       if (!this.isViewable)
+private suspend fun ArticleNode.getPages(base: StoragePath, call: ApplicationCall?): List<StoragePathWithStat> {
+       if (!name.isViewableIn(call))
                return emptyList()
        val path = base / name
        val dataPath = path.rebase(StoragePath.jsonDocDir)
@@ -59,18 +59,18 @@ private suspend fun ArticleNode.getPages(base: StoragePath): List<StoragePathWit
        return if (stat != null)
                listOf(StoragePathWithStat(path, stat))
        else subNodes?.mapSuspend { subNode ->
-               subNode.getPages(path)
+               subNode.getPages(path, call)
        }?.flatten().orEmpty()
 }
 
-suspend fun allPages(): List<StoragePathWithStat> {
+suspend fun allPages(call: ApplicationCall?): List<StoragePathWithStat> {
        return rootArticleNodeList().mapSuspend { subNode ->
-               subNode.getPages(StoragePath.articleDir)
+               subNode.getPages(StoragePath.articleDir, call)
        }.flatten()
 }
 
-suspend fun generateRecentPageEdits(): RssChannel {
-       val pages = allPages().sortedByDescending { it.stat.updated }
+suspend fun generateRecentPageEdits(call: ApplicationCall): RssChannel {
+       val pages = allPages(call).sortedByDescending { it.stat.updated }
        
        val mostRecentChange = pages.firstOrNull()?.stat?.updated
        
@@ -227,7 +227,7 @@ data class RssChannel(
        val image: RssChannelImage? = null,
        val categories: List<RssCategory> = emptyList(),
        val items: List<RssItem> = emptyList(),
-): XmlInsertable {
+) : XmlInsertable {
        override fun XmlTag.intoXml() {
                "channel" {
                        "title" { +title }
index 9e5a554f85e2007a2c59d2bdd944462bb2976a40..27e15bea9ede6174d474e986e0bb3aa65d61015e 100644 (file)
@@ -32,7 +32,6 @@ suspend fun ApplicationCall.checkCsrfToken(csrfToken: String?, route: String = r
 }
 
 fun A.installCsrfToken(route: String = href, call: ApplicationCall) {
-       attributes["data-method"] = "post"
        attributes["data-csrf-token"] = call.createCsrfToken(route)
 }
 
index abb19440b9d078d6f357b0f9a4cc2657647db269..341739cfdf02608dd428953e3f81411c2e223f10 100644 (file)
@@ -141,7 +141,7 @@ class Root : ResourceHandler, ResourceFilter {
                override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
                        with(root) { filterCall() }
                        
-                       val sitemap = buildSitemap()
+                       val sitemap = buildSitemap(call)
                        call.respondXml(contentType = ContentType.Application.Xml) {
                                sitemap(sitemap)
                        }
@@ -153,7 +153,7 @@ class Root : ResourceHandler, ResourceFilter {
                override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
                        with(root) { filterCall() }
                        
-                       call.respondRss(generateRecentPageEdits())
+                       call.respondRss(generateRecentPageEdits(call))
                }
        }
        
@@ -367,7 +367,7 @@ class Root : ResourceHandler, ResourceFilter {
                                        
                                        RobotService.getInstance()?.performMaintenance()
                                        
-                                       call.redirectHref(NukeManagement())
+                                       call.redirectHref(NukeManagement(), HttpStatusCode.SeeOther)
                                }
                        }
                        
@@ -379,7 +379,7 @@ class Root : ResourceHandler, ResourceFilter {
                                        
                                        RobotService.getInstance()?.reset()
                                        
-                                       call.redirectHref(NukeManagement())
+                                       call.redirectHref(NukeManagement(), HttpStatusCode.SeeOther)
                                }
                        }
                }
index 10a826e6d87a12ac26fc80a638ef1e3e42dec86b..766cc5a54be5e448d680c13a101d53a1f301c6fd 100644 (file)
                });
        })();
 
+       /**
+        * @param {FormData} formData
+        * @returns {(URLSearchParams|FormData)}
+        */
+       function formDataUrlEncoded(formData) {
+               const entries = [];
+               for (const [key, value] of formData) {
+                       if (value instanceof Blob) {
+                               return formData;
+                       } else {
+                               entries.push([key, value]);
+                       }
+               }
+               return new URLSearchParams(entries);
+       }
+
+       /**
+        * @param {ChildNode} target
+        * @param {ChildNode} replacement
+        */
+       function replaceElement(target, replacement) {
+               replacement.remove();
+               target.replaceWith(replacement);
+       }
+
+       /**
+        * @param {HTMLHeadElement} target
+        * @param {HTMLHeadElement} source
+        */
+       function replaceOgData(target, source) {
+               const targetDesc = target.querySelector("meta[name=description]");
+               if (targetDesc != null) {
+                       targetDesc.remove();
+               }
+
+               for (const ogTarget of target.querySelectorAll("meta[property^=\"og:\"]")) {
+                       ogTarget.remove();
+               }
+
+               let insertAfter = target.querySelector("meta[name=theme-color]");
+
+               const sourceDesc = source.querySelector("meta[name=description]");
+               if (sourceDesc != null) {
+                       const targetDesc = document.createElement("meta");
+                       targetDesc.setAttribute("name", "description");
+                       targetDesc.setAttribute("content", sourceDesc.getAttribute("content"));
+                       insertAfter.after(targetDesc);
+                       insertAfter = targetDesc;
+               }
+
+               for (const ogSource of source.querySelectorAll("meta[property^=\"og:\"]")) {
+                       const ogTarget = document.createElement("meta");
+                       ogTarget.setAttribute("property", ogSource.getAttribute("property"));
+                       ogTarget.setAttribute("content", ogSource.getAttribute("content"));
+                       insertAfter.after(ogTarget);
+                       insertAfter = ogTarget;
+               }
+       }
+
+       /**
+        * @param {string} pathName
+        * @returns {boolean}
+        */
+       function isPagePath(pathName) {
+               if (pathName === "/") {
+                       return true;
+               }
+
+               return pathName.startsWith("/lore")
+                       || pathName.startsWith("/quote")
+                       || pathName.startsWith("/preferences")
+                       || pathName.startsWith("/auth")
+                       || pathName.startsWith("/nuke")
+                       || pathName.startsWith("/comment")
+                       || pathName.startsWith("/user");
+       }
+
+       /**
+        * @param {URL} url
+        * @param {string} stateMode
+        * @param {?(URLSearchParams|FormData)} [formData=undefined]
+        * @return {boolean}
+        */
+       function goToPage(url, stateMode, formData) {
+               if (url.origin !== window.location.origin || !isPagePath(url.pathname) || url.searchParams.getAll("format").filter(format => format.toLowerCase() !== "html").length > 0) {
+                       return false;
+               }
+
+               (async function () {
+                       const newState = {"href": url.pathname, "index": history.state.index + 1};
+
+                       if (stateMode === "push") {
+                               history.replaceState({...history.state, "scroll": window.scrollY}, "");
+                               history.pushState(newState, "", url);
+                       } else if (stateMode === "replace") {
+                               history.replaceState(newState, "", url);
+                       } else if (stateMode === "pop") {
+                               newState.index = history.state.index;
+                       }
+
+                       const requestBody = {};
+                       if (formData != null) {
+                               requestBody.body = formData;
+                               requestBody.method = "post";
+                       }
+                       const htmlResponse = await fetch(url, {
+                               ...requestBody,
+                               headers: {
+                                       "X-Redirect-Json": "true"
+                               },
+                               mode: "same-origin"
+                       });
+
+                       htmlResponse.headers.forEach(console.log);
+                       const redirectJson = htmlResponse.headers.get("X-Redirect-Json");
+                       if (redirectJson != null && redirectJson.toLowerCase() === "true") {
+                               const redirectJsonBody = await htmlResponse.json();
+                               if (history.state.href !== newState.href || history.state.index !== newState.index) {
+                                       return;
+                               }
+
+                               const redirectUrl = new URL(redirectJsonBody.target, window.location.origin)
+                               if (!goToPage(redirectUrl, "replace")) {
+                                       window.location.href = redirectUrl.href;
+                               }
+
+                               return;
+                       }
+
+                       const htmlTextBody = await htmlResponse.text();
+                       if (history.state.href !== newState.href || history.state.index !== newState.index) {
+                               return;
+                       }
+
+                       const htmlDocument = new DOMParser().parseFromString(htmlTextBody, "text/html");
+
+                       document.title = htmlDocument.title;
+                       replaceOgData(document.head, htmlDocument.head);
+                       replaceElement(document.body, htmlDocument.body);
+
+                       onDomLoad(document.body);
+                       if (stateMode === "pop") {
+                               window.scroll(0, history.state.scroll);
+                       } else if (url.hash !== '') {
+                               const scrollToElement = document.querySelector(url.hash);
+                               if (scrollToElement != null) {
+                                       scrollToElement.scrollIntoView(true);
+                               }
+                       } else {
+                               window.scroll(0, 0);
+                       }
+               })().catch(reason => {
+                       console.error("Error restoring history state!", reason);
+               });
+
+               return true;
+       }
+
+       history.replaceState({"href": window.location.pathname, "index": 0}, "");
+
+       let isWindowScrollTicking = false;
+       window.addEventListener("scroll", () => {
+               if (!isWindowScrollTicking) {
+                       isWindowScrollTicking = true;
+                       window.setTimeout(() => {
+                               history.replaceState({...history.state, "scroll": window.scrollY}, "");
+                               isWindowScrollTicking = false;
+                       }, 50);
+               }
+       });
+
+       window.addEventListener("popstate", e => {
+               e.preventDefault();
+               const url = new URL(e.state.href, window.location.origin);
+               if (!goToPage(url, "pop")) {
+                       window.location.href = url.href;
+               }
+       });
+
+       /**
+        * @param {MouseEvent} e
+        */
+       function aClickHandler(e) {
+               if (goToPage(new URL(e.currentTarget.href, window.location), "push")) {
+                       e.preventDefault();
+               }
+       }
+
+       /**
+        * @param {SubmitEvent} e
+        */
+       function formSubmitHandler(e) {
+               const url = new URL(e.currentTarget.action, window.location);
+               const formData = formDataUrlEncoded(new FormData(e.currentTarget, e.submitter));
+               if (e.currentTarget.method.toLowerCase() === "post") {
+                       if (goToPage(url, "push", formData)) {
+                               e.preventDefault();
+                       }
+               } else {
+                       url.search = "?" + formData.toString();
+                       if (goToPage(url, "push")) {
+                               e.preventDefault();
+                       }
+               }
+       }
+
        /**
         * @returns {Object.<string, string>}
         */
                        }, {});
        }
 
-       /**
-        * @param {ParentNode} element
-        */
-       function clearChildren(element) {
-               while (element.hasChildNodes()) {
-                       element.firstChild.remove();
-               }
-       }
-
        /**
         * @param {number} amount
         * @return {Promise<void>}
        }
 
        /**
-        * @returns {Promise<function(string): Promise<THREE.Mesh>>}
+        * @returns {Promise<void>}
         */
        async function loadThreeJs() {
-               await loadScript("/static/obj-viewer/three.js");
-               await loadScript("/static/obj-viewer/three-examples.js");
-
-               /**
-                * @param {string} modelName
-                * @returns {Promise<THREE.Mesh>}
-                */
-               async function loadObj(modelName) {
-                       const THREE = window.THREE;
-                       const mtlLib = await (new THREE.MTLLoader()).setPath("/assets/meshes/").setResourcePath("/assets/meshes/").loadAsync(modelName + ".mtl");
-                       mtlLib.preload();
-                       return await (new THREE.OBJLoader()).setPath("/assets/meshes/").setResourcePath("/assets/meshes/").setMaterials(mtlLib).loadAsync(modelName + ".obj");
+               if (window.THREE == null) {
+                       await loadScript("/static/obj-viewer/three.js");
+                       await loadScript("/static/obj-viewer/three-examples.js");
                }
-
-               return loadObj;
        }
 
        /**
 
                        const searchTerm = vocabSearch.value.trim();
 
-                       clearChildren(vocabSearchResults);
+                       vocabSearchResults.replaceChildren();
 
                        const searchResults = [];
                        if (vocabEnglishToLang.checked) {
                const questionAnswers = [];
 
                function renderIntro() {
-                       clearChildren(quizRoot);
+                       quizRoot.replaceChildren();
 
                        const firstRow = document.createElement("tr");
                        const firstCell = document.createElement("td");
                 * @param {QuizOutcome} outcome
                 */
                function renderOutro(outcome) {
-                       clearChildren(quizRoot);
+                       quizRoot.replaceChildren();
 
                        const firstRow = document.createElement("tr");
                        const firstCell = document.createElement("td");
                 * @param {number} index
                 */
                function renderQuestion(index) {
-                       clearChildren(quizRoot);
+                       quizRoot.replaceChildren();
 
                        const question = quiz.questions[index];
 
         * @param {HTMLElement} dom
         */
        function onDomLoad(dom) {
+               (function () {
+                       // Handle <a>.click and <form>.submit events w/ Fetch+History
+
+                       const anchors = dom.querySelectorAll("a");
+                       for (const anchor of anchors) {
+                               if (anchor.hasAttribute("data-csrf-token") || anchor.classList.contains("comment-edit-link") || anchor.classList.contains("copy-text")) {
+                                       continue;
+                               }
+
+                               anchor.addEventListener("click", aClickHandler);
+                       }
+
+                       const forms = dom.querySelectorAll("form");
+                       for (const form of forms) {
+                               form.addEventListener("submit", formSubmitHandler);
+                       }
+               })();
+
                (function () {
                        // Mechyrdian font
 
                })();
 
                (function () {
-                       // Login button
+                       // Login view-checksum button
 
                        const viewChecksumButtons = dom.querySelectorAll("button.view-checksum");
                        for (const viewChecksumButton of viewChecksumButtons) {
                        const canvases = dom.querySelectorAll("canvas[data-model]");
                        if (canvases.length > 0) {
                                (async function () {
-                                       const loadObj = await loadThreeJs();
+                                       await loadThreeJs();
                                        const THREE = window.THREE;
 
                                        const promises = [];
                                        for (const canvas of canvases) {
                                                promises.push((async () => {
-                                                       const modelAsync = loadObj(canvas.getAttribute("data-model"));
+                                                       const modelName = canvas.getAttribute("data-model");
+                                                       const modelAsync = (async () => {
+                                                               const mtlLib = await (new THREE.MTLLoader()).setPath("/assets/meshes/").setResourcePath("/assets/meshes/").loadAsync(modelName + ".mtl");
+                                                               mtlLib.preload();
+                                                               return await (new THREE.OBJLoader()).setPath("/assets/meshes/").setResourcePath("/assets/meshes/").setMaterials(mtlLib).loadAsync(modelName + ".obj");
+                                                       })();
 
                                                        const camera = new THREE.PerspectiveCamera(69, 1, 0.01, 1000.0);
 
 
                                        await Promise.all(promises);
                                })().catch(reason => {
-                                       console.error("Error rendering models", reason);
+                                       console.error("Error rendering models!", reason);
                                });
                        }
                })();
                (function () {
                        // Allow POSTing with <a>s
 
-                       // TODO implement Fetch+History loading
-                       const anchors = dom.querySelectorAll("a[data-method]");
+                       const anchors = dom.querySelectorAll("a[data-csrf-token]");
                        for (const anchor of anchors) {
                                anchor.addEventListener("click", e => {
                                        e.preventDefault();
 
-                                       let form = document.createElement("form");
-                                       form.style.display = "none";
-                                       form.action = e.currentTarget.href;
-                                       form.method = e.currentTarget.getAttribute("data-method");
+                                       const formData = new URLSearchParams();
 
                                        const csrfToken = e.currentTarget.getAttribute("data-csrf-token");
                                        if (csrfToken != null) {
+                                               formData.append("csrfToken", csrfToken);
+                                       }
+
+                                       const url = new URL(e.currentTarget.href, window.location);
+                                       if (!goToPage(url, "push", formData)) {
+                                               let form = document.createElement("form");
+                                               form.style.display = "none";
+                                               form.action = url.href;
+                                               form.method = "post";
+
                                                let csrfInput = document.createElement("input");
                                                csrfInput.name = "csrfToken";
                                                csrfInput.type = "hidden";
                                                csrfInput.value = csrfToken;
                                                form.append(csrfInput);
-                                       }
 
-                                       document.body.appendChild(form).submit();
+                                               document.body.appendChild(form).submit();
+                                       }
                                });
                        }
                })();
                                                        }, 750);
                                                })
                                                .catch(reason => {
-                                                       console.error("Error copying text to clipboard", reason);
+                                                       console.error("Error copying text to clipboard!", reason);
 
                                                        thisElement.innerHTML = "Text copy failed";
                                                        window.setTimeout(() => {
                        const redirectLink = dom.querySelector("a.redirect-link");
                        if (redirectLink != null) {
                                const redirectTarget = new URL(redirectLink.href, window.location);
-                               const redirectTargetUrl = redirectTarget.pathname + redirectTarget.search + redirectTarget.hash;
 
                                if (window.localStorage.getItem("disableRedirect") === "true") {
-                                       clearChildren(redirectLink);
-                                       redirectLink.append(redirectTarget.pathname);
+                                       redirectLink.replaceChildren(redirectTarget.pathname);
                                } else {
                                        // The scope-block immediately below - labeled "Factbook redirecting (2)"
                                        // checks if the key "redirectedFrom" is present in localStorage and, if
                                        // into a microtask so it waits until after the rest of this function executes.
                                        window.queueMicrotask(() => {
                                                window.localStorage.setItem("redirectedFrom", window.location.pathname);
-                                               window.location = redirectTargetUrl;
+                                               if (!goToPage(redirectTarget, "replace")) {
+                                                       window.location.href = redirectTarget.href;
+                                               }
                                        });
                                }
                        }
                        const redirectSourceValue = window.localStorage.getItem("redirectedFrom");
                        if (redirectSourceValue != null) {
                                const redirectSource = new URL(redirectSourceValue, window.location.origin);
-                               const redirectSourceUrl = redirectSource.pathname + redirectSource.search + redirectSource.hash;
 
                                const redirectIdValue = window.location.hash;
                                const redirectIds = dom.querySelectorAll("h1[data-redirect-id], h2[data-redirect-id], h3[data-redirect-id], h4[data-redirect-id], h5[data-redirect-id], h6[data-redirect-id]");
                                        pElement.append("Redirected from ");
 
                                        const aElement = document.createElement("a");
-                                       aElement.href = redirectSourceUrl;
+                                       aElement.href = redirectSource.href;
                                        aElement.append(redirectSource.pathname);
                                        aElement.addEventListener("click", e => {
                                                e.preventDefault();
                                                window.localStorage.setItem("disableRedirect", "true");
 
-                                               // TODO implement Fetch+History loading
-                                               window.location = e.currentTarget.href;
+                                               const url = new URL(e.currentTarget.href);
+                                               if (!goToPage(url, "push")) {
+                                                       window.location.href = url.href;
+                                               }
                                        });
 
                                        pElement.append(aElement);
index f1796c6c573d0ff03acb84a50783a66a52533470..3f47a846f65002075d2e7f8c64336a70f7770096 100644 (file)
@@ -529,7 +529,7 @@ a:hover {
        text-decoration: underline;
 }
 
-a[href^="http://"]::after, a[href^="https://"]::after {
+a[rel~="external"]::after {
        content: ' ';
        background-image: var(--extln);
        background-size: contain;