From: Lanius Trolling Date: Sun, 22 Dec 2024 19:30:06 +0000 (-0500) Subject: Various NS auth and WebDAV token improvements X-Git-Url: https://gitweb.starshipfights.net/?a=commitdiff_plain;h=0e6a70d75321362e629db8fd733617a670f887e8;p=factbooks Various NS auth and WebDAV token improvements --- diff --git a/libs/nsapi4j.jar b/libs/nsapi4j.jar index a80a0c1..8f0cd7d 100644 Binary files a/libs/nsapi4j.jar and b/libs/nsapi4j.jar differ diff --git a/src/main/kotlin/info/mechyrdia/Configuration.kt b/src/main/kotlin/info/mechyrdia/Configuration.kt index 673d442..413fd54 100644 --- a/src/main/kotlin/info/mechyrdia/Configuration.kt +++ b/src/main/kotlin/info/mechyrdia/Configuration.kt @@ -3,6 +3,7 @@ package info.mechyrdia import info.mechyrdia.auth.Argon2Hasher import info.mechyrdia.data.Id import info.mechyrdia.data.NationData +import info.mechyrdia.data.NationUrlSlug import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer @@ -14,6 +15,7 @@ import kotlinx.serialization.descriptors.buildSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonPrimitive import java.io.File import java.nio.charset.Charset @@ -77,7 +79,9 @@ data class Configuration( val dbName: String = "nslore", val dbConn: String = "mongodb://localhost:27017", - val ownerNation: String = "mechyrdia", + val ownerNation: String = "1593419", + + val emergencyUsername: NationUrlSlug = NationUrlSlug("mechyrdia"), @Serializable(with = StoredPasswordConfigJsonSerializer::class) val emergencyPassword: StoredPassword? = null, @@ -122,7 +126,10 @@ object StoredPasswordConfigJsonSerializer : KSerializer { get() = buildSerialDescriptor("StoredPasswordConfigJsonSerializer", PolymorphicKind.SEALED) override fun serialize(encoder: Encoder, value: StoredPassword) { - defaultSerializer.serialize(encoder, value) + if (encoder is JsonEncoder) + encoder.encodeJsonElement(encoder.json.encodeToJsonElement(defaultSerializer, value)) + else + defaultSerializer.serialize(encoder, value) } override fun deserialize(decoder: Decoder): StoredPassword { diff --git a/src/main/kotlin/info/mechyrdia/Factbooks.kt b/src/main/kotlin/info/mechyrdia/Factbooks.kt index 5df15c3..20e0bbc 100644 --- a/src/main/kotlin/info/mechyrdia/Factbooks.kt +++ b/src/main/kotlin/info/mechyrdia/Factbooks.kt @@ -122,9 +122,7 @@ fun Application.factbooks() { generate { "call_${counter.incrementAndGet().toULong()}_${System.currentTimeMillis()}" } - reply { call, callId -> - call.response.header("X-Call-Id", callId) - } + replyToHeader("X-Call-Id") } install(CallLogging) { @@ -272,7 +270,7 @@ fun Application.factbooks() { get() post() get() - get() + get() post() post() get() @@ -283,6 +281,7 @@ fun Application.factbooks() { get() get() post() + post() get() post() postMultipart() diff --git a/src/main/kotlin/info/mechyrdia/auth/NationStates.kt b/src/main/kotlin/info/mechyrdia/auth/NationStates.kt index b7fab6d..9b6b29a 100644 --- a/src/main/kotlin/info/mechyrdia/auth/NationStates.kt +++ b/src/main/kotlin/info/mechyrdia/auth/NationStates.kt @@ -1,10 +1,11 @@ package info.mechyrdia.auth -import com.aventrix.jnanoid.jnanoid.NanoIdUtils import com.github.agadar.nationstates.DefaultNationStatesImpl import com.github.agadar.nationstates.NationStates import com.github.agadar.nationstates.exception.NationStatesResourceNotFoundException import com.github.agadar.nationstates.query.APIQuery +import info.mechyrdia.data.NationUrlSlug +import info.mechyrdia.data.nanoId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible @@ -18,9 +19,10 @@ suspend fun , R> Q.executeSuspend(): R? = runInterruptible(Di } } -fun String.toNationId() = replace(' ', '_').lowercase() +fun String.toNationSlug() = NationUrlSlug(replace(' ', '_').lowercase()) -private val tokenAlphabet = "ABCDEFGHILMNOPQRSTVXYZ0123456789".toCharArray() -fun token(): String = NanoIdUtils.randomNanoId(NanoIdUtils.DEFAULT_NUMBER_GENERATOR, tokenAlphabet, 16) +fun token(): String = nanoId(16) + +fun gigaToken(): String = nanoId(32) class ForbiddenException(override val message: String) : RuntimeException(message) diff --git a/src/main/kotlin/info/mechyrdia/auth/ViewsLogin.kt b/src/main/kotlin/info/mechyrdia/auth/ViewsLogin.kt index 4408087..847d8ac 100644 --- a/src/main/kotlin/info/mechyrdia/auth/ViewsLogin.kt +++ b/src/main/kotlin/info/mechyrdia/auth/ViewsLogin.kt @@ -1,13 +1,12 @@ package info.mechyrdia.auth -import com.github.agadar.nationstates.shard.NationShard -import info.mechyrdia.Configuration import info.mechyrdia.data.DataDocument import info.mechyrdia.data.DocumentTable import info.mechyrdia.data.Id import info.mechyrdia.data.InstantSerializer import info.mechyrdia.data.MONGODB_ID_KEY import info.mechyrdia.data.NationData +import info.mechyrdia.data.NationVerifyResult import info.mechyrdia.data.TableHolder import info.mechyrdia.lore.page import info.mechyrdia.lore.redirectHref @@ -23,7 +22,19 @@ import io.ktor.server.sessions.clear import io.ktor.server.sessions.sessions import io.ktor.server.sessions.set import io.ktor.util.AttributeKey -import kotlinx.html.* +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.serialization.SerialName import kotlinx.serialization.Serializable import java.time.Instant @@ -73,16 +84,14 @@ suspend fun ApplicationCall.loginPage(): HTML.() -> Unit { form(method = FormMethod.post, action = href(Root.Auth.LoginPost())) { installCsrfToken(call = this@loginPage) - hiddenInput { - name = "tokenId" + hiddenInput(name = "tokenId") { value = tokenId } label { +"Nation Name" br - textInput { - name = "nation" + textInput(name = "nation") { placeholder = "Name of your nation without pretitle, e.g. Mechyrdia, Valentine Z, Reinkalistan, etc." } } @@ -96,8 +105,7 @@ suspend fun ApplicationCall.loginPage(): HTML.() -> Unit { label { +"Verification Checksum" br - textInput { - name = "checksum" + textInput(name = "checksum") { placeholder = "The random text checksum generated by NationStates for verification" } } @@ -108,24 +116,14 @@ suspend fun ApplicationCall.loginPage(): HTML.() -> Unit { } suspend fun ApplicationCall.loginRoute(nation: String, checksum: String, tokenId: String): Nothing { - val nationId = nation.toNationId() + val nationSlug = nation.toNationSlug() val nsToken = NsStoredToken.verifyToken(tokenId) ?: throw MissingRequestParameterException("tokenId") - val nationData = if (nationId == Configuration.Current.ownerNation && checksum == Configuration.Current.emergencyPassword) - NationData.get(Id(nationId)) - else { - val result = NSAPI - .verifyAndGetNation(nationId, checksum) - .token("mechyrdia_$nsToken") - .shards(NationShard.NAME, NationShard.FLAG_URL) - .executeSuspend() - ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "That nation does not exist.") - - if (!result.isVerified) - redirectHrefWithError(Root.Auth.LoginPage(), error = "Checksum failed verification.") - - NationData(Id(result.id), result.name, result.flagUrl).also { NationData.Table.put(it) } + val nationData = when (val result = NationData.verify(nationSlug, nsToken, checksum)) { + NationVerifyResult.NationDoesNotExist -> redirectHrefWithError(Root.Auth.LoginPage(), error = "That nation does not exist.") + NationVerifyResult.ChecksumFailedVerification -> redirectHrefWithError(Root.Auth.LoginPage(), error = "Checksum failed verification.") + is NationVerifyResult.LoginSuccess -> result.nation } sessions.set(UserSession(nationData.id)) diff --git a/src/main/kotlin/info/mechyrdia/auth/WebDav.kt b/src/main/kotlin/info/mechyrdia/auth/WebDav.kt index 92c8d97..e9a51b0 100644 --- a/src/main/kotlin/info/mechyrdia/auth/WebDav.kt +++ b/src/main/kotlin/info/mechyrdia/auth/WebDav.kt @@ -13,10 +13,12 @@ import info.mechyrdia.data.currentNation import info.mechyrdia.data.serialName import info.mechyrdia.lore.adminPage import info.mechyrdia.lore.dateTime +import info.mechyrdia.lore.redirectHref import info.mechyrdia.lore.redirectHrefWithError 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.toList import kotlinx.html.* @@ -28,6 +30,7 @@ import java.time.Instant data class WebDavToken( @SerialName(MONGODB_ID_KEY) override val id: Id = Id(), + val pwHash: String, val holder: Id, val validUntil: @Serializable(with = InstantSerializer::class) Instant @@ -66,21 +69,28 @@ suspend fun ApplicationCall.adminRequestWebDavToken(): HTML.() -> Unit { table { tr { - th { +"Token" } - th { +"Expires at" } + th(ThScope.col) { +"Token Name" } + th(ThScope.col) { +"Expires at" } + th(ThScope.col) { +Entities.nbsp } } for (existingToken in existingTokens) { tr { td { - textInput { - readonly = true - value = existingToken.id.id + code { + +existingToken.id.id } } td { dateTime(existingToken.validUntil) } + td { + form(method = FormMethod.post, action = href(Root.Admin.Vfs.WebDavTokenDelete())) { + installCsrfToken(call = this@adminRequestWebDavToken) + hiddenInput(name = "tokenId") { value = existingToken.id.id } + submitInput(classes = "evil") { value = "Delete Token" } + } + } } } } @@ -94,9 +104,12 @@ suspend fun ApplicationCall.adminObtainWebDavToken(): HTML.() -> Unit { val nation = currentNation() ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "You must be logged in to generate WebDAV tokens") + val tokenPw = gigaToken() + val token = WebDavToken( holder = nation.id, - validUntil = Instant.now().plusSeconds(86_400) + pwHash = Argon2Hasher.createHash(tokenPw), + validUntil = Instant.now().plusSeconds(31_556_925) ) WebDavToken.Table.put(token) @@ -106,15 +119,54 @@ suspend fun ApplicationCall.adminObtainWebDavToken(): HTML.() -> Unit { h1 { +"Your New WebDAV Token" } div { style = "text-align:center" - textInput { - readonly = true - value = token.id.id - } - p { - +"Your new token will expire at " - dateTime(token.validUntil) + table { + tr { + th(ThScope.row) { + style = "text-align:right" + +"Username" + } + td { + a(href = "#", classes = "text-copy") { + +token.id.id + } + } + } + tr { + th(ThScope.row) { + style = "text-align:right" + +"Password" + } + td { + a(href = "#", classes = "text-copy") { + +tokenPw + } + } + } + tr { + th(ThScope.row) { + style = "text-align:right" + +"Expiration" + } + td { + dateTime(token.validUntil) + } + } } } } } } + +suspend fun ApplicationCall.adminDeleteWebDavToken(tokenId: Id): Nothing { + val nation = currentNation() ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "You must be logged in to delete WebDAV tokens") + + val token = WebDavToken.Table.get(tokenId) ?: redirectHrefWithError(Root.Admin.Vfs.WebDavTokenPage(), error = "That token does not exist") + + if (token.holder != nation.id) { + redirectHrefWithError(Root.Admin.Vfs.WebDavTokenPage(), error = "You do not own that token") + } + + WebDavToken.Table.del(token.id) + + redirectHref(Root.Admin.Vfs.WebDavTokenPage(), HttpStatusCode.SeeOther) +} diff --git a/src/main/kotlin/info/mechyrdia/data/Data.kt b/src/main/kotlin/info/mechyrdia/data/Data.kt index 4c7f79f..8a55276 100644 --- a/src/main/kotlin/info/mechyrdia/data/Data.kt +++ b/src/main/kotlin/info/mechyrdia/data/Data.kt @@ -47,7 +47,7 @@ import com.mongodb.reactivestreams.client.MongoDatabase as JMongoDatabase @Serializable(IdSerializer::class) @JvmInline -value class Id(val id: String) { +value class Id<@Suppress("unused") T>(val id: String) { override fun toString() = id companion object { @@ -57,7 +57,9 @@ value class Id(val id: String) { private val secureRandom = SecureRandom.getInstanceStrong() private val alphabet = "ABCDEFGHILMNOPQRSTVXYZ0123456789".toCharArray() -fun Id() = Id(NanoIdUtils.randomNanoId(secureRandom, alphabet, 24)) +fun nanoId(length: Int): String = NanoIdUtils.randomNanoId(secureRandom, alphabet, length) + +fun Id() = Id(nanoId(24)) object IdSerializer : KSerializer> { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Id", PrimitiveKind.STRING) diff --git a/src/main/kotlin/info/mechyrdia/data/DataFiles.kt b/src/main/kotlin/info/mechyrdia/data/DataFiles.kt index 7e081d7..6813dfa 100644 --- a/src/main/kotlin/info/mechyrdia/data/DataFiles.kt +++ b/src/main/kotlin/info/mechyrdia/data/DataFiles.kt @@ -294,8 +294,8 @@ private data class GridFsEntry( ) : DataDocument private class GridFsStorage(val table: DocumentTable, val bucket: GridFSBucket) : FileStorage { - private fun toExactPath(path: StoragePath) = path.elements.concat("/", prefix = "/") - private fun toPrefixPath(path: StoragePath) = path.elements.concat("/", prefix = "/", suffix = "/") + private fun toExactPath(path: StoragePath) = path.elements.map { "/$it" }.concat() + private fun toPrefixPath(path: StoragePath) = path.elements.map { "/$it" }.concat(suffix = "/") private suspend fun testExact(path: StoragePath) = table.number(Filters.eq(GridFsEntry::path.serialName, toExactPath(path))) > 0L private suspend fun getExact(path: StoragePath) = table.locate(Filters.eq(GridFsEntry::path.serialName, toExactPath(path))) diff --git a/src/main/kotlin/info/mechyrdia/data/Nations.kt b/src/main/kotlin/info/mechyrdia/data/Nations.kt index 800bd27..5f177f8 100644 --- a/src/main/kotlin/info/mechyrdia/data/Nations.kt +++ b/src/main/kotlin/info/mechyrdia/data/Nations.kt @@ -1,6 +1,11 @@ package info.mechyrdia.data +import com.github.agadar.nationstates.domain.nation.Nation +import com.github.agadar.nationstates.domain.nation.NationVerification +import com.github.agadar.nationstates.query.ShardQuery import com.github.agadar.nationstates.shard.NationShard +import com.mongodb.client.model.Filters +import info.mechyrdia.Configuration import info.mechyrdia.OwnerNationId import info.mechyrdia.auth.NSAPI import info.mechyrdia.auth.UserSession @@ -17,10 +22,17 @@ import java.util.concurrent.ConcurrentHashMap private val NationsLogger: Logger = LoggerFactory.getLogger("info.mechyrdia.data.NationsKt") +@Serializable +@JvmInline +value class NationUrlSlug(val slug: String) { + override fun toString() = slug +} + @Serializable data class NationData( @SerialName(MONGODB_ID_KEY) override val id: Id, + val slug: NationUrlSlug, val name: String, val flag: String, @@ -30,44 +42,118 @@ data class NationData( override val Table = DocumentTable() override suspend fun initialize() { + Table.index(NationData::slug.ascending) Table.index(NationData::name.ascending) } - fun unknown(id: Id): NationData { - NationsLogger.warn("Unable to find nation with Id $id - did it CTE?") - return NationData(id, "Unknown Nation", "https://www.nationstates.net/images/flags/exnation.png") + private fun unknownOfDbId(dbId: Id): NationData { + NationsLogger.warn("Unable to find nation with DB ID $dbId") + return NationData( + id = dbId, + slug = NationUrlSlug("unknown_dbid_$dbId"), + name = "Unknown Nation", + flag = "https://www.nationstates.net/images/flags/exnation.png" + ) + } + + private fun unknownOfSlug(slug: NationUrlSlug): NationData { + NationsLogger.warn("Unable to find nation with URL slug $slug") + return NationData( + id = Id("unknown_slug_$slug"), + slug = slug, + name = "Unknown Nation", + flag = "https://www.nationstates.net/images/flags/exnation.png" + ) + } + + suspend fun getByDbId(nationDbId: Id): NationData { + return Table.get(nationDbId) ?: unknownOfDbId(nationDbId) } - suspend fun get(id: Id): NationData = Table.getOrPut(id) { - NSAPI - .getNation(id.id) - .shards(NationShard.NAME, NationShard.FLAG_URL) - .executeSuspend() - ?.let { nation -> - NationData(id = Id(nation.id), name = nation.name, flag = nation.flagUrl) - } ?: unknown(id) + suspend fun getBySlug(nationSlug: NationUrlSlug, queryNsApi: Boolean = true): NationData { + return Table.locate(Filters.eq(NationData::slug.serialName, nationSlug)) + ?: (if (queryNsApi) + NSAPI.getNation(nationSlug.slug) + .defaultShards() + .executeSuspend() + ?.toNationData() + else null) ?: unknownOfSlug(nationSlug) + } + + suspend fun verify(nationSlug: NationUrlSlug, nsToken: String, checksum: String): NationVerifyResult { + val result = if (nationSlug == Configuration.Current.emergencyUsername && Configuration.Current.emergencyPassword?.verify(checksum) == true) { + NSAPI.getNation(nationSlug.slug) + .defaultShards() + .executeSuspend() + } else { + NSAPI.verifyAndGetNation(nationSlug.slug, checksum) + .token("mechyrdia_$nsToken") + .defaultShards() + .executeSuspend() + } + + result ?: return NationVerifyResult.NationDoesNotExist + + if (result is NationVerification && !result.isVerified) + return NationVerifyResult.ChecksumFailedVerification + + val nationData = result.toNationData(Table.get(Id(result.dbId))).also { Table.put(it) } + return NationVerifyResult.LoginSuccess(nationData) } } } -val CallNationCacheAttribute = AttributeKey, NationData>>("Mechyrdia.NationCache") +private fun > Q.defaultShards(): Q = shards(NationShard.DB_ID, NationShard.NAME, NationShard.FLAG_URL) -val ApplicationCall.nationCache: MutableMap, NationData> - get() = attributes.computeIfAbsent(CallNationCacheAttribute) { - ConcurrentHashMap, NationData>() - } +private fun Nation.toNationData(prev: NationData? = null) = NationData( + id = Id(dbId), + slug = NationUrlSlug(id), + name = name, + flag = flagUrl, + isBanned = prev?.isBanned == true +) -suspend fun MutableMap, NationData>.getNation(id: Id): NationData { - return getOrPut(id) { - NationData.get(id) +sealed interface NationVerifyResult { + data object NationDoesNotExist : NationVerifyResult + data object ChecksumFailedVerification : NationVerifyResult + + @JvmInline + value class LoginSuccess(val nation: NationData) : NationVerifyResult +} + +val CallNationCacheAttribute = AttributeKey("Mechyrdia.NationCache") + +class NationCache { + private val bySlug = ConcurrentHashMap() + private val byDbId = ConcurrentHashMap, NationData>() + + suspend fun getBySlug(slug: NationUrlSlug): NationData { + return bySlug.getOrPut(slug) { + NationData.getBySlug(slug, false).also { byDbId.putIfAbsent(it.id, it) } + } + } + + suspend fun getByDbId(dbId: Id): NationData { + return byDbId.getOrPut(dbId) { + NationData.getByDbId(dbId).also { bySlug.putIfAbsent(it.slug, it) } + } } } +val ApplicationCall.nationCache: NationCache + get() = attributes.computeIfAbsent(CallNationCacheAttribute) { + NationCache() + } + private val CallCurrentNationAttribute = AttributeKey("Mechyrdia.CurrentNation") -fun ApplicationCall.ownerNationOnly() { - if (sessions.get()?.nationId != OwnerNationId) +suspend fun ApplicationCall.adminNationOnly(): NationData { + val nationData = currentNation() + + if (nationData?.id != OwnerNationId) throw NoSuchElementException("Hidden page") + + return nationData } suspend fun ApplicationCall.currentNation(): NationData? { @@ -77,7 +163,7 @@ suspend fun ApplicationCall.currentNation(): NationData? { return sessions.get() ?.nationId - ?.let { nationCache.getNation(it) } + ?.let { nationCache.getByDbId(it) } ?.also { attributes.put(CallCurrentNationAttribute, NationSession(it)) } } diff --git a/src/main/kotlin/info/mechyrdia/data/ViewComments.kt b/src/main/kotlin/info/mechyrdia/data/ViewComments.kt index ad24564..e6a9610 100644 --- a/src/main/kotlin/info/mechyrdia/data/ViewComments.kt +++ b/src/main/kotlin/info/mechyrdia/data/ViewComments.kt @@ -36,9 +36,9 @@ data class CommentRenderData( val replyLinks: List>, ) { companion object { - private suspend fun render(comment: Comment, nations: MutableMap, NationData> = mutableMapOf()): CommentRenderData { + private suspend fun render(comment: Comment, nations: NationCache = NationCache()): CommentRenderData { val (nationData, pageTitle, htmlResult) = coroutineScope { - val nationDataAsync = async { nations.getNation(comment.submittedBy) } + val nationDataAsync = async { nations.getByDbId(comment.submittedBy) } val pageTitleAsync = async { (StoragePath.articleDir / comment.submittedIn).toFriendlyPathTitle() } val htmlResultAsync = async { comment.contents.parseAs(ParserTree::toCommentHtml) } @@ -59,7 +59,7 @@ data class CommentRenderData( ) } - suspend operator fun invoke(comments: List, nations: MutableMap, NationData> = mutableMapOf()): List { + suspend operator fun invoke(comments: List, nations: NationCache = NationCache()): List { return comments.mapSuspend { comment -> render(comment, nations) } @@ -86,7 +86,7 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id Unit { Comment.Table .sorted(Sorts.descending(Comment::submittedAt.serialName)) .filterNot { comment -> - comment.submittedBy != currNation?.id && NationData.get(comment.submittedBy).isBanned + comment.submittedBy != currNation?.id && NationData.getByDbId(comment.submittedBy).isBanned } .take(limit) .toList(), @@ -103,7 +103,7 @@ suspend fun ApplicationCall.viewCommentRoute(commentId: Id): Nothing { val comment = Comment.Table.get(commentId)!! val currentNation = currentNation() - val submitter = nationCache.getNation(comment.submittedBy) + val submitter = nationCache.getByDbId(comment.submittedBy) if (submitter.isBanned && currentNation?.id != comment.submittedBy && currentNation?.id != OwnerNationId) throw NoSuchElementException("Shadowbanned comment") diff --git a/src/main/kotlin/info/mechyrdia/data/ViewsFiles.kt b/src/main/kotlin/info/mechyrdia/data/ViewsFiles.kt index 9e2630d..2de8a3b 100644 --- a/src/main/kotlin/info/mechyrdia/data/ViewsFiles.kt +++ b/src/main/kotlin/info/mechyrdia/data/ViewsFiles.kt @@ -119,7 +119,7 @@ suspend fun ApplicationCall.adminViewVfs(path: StoragePath): HTML.() -> Unit { when (tree) { is TreeNode.FileNode -> table { tr { - th { + th(ThScope.col) { colSpan = "2" +"/$path" } @@ -133,19 +133,19 @@ suspend fun ApplicationCall.adminViewVfs(path: StoragePath): HTML.() -> Unit { } } tr { - th { +"Created at" } + th(ThScope.row) { +"Created at" } td { dateTime(tree.stats.created) } } tr { - th { +"Last updated at" } + th(ThScope.row) { +"Last updated at" } td { dateTime(tree.stats.updated) } } tr { - th { +"Size (bytes)" } + th(ThScope.row) { +"Size (bytes)" } td { +"${tree.stats.size}" } } tr { - th { +"Actions" } + th(ThScope.row) { +"Actions" } td { ul { li { @@ -177,7 +177,7 @@ suspend fun ApplicationCall.adminViewVfs(path: StoragePath): HTML.() -> Unit { } } tr { - th { +"Navigate" } + th(ThScope.row) { +"Navigate" } td { ul { path.elements.indices.forEach { index -> @@ -311,11 +311,11 @@ suspend fun ApplicationCall.adminConfirmDeleteFile(path: StoragePath) { } table { tr { - th { +"Last Updated" } + th(ThScope.row) { +"Last Updated" } td { dateTime(stats.updated) } } tr { - th { +"Size (bytes)" } + th(ThScope.row) { +"Size (bytes)" } td { +"${stats.size}" } } } diff --git a/src/main/kotlin/info/mechyrdia/data/ViewsUser.kt b/src/main/kotlin/info/mechyrdia/data/ViewsUser.kt index 5f814e1..5d48b69 100644 --- a/src/main/kotlin/info/mechyrdia/data/ViewsUser.kt +++ b/src/main/kotlin/info/mechyrdia/data/ViewsUser.kt @@ -2,7 +2,6 @@ package info.mechyrdia.data import com.mongodb.client.model.Updates import info.mechyrdia.OwnerNationId -import info.mechyrdia.auth.UserSession import info.mechyrdia.lore.NationProfileSidebar import info.mechyrdia.lore.page import info.mechyrdia.lore.redirectHref @@ -12,22 +11,25 @@ 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 import kotlinx.coroutines.flow.toList -import kotlinx.html.* +import kotlinx.html.HTML +import kotlinx.html.a +import kotlinx.html.h1 +import kotlinx.html.id +import kotlinx.html.p +import kotlinx.html.section -fun ApplicationCall.currentUserPage(): Nothing { - val currNationId = sessions.get()?.nationId - if (currNationId == null) +suspend fun ApplicationCall.currentUserPage(): Nothing { + val currNation = currentNation() + if (currNation == null) redirectHref(Root.Auth.LoginPage(), HttpStatusCode.Found) else - redirectHref(Root.User.ById(currNationId), HttpStatusCode.Found) + redirectHref(Root.User.BySlug(currNation.slug), HttpStatusCode.Found) } -suspend fun ApplicationCall.userPage(userId: Id): HTML.() -> Unit { +suspend fun ApplicationCall.userPage(userSlug: NationUrlSlug): HTML.() -> Unit { val currNation = currentNation() - val viewingNation = nationCache.getNation(userId) + val viewingNation = nationCache.getBySlug(userSlug) val comments = CommentRenderData( Comment.getCommentsBy(viewingNation.id).toList(), @@ -41,13 +43,13 @@ suspend fun ApplicationCall.userPage(userId: Id): HTML.() -> Unit { if (currNation?.id == OwnerNationId) { if (viewingNation.isBanned) { p { +"This user is banned" } - val unbanLink = href(Root.Admin.Unban(viewingNation.id)) + val unbanLink = href(Root.Admin.Unban(viewingNation.slug)) a(href = unbanLink) { installCsrfToken(unbanLink, call = this@userPage) +"Unban" } } else { - val banLink = href(Root.Admin.Ban(viewingNation.id)) + val banLink = href(Root.Admin.Ban(viewingNation.slug)) a(href = banLink) { installCsrfToken(banLink, call = this@userPage) +"Ban" @@ -60,20 +62,20 @@ suspend fun ApplicationCall.userPage(userId: Id): HTML.() -> Unit { } } -suspend fun ApplicationCall.adminBanUserRoute(userId: Id): Nothing { - val bannedNation = nationCache.getNation(userId) +suspend fun ApplicationCall.adminBanUserRoute(userSlug: NationUrlSlug): Nothing { + val bannedNation = nationCache.getBySlug(userSlug) if (!bannedNation.isBanned) NationData.Table.set(bannedNation.id, Updates.set(NationData::isBanned.serialName, true)) - redirectHref(Root.User.ById(userId), HttpStatusCode.SeeOther) + redirectHref(Root.User.BySlug(userSlug), HttpStatusCode.SeeOther) } -suspend fun ApplicationCall.adminUnbanUserRoute(userId: Id): Nothing { - val bannedNation = nationCache.getNation(userId) +suspend fun ApplicationCall.adminUnbanUserRoute(userSlug: NationUrlSlug): Nothing { + val bannedNation = nationCache.getBySlug(userSlug) if (bannedNation.isBanned) NationData.Table.set(bannedNation.id, Updates.set(NationData::isBanned.serialName, false)) - redirectHref(Root.User.ById(userId), HttpStatusCode.SeeOther) + redirectHref(Root.User.BySlug(userSlug), HttpStatusCode.SeeOther) } diff --git a/src/main/kotlin/info/mechyrdia/lore/ParserPreprocess.kt b/src/main/kotlin/info/mechyrdia/lore/ParserPreprocess.kt index 6f1e07d..188e89c 100644 --- a/src/main/kotlin/info/mechyrdia/lore/ParserPreprocess.kt +++ b/src/main/kotlin/info/mechyrdia/lore/ParserPreprocess.kt @@ -48,7 +48,7 @@ class PreProcessorContext private constructor( fun defaults(lorePath: StoragePath) = defaults(lorePath.elements.drop(1)) fun defaults(lorePath: List) = mapOf( - PAGE_PATH_KEY to lorePath.concat("/", prefix = "/").textToTree(), + PAGE_PATH_KEY to lorePath.map { "/$it" }.concat().textToTree(), INSTANT_NOW_KEY to Instant.now().toEpochMilli().numberToTree(), ) } diff --git a/src/main/kotlin/info/mechyrdia/lore/ViewsRss.kt b/src/main/kotlin/info/mechyrdia/lore/ViewsRss.kt index dad60f3..3a7b6f3 100644 --- a/src/main/kotlin/info/mechyrdia/lore/ViewsRss.kt +++ b/src/main/kotlin/info/mechyrdia/lore/ViewsRss.kt @@ -14,7 +14,6 @@ import info.mechyrdia.data.XmlTag import info.mechyrdia.data.XmlTagConsumer import info.mechyrdia.data.currentNation import info.mechyrdia.data.declaration -import info.mechyrdia.data.getNation import info.mechyrdia.data.nationCache import info.mechyrdia.data.respondXml import info.mechyrdia.data.root @@ -150,7 +149,7 @@ suspend fun ApplicationCall.recentCommentsRssFeedGenerator(limit: Int): RssChann if (currNation?.id == OwnerNationId) flow else flow.filterNot { comment -> - comment.submittedBy != currNation?.id && nationCache.getNation(comment.submittedBy).isBanned + comment.submittedBy != currNation?.id && nationCache.getByDbId(comment.submittedBy).isBanned } } .take(limit) diff --git a/src/main/kotlin/info/mechyrdia/route/ResourceBodies.kt b/src/main/kotlin/info/mechyrdia/route/ResourceBodies.kt index bc5507d..1a43760 100644 --- a/src/main/kotlin/info/mechyrdia/route/ResourceBodies.kt +++ b/src/main/kotlin/info/mechyrdia/route/ResourceBodies.kt @@ -1,5 +1,7 @@ package info.mechyrdia.route +import info.mechyrdia.auth.WebDavToken +import info.mechyrdia.data.Id import info.mechyrdia.lore.TextAlignment import kotlinx.html.* import kotlinx.serialization.Serializable @@ -53,6 +55,9 @@ class AdminVfsCopyFilePayload(val from: String, override val csrfToken: String? @Serializable class AdminVfsRequestWebDavTokenPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload +@Serializable +class AdminVfsDeleteWebDavTokenPayload(val tokenId: Id, override val csrfToken: String? = null) : CsrfProtectedResourcePayload + @Serializable class AdminVfsDeleteFilePayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload diff --git a/src/main/kotlin/info/mechyrdia/route/ResourceCsrf.kt b/src/main/kotlin/info/mechyrdia/route/ResourceCsrf.kt index 27e15be..d9e6330 100644 --- a/src/main/kotlin/info/mechyrdia/route/ResourceCsrf.kt +++ b/src/main/kotlin/info/mechyrdia/route/ResourceCsrf.kt @@ -36,8 +36,7 @@ fun A.installCsrfToken(route: String = href, call: ApplicationCall) { } fun FORM.installCsrfToken(route: String = action, call: ApplicationCall) { - hiddenInput { - name = "csrfToken" + hiddenInput(name = "csrfToken") { value = call.createCsrfToken(route) } } diff --git a/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt b/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt index 1db9c1c..24da667 100644 --- a/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt +++ b/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt @@ -1,5 +1,6 @@ package info.mechyrdia.route +import info.mechyrdia.auth.adminDeleteWebDavToken import info.mechyrdia.auth.adminObtainWebDavToken import info.mechyrdia.auth.adminRequestWebDavToken import info.mechyrdia.auth.loginPage @@ -8,7 +9,7 @@ import info.mechyrdia.auth.logoutRoute import info.mechyrdia.concat import info.mechyrdia.data.Comment import info.mechyrdia.data.Id -import info.mechyrdia.data.NationData +import info.mechyrdia.data.NationUrlSlug import info.mechyrdia.data.StoragePath import info.mechyrdia.data.adminBanUserRoute import info.mechyrdia.data.adminConfirmDeleteFile @@ -16,6 +17,7 @@ import info.mechyrdia.data.adminConfirmRemoveDirectory import info.mechyrdia.data.adminDeleteFile import info.mechyrdia.data.adminDoCopyFile import info.mechyrdia.data.adminMakeDirectory +import info.mechyrdia.data.adminNationOnly import info.mechyrdia.data.adminOverwriteFile import info.mechyrdia.data.adminPreviewFile import info.mechyrdia.data.adminRemoveDirectory @@ -29,7 +31,6 @@ import info.mechyrdia.data.deleteCommentPage import info.mechyrdia.data.deleteCommentRoute import info.mechyrdia.data.editCommentRoute import info.mechyrdia.data.newCommentRoute -import info.mechyrdia.data.ownerNationOnly import info.mechyrdia.data.recentCommentsPage import info.mechyrdia.data.respondStoredFile import info.mechyrdia.data.respondXml @@ -329,12 +330,12 @@ class Root : ResourceHandler, ResourceFilter { call.currentUserPage() } - @Resource("{id}") - class ById(val id: Id, val user: User = User()) : ResourceHandler { + @Resource("{slug}") + class BySlug(val slug: NationUrlSlug, val user: User = User()) : ResourceHandler { override suspend fun RoutingContext.handleCall() { with(user) { call.filterCall() } - call.respondHtml(HttpStatusCode.OK, call.userPage(id)) + call.respondHtml(HttpStatusCode.OK, call.userPage(slug)) } } } @@ -343,26 +344,26 @@ class Root : ResourceHandler, ResourceFilter { class Admin(val root: Root = Root()) : ResourceFilter { override suspend fun ApplicationCall.filterCall() { with(root) { filterCall() } - ownerNationOnly() + adminNationOnly() } - @Resource("ban/{id}") - class Ban(val id: Id, val admin: Admin = Admin()) : ResourceReceiver { + @Resource("ban/{slug}") + class Ban(val slug: NationUrlSlug, val admin: Admin = Admin()) : ResourceReceiver { override suspend fun RoutingContext.handleCall(payload: AdminBanUserPayload) { with(admin) { call.filterCall() } with(payload) { call.verifyCsrfToken() } - call.adminBanUserRoute(id) + call.adminBanUserRoute(slug) } } - @Resource("unban/{id}") - class Unban(val id: Id, val admin: Admin = Admin()) : ResourceReceiver { + @Resource("unban/{slug}") + class Unban(val slug: NationUrlSlug, val admin: Admin = Admin()) : ResourceReceiver { override suspend fun RoutingContext.handleCall(payload: AdminUnbanUserPayload) { with(admin) { call.filterCall() } with(payload) { call.verifyCsrfToken() } - call.adminUnbanUserRoute(id) + call.adminUnbanUserRoute(slug) } } @@ -456,6 +457,16 @@ class Root : ResourceHandler, ResourceFilter { } } + @Resource("webdav-token/delete") + class WebDavTokenDelete(val vfs: Vfs = Vfs()) : ResourceReceiver { + override suspend fun RoutingContext.handleCall(payload: AdminVfsDeleteWebDavTokenPayload) { + with(vfs) { call.filterCall() } + with(payload) { call.verifyCsrfToken() } + + call.adminDeleteWebDavToken(payload.tokenId) + } + } + @Resource("copy/{path...}") class CopyPage(val path: List, val vfs: Vfs = Vfs()) : ResourceHandler { override suspend fun RoutingContext.handleCall() { diff --git a/src/main/kotlin/info/mechyrdia/route/ResourceWebDav.kt b/src/main/kotlin/info/mechyrdia/route/ResourceWebDav.kt index 95725b2..04eebdc 100644 --- a/src/main/kotlin/info/mechyrdia/route/ResourceWebDav.kt +++ b/src/main/kotlin/info/mechyrdia/route/ResourceWebDav.kt @@ -1,8 +1,9 @@ package info.mechyrdia.route +import info.mechyrdia.Configuration import info.mechyrdia.Utf8 +import info.mechyrdia.auth.Argon2Hasher import info.mechyrdia.auth.WebDavToken -import info.mechyrdia.auth.toNationId import info.mechyrdia.concat import info.mechyrdia.data.FileStorage import info.mechyrdia.data.Id @@ -22,6 +23,7 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.log import io.ktor.server.html.respondHtml import io.ktor.server.request.ApplicationRequest import io.ktor.server.request.authorization @@ -42,9 +44,8 @@ import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.util.Base64 -import java.util.UUID -const val WebDavDomainName = "https://dav.mechyrdia.info" +const val WebDavDomainName = "http://localhost:8180" private val dateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME @@ -134,7 +135,7 @@ private suspend fun getWebDavPropertiesWithIncludeTags(path: StoragePath, webRoo .filterNotNull() .flatten() - val pathWithSuffix = path.elements.concat("/", suffix = "/") + val pathWithSuffix = path.elements.map { "$it/" }.concat() listOf( WebDavProperties.Collection( creationDate = subProps.mapNotNull { it.first.creationDate }.maxOrNull(), @@ -198,7 +199,7 @@ private val base64Decoder = Base64.getDecoder() fun ApplicationRequest.basicAuth(): Pair? { val auth = authorization() ?: return null - if (!auth.startsWith("Basic ")) return null + if (!auth.startsWith(" ")) return null val basic = auth.substring(6) return String(base64Decoder.decode(basic), Utf8) .split(':', limit = 2) @@ -208,13 +209,16 @@ fun ApplicationRequest.basicAuth(): Pair? { suspend fun ApplicationCall.beforeWebDav() { attributes.put(WebDavAttributeKey, true) - val (user, token) = request.basicAuth() ?: throw WebDavAuthRequired() - val tokenData = WebDavToken.Table.get(Id(token)) ?: throw WebDavAuthRequired() + response.header(HttpHeaders.DAV, "1,2") - if (tokenData.holder.id != user.toNationId() || tokenData.validUntil < Instant.now()) - throw WebDavAuthRequired() + if (Configuration.Current.isDevMode) + return - response.header(HttpHeaders.DAV, "1,2") + val (tokenId, tokenPw) = request.basicAuth() ?: throw WebDavAuthRequired() + val tokenData = WebDavToken.Table.get(Id(tokenId)) ?: throw WebDavAuthRequired() + + if (tokenData.validUntil < Instant.now() || !Argon2Hasher.verifyHash(tokenData.pwHash, tokenPw)) + throw WebDavAuthRequired() } suspend fun ApplicationCall.webDavOptions() { @@ -360,10 +364,8 @@ suspend fun ApplicationCall.webDavDelete(path: StoragePath) { suspend fun ApplicationCall.webDavLock(path: StoragePath) { beforeWebDav() - if (request.header(HttpHeaders.ContentType) != null) - receiveText() - - val depth = request.header(HttpHeaders.Depth) ?: "Infinity" + val lockRequest = receiveText() + application.log.debug(lockRequest) respondXml { declaration() @@ -372,12 +374,9 @@ suspend fun ApplicationCall.webDavLock(path: StoragePath) { "activelock" { "lockscope" { "shared"() } "locktype" { "write"() } - "depth" { +depth } + "depth" { +"0" } "owner"() "timeout" { +"Second-86400" } - "locktoken" { - "href" { +"opaquelocktoken:${UUID.randomUUID()}" } - } } } } diff --git a/src/main/resources/static/admin.js b/src/main/resources/static/admin.js index e6ad241..c935f7c 100644 --- a/src/main/resources/static/admin.js +++ b/src/main/resources/static/admin.js @@ -1,5 +1,18 @@ (function () { + function getCookieMap() { + return document.cookie + .split(";") + .reduce((obj, entry) => { + const trimmed = entry.trim(); + const eqI = trimmed.indexOf('='); + const key = trimmed.substring(0, eqI).trimEnd(); + const value = trimmed.substring(eqI + 1).trimStart(); + return {...obj, [key]: value}; + }, {}); + } + window.addEventListener("load", function () { + // File uploads const fileInputs = document.querySelectorAll("input[type=file]"); for (const fileInput of fileInputs) { fileInput.addEventListener("change", e => { @@ -17,4 +30,28 @@ moment.style.display = "inline"; } }); + + window.addEventListener("load", function () { + // Text copying + const textsToCopy = document.querySelectorAll(".text-copy"); + for (const textToCopy of textsToCopy) { + textToCopy.addEventListener("click", e => { + e.preventDefault(); + navigator.clipboard.writeText(e.currentTarget.innerText) + .catch(reason => { + console.error("Error copying text to clipboard!", reason); + alert("Text copy failed"); + }); + }); + } + }); + + window.addEventListener("load", function () { + // Error popup + const errorMsg = getCookieMap()["ERROR_MSG"]; + if (errorMsg != null) { + document.cookie = "ERROR_MSG=; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax; Secure"; + alert(errorMsg); + } + }); })();