}
}
-tasks.named<ShadowJar>("shadowJar") {
- mergeServiceFiles()
- exclude { it.name == "module-info.class" }
-}
-
application {
mainClass.set("info.mechyrdia.Factbooks")
}
}
tasks.withType<ShadowJar> {
+ mergeServiceFiles()
+ exclude { it.name == "module-info.class" }
buildJsAsset("map")
}
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
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)
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
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)
}
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
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
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 {
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 {
// 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(
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> {
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()) {
td {
+"Creates an "
a(href = "https://google.com/") {
- rel = "nofollow"
+ rel = "nofollow external"
+"HTML link"
}
}
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."
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)
}
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)
}
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)
}
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)
}
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)
}
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
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 {
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 {
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)
}
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
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))) {
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 = ";"))
}
}
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
}
}
}
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
}
}
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
}
}
-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
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)
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()}"
}
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
}
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 ->
p {
style = "text-align:center"
a(href = "https://www.nationstates.net/nation=${nationData.id}") {
+ rel = "nofollow external"
+nationData.name
}
}
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(
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(
}
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,
textIsHtml = textIsHtml,
aClasses = aClasses,
linkAttributes = extraAttributes + mapOf(
- "data-method" to "post",
"data-csrf-token" to call.createCsrfToken(to)
)
)
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) {
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) {
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
)
}
-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,
}
}
-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") {
)
}
-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)
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
val image: RssChannelImage? = null,
val categories: List<RssCategory> = emptyList(),
val items: List<RssItem> = emptyList(),
-): XmlInsertable {
+) : XmlInsertable {
override fun XmlTag.intoXml() {
"channel" {
"title" { +title }
}
fun A.installCsrfToken(route: String = href, call: ApplicationCall) {
- attributes["data-method"] = "post"
attributes["data-csrf-token"] = call.createCsrfToken(route)
}
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)
}
override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
with(root) { filterCall() }
- call.respondRss(generateRecentPageEdits())
+ call.respondRss(generateRecentPageEdits(call))
}
}
RobotService.getInstance()?.performMaintenance()
- call.redirectHref(NukeManagement())
+ call.redirectHref(NukeManagement(), HttpStatusCode.SeeOther)
}
}
RobotService.getInstance()?.reset()
- call.redirectHref(NukeManagement())
+ call.redirectHref(NukeManagement(), HttpStatusCode.SeeOther)
}
}
}
});
})();
+ /**
+ * @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);
text-decoration: underline;
}
-a[href^="http://"]::after, a[href^="https://"]::after {
+a[rel~="external"]::after {
content: ' ';
background-image: var(--extln);
background-size: contain;