From: Lanius Trolling Date: Sun, 14 Apr 2024 16:15:14 +0000 (-0400) Subject: Add WebDAV support X-Git-Url: https://gitweb.starshipfights.net/?a=commitdiff_plain;h=8881153df3e97a7654cbefcbd20fcb102860e15a;p=factbooks Add WebDAV support --- diff --git a/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt b/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt index be4838c..253d464 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt @@ -110,41 +110,69 @@ fun Application.factbooks() { install(StatusPages) { status(HttpStatusCode.BadRequest) { call, _ -> - call.respondHtml(HttpStatusCode.BadRequest, call.error400()) + if (!call.isWebDav) + call.respondHtml(HttpStatusCode.BadRequest, call.error400()) } status(HttpStatusCode.Forbidden) { call, _ -> - call.respondHtml(HttpStatusCode.Forbidden, call.error403()) + if (!call.isWebDav) + call.respondHtml(HttpStatusCode.Forbidden, call.error403()) } status(HttpStatusCode.NotFound) { call, _ -> - call.respondHtml(HttpStatusCode.NotFound, call.error404()) + if (!call.isWebDav) + call.respondHtml(HttpStatusCode.NotFound, call.error404()) } status(HttpStatusCode.Conflict) { call, _ -> - call.respondHtml(HttpStatusCode.Conflict, call.error409()) + if (!call.isWebDav) + call.respondHtml(HttpStatusCode.Conflict, call.error409()) } status(HttpStatusCode.InternalServerError) { call, _ -> - call.respondHtml(HttpStatusCode.InternalServerError, call.error500()) + if (!call.isWebDav) + call.respondHtml(HttpStatusCode.InternalServerError, call.error500()) } exception { call, (url, permanent) -> - call.respondRedirect(url, permanent) + if (!call.isWebDav) + call.respondRedirect(url, permanent) } exception { call, _ -> - call.respondHtml(HttpStatusCode.BadRequest, call.error400()) + if (call.isWebDav) + call.respond(HttpStatusCode.BadRequest) + else + call.respondHtml(HttpStatusCode.BadRequest, call.error400()) } exception { call, _ -> - call.respondHtml(HttpStatusCode.Forbidden, call.error403()) + if (call.isWebDav) + call.respond(HttpStatusCode.Forbidden) + else + call.respondHtml(HttpStatusCode.Forbidden, call.error403()) } exception { call, (_, payload) -> - call.respondHtml(HttpStatusCode.Forbidden, call.error403PageExpired(payload)) + if (call.isWebDav) + call.respond(HttpStatusCode.Forbidden) + else + call.respondHtml(HttpStatusCode.Forbidden, call.error403PageExpired(payload)) } exception { call, _ -> - call.respondHtml(HttpStatusCode.NotFound, call.error404()) + if (call.isWebDav) + call.respond(HttpStatusCode.NotFound) + else + call.respondHtml(HttpStatusCode.NotFound, call.error404()) } exception { call, _ -> - call.respondHtml(HttpStatusCode.NotFound, call.error404()) + if (call.isWebDav) + call.respond(HttpStatusCode.NotFound) + else + call.respondHtml(HttpStatusCode.NotFound, call.error404()) } exception { call, _ -> - call.respondHtml(HttpStatusCode.NotFound, call.error404()) + if (call.isWebDav) + call.respond(HttpStatusCode.NotFound) + else + call.respondHtml(HttpStatusCode.NotFound, call.error404()) + } + exception { call, _ -> + call.response.header(HttpHeaders.WWWAuthenticate, "Basic realm=\"WebDAV Endpoint\"") + call.respond(HttpStatusCode.Unauthorized) } exception { call, ex -> @@ -187,6 +215,8 @@ fun Application.factbooks() { get() get() get() + get() + post() get() post() postMultipart() @@ -200,5 +230,7 @@ fun Application.factbooks() { post() post() post() + + route("/webdav") { installWebDav() } } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/auth/WebDav.kt b/src/jvmMain/kotlin/info/mechyrdia/auth/WebDav.kt new file mode 100644 index 0000000..2e952d3 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/auth/WebDav.kt @@ -0,0 +1,71 @@ +package info.mechyrdia.auth + +import info.mechyrdia.data.* +import info.mechyrdia.lore.adminPage +import info.mechyrdia.lore.dateTime +import info.mechyrdia.lore.redirectHref +import info.mechyrdia.route.Root +import info.mechyrdia.route.href +import info.mechyrdia.route.installCsrfToken +import io.ktor.server.application.* +import kotlinx.html.* +import kotlinx.serialization.Serializable +import java.time.Instant + +@Serializable +data class WebDavToken( + override val id: Id = Id(), + val holder: Id, + val validUntil: @Serializable(with = InstantSerializer::class) Instant +) : DataDocument { + companion object : TableHolder { + override val Table = DocumentTable() + + override suspend fun initialize() { + Table.index(WebDavToken::holder) + } + } +} + +fun ApplicationCall.adminRequestWebDavToken(): HTML.() -> Unit { + return adminPage("Request WebDav Token") { + div(classes = "message") { + div { + style = "text-align:center" + form(method = FormMethod.post, action = href(Root.Admin.Vfs.WebDavTokenPost())) { + installCsrfToken() + submitInput { value = "Request WebDav Token" } + } + } + } + } +} + +suspend fun ApplicationCall.adminObtainWebDavToken(): HTML.() -> Unit { + val nation = currentNation() + ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "You must be logged in to generate WebDav tokens")))) + + val token = WebDavToken( + holder = nation.id, + validUntil = Instant.now().plusSeconds(86_400) + ) + + WebDavToken.Table.put(token) + + return adminPage("Your New WebDav Token") { + div(classes = "message") { + 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) + } + } + } + } +} diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewNav.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewNav.kt index 44f7890..888c82b 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewNav.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewNav.kt @@ -58,6 +58,7 @@ suspend fun ApplicationCall.standardNavBar(path: List? = null) = listOf( listOf( NavHead("Administration"), NavLink(href(Root.Admin.Vfs.View(emptyList())), "View VFS"), + NavLink(href(Root.Admin.Vfs.WebDavTokenPage()), "Create WebDav Token"), ) else emptyList()) diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceBodies.kt b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceBodies.kt index 30cc0ae..3bdd9d9 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceBodies.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceBodies.kt @@ -46,6 +46,9 @@ class AdminUnbanUserPayload(override val csrfToken: String? = null) : CsrfProtec @Serializable class AdminVfsCopyFilePayload(val from: String, override val csrfToken: String? = null) : CsrfProtectedResourcePayload +@Serializable +class AdminVfsRequestWebDavTokenPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload + @Serializable class AdminVfsDeleteFilePayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt index 3238b3b..bb2c26e 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt @@ -303,6 +303,25 @@ class Root(val error: String? = null) : ResourceHandler, ResourceFilter { } } + @Resource("webdav-token") + class WebDavTokenPage(val vfs: Vfs = Vfs()) : ResourceHandler { + override suspend fun PipelineContext.handleCall() { + with(vfs) { filterCall() } + + call.respondHtml(HttpStatusCode.OK, call.adminRequestWebDavToken()) + } + } + + @Resource("webdav-token") + class WebDavTokenPost(val vfs: Vfs = Vfs()) : ResourceReceiver { + override suspend fun PipelineContext.handleCall(payload: AdminVfsRequestWebDavTokenPayload) { + with(vfs) { filterCall() } + with(payload) { call.verifyCsrfToken() } + + call.respondHtml(HttpStatusCode.Created, call.adminObtainWebDavToken()) + } + } + @Resource("copy/{path...}") class CopyPage(val path: List, val vfs: Vfs = Vfs()) : ResourceHandler { override suspend fun PipelineContext.handleCall() { diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceWebDav.kt b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceWebDav.kt new file mode 100644 index 0000000..f735438 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceWebDav.kt @@ -0,0 +1,364 @@ +package info.mechyrdia.route + +import info.mechyrdia.auth.WebDavToken +import info.mechyrdia.auth.toNationId +import info.mechyrdia.data.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.html.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.util.* +import io.ktor.utils.io.core.* +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.html.* +import java.net.URI +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.* +import kotlin.text.String + +const val WebDavDomainName = "https://dav.mechyrdia.info" + +private val dateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME + +private val Instant.webDavValue: String + get() = dateTimeFormatter.format(ZonedDateTime.ofInstant(this, ZoneOffset.UTC)) + +sealed class WebDavProperties { + abstract val creationDate: Instant? + abstract val lastModified: Instant? + abstract val displayName: String + abstract val displayHref: String + + data class Leaf( + override val creationDate: Instant, + override val lastModified: Instant, + override val displayName: String, + override val displayHref: String, + val contentLength: Long, + val contentType: ContentType, + ) : WebDavProperties() + + data class Collection( + override val creationDate: Instant?, + override val lastModified: Instant?, + override val displayName: String, + override val displayHref: String, + ) : WebDavProperties() +} + +context(XmlTag) +operator fun WebDavProperties.unaryPlus() = "response" { + "href" { +displayHref } + "propstat" { + "props" { + creationDate?.webDavValue?.let { value -> "creationdate" { +value } } + lastModified?.webDavValue?.let { value -> "getlastmodified" { +value } } + "displayname" { +displayName } + if (this@unaryPlus is WebDavProperties.Leaf) { + "getcontentlength" { +"$contentLength" } + "getcontenttype" { +"${contentType.withoutParameters()}" } + } else { + "resourcetype" { "collection"() } + } + "supportedlock" { + "lockentry" { + "lockscope" { "shared"() } + "locktype" { "write"() } + } + } + } + "status" { +"HTTP/1.1 200 OK" } + } +} + +fun > C.webDavProps(props: List) = declaration() + .root("multistatus", namespace = "DAV:") { + for (propSet in props) + +propSet + } + +private suspend fun getWebDavPropertiesWithIncludeTags(path: StoragePath, webRoot: String, depth: Int): List>? { + return FileStorage.instance.statFile(path)?.let { stats -> + listOf( + WebDavProperties.Leaf( + creationDate = stats.created, + lastModified = stats.updated, + displayName = path.name, + displayHref = "${webRoot.removeSuffix("/")}/$path".removeSuffix("/"), + contentLength = stats.size, + contentType = path.contentType + ) to (depth >= 0) + ) + } ?: FileStorage.instance.listDir(path)?.let { subEntries -> + val subPaths = subEntries.keys.map { path / it } + val subProps = coroutineScope { + subPaths.map { subPath -> + async { + getWebDavPropertiesWithIncludeTags(subPath, webRoot, depth - 1) + } + }.awaitAll().filterNotNull().flatten() + } + + val pathWithSuffix = path.elements.joinToString(separator = "") { "$it/" } + listOf( + WebDavProperties.Collection( + creationDate = subProps.mapNotNull { it.first.creationDate }.maxOrNull(), + lastModified = subProps.mapNotNull { it.first.lastModified }.maxOrNull(), + displayName = path.name, + displayHref = "${webRoot.removeSuffix("/")}/$pathWithSuffix", + ) to (depth >= 0) + ) + subProps + } +} + +suspend fun getWebDavProperties(path: StoragePath, webRoot: String, depth: Int = Int.MAX_VALUE): List? { + return getWebDavPropertiesWithIncludeTags(path, webRoot, depth)?.mapNotNull { (props, include) -> + if (include) props else null + } +} + +suspend fun FileStorage.copyWebDav(source: StoragePath, target: StoragePath): Boolean { + return when (getType(source)) { + StoredFileType.DIRECTORY -> createDir(target) && (listDir(source)?.let { subPaths -> + val copyActions = subPaths.keys.map { (source / it) to (target / it) } + coroutineScope { + copyActions.map { (subSource, subTarget) -> + async { copyWebDav(subSource, subTarget) } + }.awaitAll().all { it } + } + } == true) + + StoredFileType.FILE -> copyFile(source, target) + + null -> false + } +} + +suspend fun FileStorage.deleteWebDav(path: StoragePath): Boolean { + return when (getType(path)) { + StoredFileType.DIRECTORY -> deleteDir(path) + + StoredFileType.FILE -> eraseFile(path) + + null -> false + } +} + +val WebDavAttributeKey = AttributeKey("Mechyrdia.WebDav") +val ApplicationCall.isWebDav: Boolean + get() = attributes.getOrNull(WebDavAttributeKey) == true + +val WebDavAuthAttributeKey = AttributeKey>("Mechyrdia.WebDavAuth") +val ApplicationCall.webDavAuth: Id + get() = attributes.getOrNull(WebDavAuthAttributeKey) ?: throw WebDavAuthRequired() + +class WebDavAuthRequired : RuntimeException() + +private val base64Decoder = Base64.getDecoder() + +fun ApplicationRequest.basicAuth(): Pair? { + val auth = authorization() ?: return null + if (!auth.startsWith("Basic ")) return null + val basic = auth.substring(6) + return String(base64Decoder.decode(basic)) + .split(':', limit = 2) + .let { (user, pass) -> user to pass } +} + +suspend fun ApplicationCall.beforeWebDav() { + attributes.put(WebDavAttributeKey, true) + + val (user, token) = request.basicAuth() ?: throw WebDavAuthRequired() + val tokenData = WebDavToken.Table.get(Id(token)) ?: throw WebDavAuthRequired() + + if (tokenData.holder.id != user.toNationId() || tokenData.validUntil < Instant.now()) + throw WebDavAuthRequired() + + response.header(HttpHeaders.DAV, "1,2") +} + +suspend fun ApplicationCall.webDavOptions() { + beforeWebDav() + + response.header(HttpHeaders.Allow, "GET, PUT, DELETE, MKCOL, OPTIONS, COPY, MOVE, PROPFIND, PROPPATCH, LOCK, UNLOCK, HEAD") + response.header(HttpHeaders.ContentType, "httpd/unix-directory") + respond(HttpStatusCode.NoContent) +} + +val ApplicationCall.webDavPath: StoragePath + get() = StoragePath(parameters.getAll("path").orEmpty()) + +suspend fun ApplicationCall.webDavPropFind(path: StoragePath) { + beforeWebDav() + + val depth = request.header(HttpHeaders.Depth)?.toIntOrNull() ?: Int.MAX_VALUE + val propList = getWebDavProperties(path, WebDavDomainName, depth) + if (propList == null) + respond(HttpStatusCode.NotFound) + else + respondXml(status = HttpStatusCode.MultiStatus) { + webDavProps(propList) + } +} + +suspend fun ApplicationCall.webDavGet(path: StoragePath) { + beforeWebDav() + + FileStorage.instance.readFile(path)?.let { bytes -> + respondBytes(bytes, path.contentType) + } ?: FileStorage.instance.listDir(path)?.sortedAsFiles()?.let { entries -> + respondHtml { + head { + title { +"$path/" } + } + body { + h1 { +"$path/" } + ul { + for ((name, type) in entries) + li { + val subPath = path / name + val suffix = when (type) { + StoredFileType.DIRECTORY -> "/" + StoredFileType.FILE -> "" + } + a("$WebDavDomainName/$subPath$suffix") { +"$name$suffix" } + } + } + } + } + } ?: respond(HttpStatusCode.NotFound) +} + +suspend fun ApplicationCall.webDavMkCol(path: StoragePath) { + beforeWebDav() + + if (FileStorage.instance.createDir(path)) + respond(HttpStatusCode.Created) + else + respond(HttpStatusCode.MethodNotAllowed) +} + +private suspend fun ApplicationCall.checkWebDavOverwrite(): StoragePath? { + val overwrite = (request.header(HttpHeaders.Overwrite) ?: "T") == "T" + val dest = request.header(HttpHeaders.Destination)?.let { StoragePath(URI(it).path) }!! + val existingType = FileStorage.instance.getType(dest) + if (overwrite) { + if (existingType == StoredFileType.DIRECTORY) + FileStorage.instance.deleteDir(dest) + else if (existingType == StoredFileType.FILE) + FileStorage.instance.eraseFile(dest) + } else { + if (existingType != null) { + respond(HttpStatusCode.PreconditionFailed) + return null + } + } + + return dest +} + +suspend fun ApplicationCall.webDavPut(path: StoragePath) { + beforeWebDav() + + val body = receiveChannel().readRemaining().readBytes() + + if (!FileStorage.instance.deleteWebDav(path)) + return respond(HttpStatusCode.Conflict) + + if (FileStorage.instance.writeFile(path, body)) + respond(HttpStatusCode.Created) + else + respond(HttpStatusCode.Conflict) +} + +suspend fun ApplicationCall.webDavCopy(path: StoragePath) { + beforeWebDav() + + val dest = checkWebDavOverwrite() ?: return + + if (FileStorage.instance.copyWebDav(path, dest)) + respond(HttpStatusCode.NoContent) + else + respond(HttpStatusCode.NotFound) +} + +suspend fun ApplicationCall.webDavMove(path: StoragePath) { + beforeWebDav() + + val dest = checkWebDavOverwrite() ?: return + + if (!FileStorage.instance.copyWebDav(path, dest)) + return respond(HttpStatusCode.NotFound) + + if (FileStorage.instance.deleteWebDav(path)) + respond(HttpStatusCode.NoContent) + else + respond(HttpStatusCode.Conflict) +} + +suspend fun ApplicationCall.webDavDelete(path: StoragePath) { + beforeWebDav() + + if (FileStorage.instance.deleteWebDav(path)) + respond(HttpStatusCode.NoContent) + else + respond(HttpStatusCode.NotFound) +} + +suspend fun ApplicationCall.webDavLock(path: StoragePath) { + beforeWebDav() + + if (request.header(HttpHeaders.ContentType) != null) + receiveText() + + val depth = request.header(HttpHeaders.Depth) ?: "Infinity" + + respondXml { + declaration() + .root("prop", namespace = "DAV:") { + "lockdiscovery" { + "activelock" { + "lockscope" { "shared"() } + "locktype" { "write"() } + "depth" { +depth } + "owner"() + "timeout" { +"Second-86400" } + "locktoken" { + "href" { +"opaquelocktoken:${UUID.randomUUID()}" } + } + } + } + } + } +} + +suspend fun ApplicationCall.webDavUnlock(path: StoragePath) { + beforeWebDav() + + if (request.header(HttpHeaders.ContentType) != null) + receiveText() + + respond(HttpStatusCode.NoContent) +} + +fun Route.installWebDav() { + route("{path...}") { + method(HttpMethod.parse("OPTIONS")) { handle { call.webDavOptions() } } + method(HttpMethod.parse("PROPFIND")) { handle { call.webDavPropFind(call.webDavPath) } } + method(HttpMethod.parse("GET")) { handle { call.webDavGet(call.webDavPath) } } + method(HttpMethod.parse("MKCOL")) { handle { call.webDavMkCol(call.webDavPath) } } + method(HttpMethod.parse("PUT")) { handle { call.webDavPut(call.webDavPath) } } + method(HttpMethod.parse("COPY")) { handle { call.webDavCopy(call.webDavPath) } } + method(HttpMethod.parse("MOVE")) { handle { call.webDavMkCol(call.webDavPath) } } + method(HttpMethod.parse("DELETE")) { handle { call.webDavDelete(call.webDavPath) } } + method(HttpMethod.parse("LOCK")) { handle { call.webDavLock(call.webDavPath) } } + method(HttpMethod.parse("UNLOCK")) { handle { call.webDavUnlock(call.webDavPath) } } + } +}