From 3924903e060261ff879ade8ed1624f5f73294eac Mon Sep 17 00:00:00 2001 From: Lanius Trolling Date: Sat, 24 Aug 2024 13:35:31 -0400 Subject: [PATCH] Finally implement Fetch+History factbook loading --- build.gradle.kts | 7 +- .../kotlin/info/mechyrdia/Factbooks.kt | 19 +- .../kotlin/info/mechyrdia/auth/ViewsLogin.kt | 24 +- .../info/mechyrdia/data/ViewsComment.kt | 19 +- .../kotlin/info/mechyrdia/data/ViewsFiles.kt | 12 +- .../kotlin/info/mechyrdia/data/ViewsUser.kt | 9 +- .../info/mechyrdia/lore/ArticleListing.kt | 16 +- .../info/mechyrdia/lore/ArticleTitles.kt | 41 ++- .../info/mechyrdia/lore/AssetCaching.kt | 35 +- .../info/mechyrdia/lore/AssetCompression.kt | 4 +- .../info/mechyrdia/lore/AssetHashing.kt | 4 +- .../kotlin/info/mechyrdia/lore/HttpUtils.kt | 9 +- .../kotlin/info/mechyrdia/lore/ParserHtml.kt | 4 +- .../kotlin/info/mechyrdia/lore/ParserRobot.kt | 4 +- .../kotlin/info/mechyrdia/lore/ViewBar.kt | 1 + .../kotlin/info/mechyrdia/lore/ViewNav.kt | 17 +- .../kotlin/info/mechyrdia/lore/ViewsLore.kt | 4 +- .../kotlin/info/mechyrdia/lore/ViewsRobots.kt | 10 +- .../kotlin/info/mechyrdia/lore/ViewsRss.kt | 16 +- .../info/mechyrdia/route/ResourceCsrf.kt | 1 - .../info/mechyrdia/route/ResourceTypes.kt | 8 +- src/jvmMain/resources/static/init.js | 314 +++++++++++++++--- src/jvmMain/resources/static/style.css | 2 +- 23 files changed, 413 insertions(+), 167 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 61f7367..72c2a88 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -208,11 +208,6 @@ tasks.named("jvmProcessResources") { } } -tasks.named("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 { + mergeServiceFiles() + exclude { it.name == "module-info.class" } buildJsAsset("map") } diff --git a/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt b/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt index dbbb971..2d5eee5 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt @@ -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 { call, (url, permanent) -> - if (!call.isWebDav) - call.respondRedirect(url, permanent) + exception { 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 { call, _ -> if (call.isWebDav) diff --git a/src/jvmMain/kotlin/info/mechyrdia/auth/ViewsLogin.kt b/src/jvmMain/kotlin/info/mechyrdia/auth/ViewsLogin.kt index 815d3a4..4408087 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/auth/ViewsLogin.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/auth/ViewsLogin.kt @@ -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() +fun ApplicationCall.logoutRoute(): Nothing { sessions.clear() - sessId?.let { id -> SessionStorageDoc.Table.del(Id(id)) } - redirectHref(Root()) + redirectHref(Root(), HttpStatusCode.SeeOther) } diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/ViewsComment.kt b/src/jvmMain/kotlin/info/mechyrdia/data/ViewsComment.kt index c5a42a5..6a3d650 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/data/ViewsComment.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/data/ViewsComment.kt @@ -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, 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): Nothing { @@ -107,7 +108,7 @@ suspend fun ApplicationCall.viewCommentRoute(commentId: Id): 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, newContents: String): Nothing { @@ -123,7 +124,7 @@ suspend fun ApplicationCall.editCommentRoute(commentId: Id, 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, 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): Pair { @@ -181,7 +182,7 @@ suspend fun ApplicationCall.deleteCommentRoute(commentId: Id): 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." diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/ViewsFiles.kt b/src/jvmMain/kotlin/info/mechyrdia/data/ViewsFiles.kt index f6e1ee6..5b3663a 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/data/ViewsFiles.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/data/ViewsFiles.kt @@ -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) } diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/ViewsUser.kt b/src/jvmMain/kotlin/info/mechyrdia/data/ViewsUser.kt index 456b0af..64c89d7 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/data/ViewsUser.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/data/ViewsUser.kt @@ -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()?.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): HTML.() -> Unit { @@ -70,7 +71,7 @@ suspend fun ApplicationCall.adminBanUserRoute(userId: Id): 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): Nothing { @@ -79,5 +80,5 @@ suspend fun ApplicationCall.adminUnbanUserRoute(userId: Id): 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) } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ArticleListing.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ArticleListing.kt index f0670ef..1f19bb2 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ArticleListing.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ArticleListing.kt @@ -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 List.sortedLexically(selector: (T) -> String?) = map { it to collator private fun List.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()?.nationId == OwnerNationId fun List.renderInto(list: UL, base: List = 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))) { diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ArticleTitles.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ArticleTitles.kt index fdcaded..985e60b 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ArticleTitles.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ArticleTitles.kt @@ -5,28 +5,45 @@ import info.mechyrdia.data.StoragePath data class ArticleTitle(val title: String, val css: String = "") object ArticleTitleCache : FileDependentCache() { - 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 + 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 = ";")) } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/AssetCaching.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/AssetCaching.kt index 708b607..65bd140 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/AssetCaching.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/AssetCaching.kt @@ -10,28 +10,25 @@ import java.util.concurrent.ConcurrentHashMap val StoragePathAttributeKey = AttributeKey("Mechyrdia.StoragePath") -abstract class FileDependentCache { - private inner class Entry(updated: Instant?, data: T?) { +abstract class FileDependentCache { + 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 { 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 } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/AssetCompression.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/AssetCompression.kt index 1bb1702..7aec269 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/AssetCompression.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/AssetCompression.kt @@ -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() { +private class CompressedCache(val encoding: String, private val compressorFactory: (OutputStream, Boolean) -> FilterOutputStream) : FileDependentCache() { + override fun default(path: StoragePath) = null + override suspend fun processFile(path: StoragePath): ByteArray? { val fileContents = FileStorage.instance.readFile(path) ?: return null diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/AssetHashing.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/AssetHashing.kt index f467a8f..b7702b3 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/AssetHashing.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/AssetHashing.kt @@ -56,9 +56,11 @@ private class DigestingOutputStream(stomach: MessageDigest) : OutputStream() { } } -private class FileHashCache(val hashAlgo: String) : FileDependentCache() { +private class FileHashCache(val hashAlgo: String) : FileDependentCache() { private val hashinator: ThreadLocal = 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 diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/HttpUtils.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/HttpUtils.kt index f6f028c..8796f77 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/HttpUtils.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/HttpUtils.kt @@ -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 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 ApplicationCall.redirectHref(resource: T, permanent: Boolean = false, hash: String? = null): Nothing = redirect(href(resource, hash), permanent) +inline fun ApplicationCall.redirectHref(resource: T, status: HttpStatusCode, hash: String? = null): Nothing = redirect(href(resource, hash), status) diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserHtml.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserHtml.kt index ff4661c..0f48b04 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserHtml.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserHtml.kt @@ -381,10 +381,10 @@ fun processInternalLink(param: String?): Map = param fun processExternalLink(param: String?): Map = param ?.sanitizeExtLink() ?.toExternalUrl() - ?.let { mapOf("href" to it) } + ?.let { mapOf("href" to it, "rel" to "external") } .orEmpty() -fun processCommentLink(param: String?): Map = processExternalLink(param) + mapOf("rel" to "ugc nofollow") +fun processCommentLink(param: String?): Map = processExternalLink(param) + mapOf("rel" to "ugc external nofollow") fun processCommentImage(url: String, domain: String) = "https://$domain/${url.sanitizeExtImgLink()}" diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserRobot.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserRobot.kt index 8bc0af3..74e2193 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserRobot.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserRobot.kt @@ -197,7 +197,7 @@ object RobotFactbookLoader { } suspend fun loadAllFactbooks(): Map { - 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 { - 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 -> diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewBar.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewBar.kt index 06b0d15..0f415fe 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewBar.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewBar.kt @@ -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 } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewNav.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewNav.kt index 73c9b57..865de71 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewNav.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewNav.kt @@ -30,7 +30,7 @@ suspend fun loadExternalLinks(): List { 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? = null) = listOf( @@ -47,7 +47,7 @@ suspend fun ApplicationCall.standardNavBar(path: List? = 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 = 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 = 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) ) ) diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsLore.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsLore.kt index 0ac31c1..2d936ef 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsLore.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsLore.kt @@ -85,7 +85,7 @@ suspend fun ApplicationCall.loreRawArticlePage(pagePath: List): 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, 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) { diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRobots.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRobots.kt index 8d9f322..f97c092 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRobots.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRobots.kt @@ -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 { - return allPages().mapNotNull { page -> - if (!page.path.isViewable) null - else SitemapEntry( +private suspend fun buildLoreSitemap(call: ApplicationCall): List { + 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 { } } -suspend fun buildSitemap() = listOfNotNull(buildIntroSitemap()) + buildLoreSitemap() +suspend fun buildSitemap(call: ApplicationCall) = listOfNotNull(buildIntroSitemap()) + buildLoreSitemap(call) fun > C.sitemap(entries: List) = declaration() .root("urlset", namespace = "http://www.sitemaps.org/schemas/sitemap/0.9") { diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRss.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRss.kt index 503c61c..eef186c 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRss.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRss.kt @@ -50,8 +50,8 @@ private suspend fun statAll(paths: Iterable): StoredFileStats? { ) } -private suspend fun ArticleNode.getPages(base: StoragePath): List { - if (!this.isViewable) +private suspend fun ArticleNode.getPages(base: StoragePath, call: ApplicationCall?): List { + 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 - subNode.getPages(path) + subNode.getPages(path, call) }?.flatten().orEmpty() } -suspend fun allPages(): List { +suspend fun allPages(call: ApplicationCall?): List { 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 = emptyList(), val items: List = emptyList(), -): XmlInsertable { +) : XmlInsertable { override fun XmlTag.intoXml() { "channel" { "title" { +title } diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceCsrf.kt b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceCsrf.kt index 9e5a554..27e15be 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceCsrf.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceCsrf.kt @@ -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) } diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt index abb1944..341739c 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt @@ -141,7 +141,7 @@ class Root : ResourceHandler, ResourceFilter { override suspend fun PipelineContext.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.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) } } } diff --git a/src/jvmMain/resources/static/init.js b/src/jvmMain/resources/static/init.js index 10a826e..766cc5a 100644 --- a/src/jvmMain/resources/static/init.js +++ b/src/jvmMain/resources/static/init.js @@ -11,6 +11,212 @@ }); })(); + /** + * @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.} */ @@ -26,15 +232,6 @@ }, {}); } - /** - * @param {ParentNode} element - */ - function clearChildren(element) { - while (element.hasChildNodes()) { - element.firstChild.remove(); - } - } - /** * @param {number} amount * @return {Promise} @@ -82,24 +279,13 @@ } /** - * @returns {Promise>} + * @returns {Promise} */ async function loadThreeJs() { - await loadScript("/static/obj-viewer/three.js"); - await loadScript("/static/obj-viewer/three-examples.js"); - - /** - * @param {string} modelName - * @returns {Promise} - */ - 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; } /** @@ -204,7 +390,7 @@ const searchTerm = vocabSearch.value.trim(); - clearChildren(vocabSearchResults); + vocabSearchResults.replaceChildren(); const searchResults = []; if (vocabEnglishToLang.checked) { @@ -257,7 +443,7 @@ const questionAnswers = []; function renderIntro() { - clearChildren(quizRoot); + quizRoot.replaceChildren(); const firstRow = document.createElement("tr"); const firstCell = document.createElement("td"); @@ -296,7 +482,7 @@ * @param {QuizOutcome} outcome */ function renderOutro(outcome) { - clearChildren(quizRoot); + quizRoot.replaceChildren(); const firstRow = document.createElement("tr"); const firstCell = document.createElement("td"); @@ -358,7 +544,7 @@ * @param {number} index */ function renderQuestion(index) { - clearChildren(quizRoot); + quizRoot.replaceChildren(); const question = quiz.questions[index]; @@ -420,6 +606,24 @@ * @param {HTMLElement} dom */ function onDomLoad(dom) { + (function () { + // Handle .click and
.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 @@ -625,7 +829,7 @@ })(); (function () { - // Login button + // Login view-checksum button const viewChecksumButtons = dom.querySelectorAll("button.view-checksum"); for (const viewChecksumButton of viewChecksumButtons) { @@ -683,13 +887,18 @@ 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); @@ -741,7 +950,7 @@ await Promise.all(promises); })().catch(reason => { - console.error("Error rendering models", reason); + console.error("Error rendering models!", reason); }); } })(); @@ -749,27 +958,33 @@ (function () { // Allow POSTing with 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(); + } }); } })(); @@ -893,7 +1108,7 @@ }, 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(() => { @@ -947,11 +1162,9 @@ 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 @@ -960,7 +1173,9 @@ // 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; + } }); } } @@ -974,7 +1189,6 @@ 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]"); @@ -987,14 +1201,16 @@ 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); diff --git a/src/jvmMain/resources/static/style.css b/src/jvmMain/resources/static/style.css index f1796c6..3f47a84 100644 --- a/src/jvmMain/resources/static/style.css +++ b/src/jvmMain/resources/static/style.css @@ -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; -- 2.25.1