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
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
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,
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 {
generate {
"call_${counter.incrementAndGet().toULong()}_${System.currentTimeMillis()}"
}
- reply { call, callId ->
- call.response.header("X-Call-Id", callId)
- }
+ replyToHeader("X-Call-Id")
}
install(CallLogging) {
get<Root.Comments.DeleteConfirmPage>()
post<Root.Comments.DeleteConfirmPost, _>()
get<Root.User>()
- get<Root.User.ById>()
+ get<Root.User.BySlug>()
post<Root.Admin.Ban, _>()
post<Root.Admin.Unban, _>()
get<Root.Admin.NukeManagement>()
get<Root.Admin.Vfs.View>()
get<Root.Admin.Vfs.WebDavTokenPage>()
post<Root.Admin.Vfs.WebDavTokenPost, _>()
+ post<Root.Admin.Vfs.WebDavTokenDelete, _>()
get<Root.Admin.Vfs.CopyPage>()
post<Root.Admin.Vfs.CopyPost, _>()
postMultipart<Root.Admin.Vfs.Upload, _>()
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
}
}
-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)
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
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
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."
}
}
label {
+"Verification Checksum"
br
- textInput {
- name = "checksum"
+ textInput(name = "checksum") {
placeholder = "The random text checksum generated by NationStates for verification"
}
}
}
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))
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.*
data class WebDavToken(
@SerialName(MONGODB_ID_KEY)
override val id: Id<WebDavToken> = Id(),
+ val pwHash: String,
val holder: Id<NationData>,
val validUntil: @Serializable(with = InstantSerializer::class) Instant
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" }
+ }
+ }
}
}
}
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)
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<WebDavToken>): 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)
+}
@Serializable(IdSerializer::class)
@JvmInline
-value class Id<T>(val id: String) {
+value class Id<@Suppress("unused") T>(val id: String) {
override fun toString() = id
companion object {
private val secureRandom = SecureRandom.getInstanceStrong()
private val alphabet = "ABCDEFGHILMNOPQRSTVXYZ0123456789".toCharArray()
-fun <T> Id() = Id<T>(NanoIdUtils.randomNanoId(secureRandom, alphabet, 24))
+fun nanoId(length: Int): String = NanoIdUtils.randomNanoId(secureRandom, alphabet, length)
+
+fun <T> Id() = Id<T>(nanoId(24))
object IdSerializer : KSerializer<Id<*>> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Id", PrimitiveKind.STRING)
) : DataDocument<GridFsEntry>
private class GridFsStorage(val table: DocumentTable<GridFsEntry>, 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)))
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
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<NationData>,
+ val slug: NationUrlSlug,
val name: String,
val flag: String,
override val Table = DocumentTable<NationData>()
override suspend fun initialize() {
+ Table.index(NationData::slug.ascending)
Table.index(NationData::name.ascending)
}
- fun unknown(id: Id<NationData>): 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>): 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>): NationData {
+ return Table.get(nationDbId) ?: unknownOfDbId(nationDbId)
}
- suspend fun get(id: Id<NationData>): 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<MutableMap<Id<NationData>, NationData>>("Mechyrdia.NationCache")
+private fun <Q : ShardQuery<Q, *, NationShard>> Q.defaultShards(): Q = shards(NationShard.DB_ID, NationShard.NAME, NationShard.FLAG_URL)
-val ApplicationCall.nationCache: MutableMap<Id<NationData>, NationData>
- get() = attributes.computeIfAbsent(CallNationCacheAttribute) {
- ConcurrentHashMap<Id<NationData>, 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<Id<NationData>, NationData>.getNation(id: Id<NationData>): 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<NationCache>("Mechyrdia.NationCache")
+
+class NationCache {
+ private val bySlug = ConcurrentHashMap<NationUrlSlug, NationData>()
+ private val byDbId = ConcurrentHashMap<Id<NationData>, 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>): 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<NationSession>("Mechyrdia.CurrentNation")
-fun ApplicationCall.ownerNationOnly() {
- if (sessions.get<UserSession>()?.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? {
return sessions.get<UserSession>()
?.nationId
- ?.let { nationCache.getNation(it) }
+ ?.let { nationCache.getByDbId(it) }
?.also { attributes.put(CallCurrentNationAttribute, NationSession(it)) }
}
val replyLinks: List<Id<Comment>>,
) {
companion object {
- private suspend fun render(comment: Comment, nations: MutableMap<Id<NationData>, 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) }
)
}
- suspend operator fun invoke(comments: List<Comment>, nations: MutableMap<Id<NationData>, NationData> = mutableMapOf()): List<CommentRenderData> {
+ suspend operator fun invoke(comments: List<Comment>, nations: NationCache = NationCache()): List<CommentRenderData> {
return comments.mapSuspend { comment ->
render(comment, nations)
}
img(src = comment.submittedBy.flag, alt = "Flag of ${comment.submittedBy.name}", classes = "flag-icon")
span(classes = "author-name") {
+Entities.nbsp
- a(href = call.href(Root.User.ById(comment.submittedBy.id))) {
+ a(href = call.href(Root.User.BySlug(comment.submittedBy.slug))) {
+comment.submittedBy.name
}
}
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(),
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")
when (tree) {
is TreeNode.FileNode -> table {
tr {
- th {
+ th(ThScope.col) {
colSpan = "2"
+"/$path"
}
}
}
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 {
}
}
tr {
- th { +"Navigate" }
+ th(ThScope.row) { +"Navigate" }
td {
ul {
path.elements.indices.forEach { index ->
}
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}" }
}
}
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
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<UserSession>()?.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<NationData>): 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(),
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"
}
}
-suspend fun ApplicationCall.adminBanUserRoute(userId: Id<NationData>): 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<NationData>): 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)
}
fun defaults(lorePath: StoragePath) = defaults(lorePath.elements.drop(1))
fun defaults(lorePath: List<String>) = 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(),
)
}
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
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)
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
@Serializable
class AdminVfsRequestWebDavTokenPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
+@Serializable
+class AdminVfsDeleteWebDavTokenPayload(val tokenId: Id<WebDavToken>, override val csrfToken: String? = null) : CsrfProtectedResourcePayload
+
@Serializable
class AdminVfsDeleteFilePayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
}
fun FORM.installCsrfToken(route: String = action, call: ApplicationCall) {
- hiddenInput {
- name = "csrfToken"
+ hiddenInput(name = "csrfToken") {
value = call.createCsrfToken(route)
}
}
package info.mechyrdia.route
+import info.mechyrdia.auth.adminDeleteWebDavToken
import info.mechyrdia.auth.adminObtainWebDavToken
import info.mechyrdia.auth.adminRequestWebDavToken
import info.mechyrdia.auth.loginPage
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
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
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
call.currentUserPage()
}
- @Resource("{id}")
- class ById(val id: Id<NationData>, 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))
}
}
}
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<NationData>, val admin: Admin = Admin()) : ResourceReceiver<AdminBanUserPayload> {
+ @Resource("ban/{slug}")
+ class Ban(val slug: NationUrlSlug, val admin: Admin = Admin()) : ResourceReceiver<AdminBanUserPayload> {
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<NationData>, val admin: Admin = Admin()) : ResourceReceiver<AdminUnbanUserPayload> {
+ @Resource("unban/{slug}")
+ class Unban(val slug: NationUrlSlug, val admin: Admin = Admin()) : ResourceReceiver<AdminUnbanUserPayload> {
override suspend fun RoutingContext.handleCall(payload: AdminUnbanUserPayload) {
with(admin) { call.filterCall() }
with(payload) { call.verifyCsrfToken() }
- call.adminUnbanUserRoute(id)
+ call.adminUnbanUserRoute(slug)
}
}
}
}
+ @Resource("webdav-token/delete")
+ class WebDavTokenDelete(val vfs: Vfs = Vfs()) : ResourceReceiver<AdminVfsDeleteWebDavTokenPayload> {
+ 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<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
override suspend fun RoutingContext.handleCall() {
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
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
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
.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(),
fun ApplicationRequest.basicAuth(): Pair<String, String>? {
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)
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() {
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()
"activelock" {
"lockscope" { "shared"() }
"locktype" { "write"() }
- "depth" { +depth }
+ "depth" { +"0" }
"owner"()
"timeout" { +"Second-86400" }
- "locktoken" {
- "href" { +"opaquelocktoken:${UUID.randomUUID()}" }
- }
}
}
}
(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 => {
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);
+ }
+ });
})();