Add WebDAV support
authorLanius Trolling <lanius@laniustrolling.dev>
Sun, 14 Apr 2024 16:15:14 +0000 (12:15 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Sun, 14 Apr 2024 16:15:14 +0000 (12:15 -0400)
src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt
src/jvmMain/kotlin/info/mechyrdia/auth/WebDav.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ViewNav.kt
src/jvmMain/kotlin/info/mechyrdia/route/ResourceBodies.kt
src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt
src/jvmMain/kotlin/info/mechyrdia/route/ResourceWebDav.kt [new file with mode: 0644]

index be4838c0868aa5def8e37b657faa896dc04eff1f..253d464bd905758b4b98ec8911c3671cc8aba669 100644 (file)
@@ -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<HttpRedirectException> { call, (url, permanent) ->
-                       call.respondRedirect(url, permanent)
+                       if (!call.isWebDav)
+                               call.respondRedirect(url, permanent)
                }
                exception<MissingRequestParameterException> { call, _ ->
-                       call.respondHtml(HttpStatusCode.BadRequest, call.error400())
+                       if (call.isWebDav)
+                               call.respond(HttpStatusCode.BadRequest)
+                       else
+                               call.respondHtml(HttpStatusCode.BadRequest, call.error400())
                }
                exception<ForbiddenException> { call, _ ->
-                       call.respondHtml(HttpStatusCode.Forbidden, call.error403())
+                       if (call.isWebDav)
+                               call.respond(HttpStatusCode.Forbidden)
+                       else
+                               call.respondHtml(HttpStatusCode.Forbidden, call.error403())
                }
                exception<CsrfFailedException> { 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<NullPointerException> { call, _ ->
-                       call.respondHtml(HttpStatusCode.NotFound, call.error404())
+                       if (call.isWebDav)
+                               call.respond(HttpStatusCode.NotFound)
+                       else
+                               call.respondHtml(HttpStatusCode.NotFound, call.error404())
                }
                exception<NoSuchElementException> { call, _ ->
-                       call.respondHtml(HttpStatusCode.NotFound, call.error404())
+                       if (call.isWebDav)
+                               call.respond(HttpStatusCode.NotFound)
+                       else
+                               call.respondHtml(HttpStatusCode.NotFound, call.error404())
                }
                exception<IOException> { call, _ ->
-                       call.respondHtml(HttpStatusCode.NotFound, call.error404())
+                       if (call.isWebDav)
+                               call.respond(HttpStatusCode.NotFound)
+                       else
+                               call.respondHtml(HttpStatusCode.NotFound, call.error404())
+               }
+               exception<WebDavAuthRequired> { call, _ ->
+                       call.response.header(HttpHeaders.WWWAuthenticate, "Basic realm=\"WebDAV Endpoint\"")
+                       call.respond(HttpStatusCode.Unauthorized)
                }
                
                exception<Exception> { call, ex ->
@@ -187,6 +215,8 @@ fun Application.factbooks() {
                get<Root.Admin.Vfs.Inline>()
                get<Root.Admin.Vfs.Download>()
                get<Root.Admin.Vfs.View>()
+               get<Root.Admin.Vfs.WebDavTokenPage>()
+               post<Root.Admin.Vfs.WebDavTokenPost, _>()
                get<Root.Admin.Vfs.CopyPage>()
                post<Root.Admin.Vfs.CopyPost, _>()
                postMultipart<Root.Admin.Vfs.Upload, _>()
@@ -200,5 +230,7 @@ fun Application.factbooks() {
                post<Root.Utils.TylanLanguage, _>()
                post<Root.Utils.PokhwalishLanguage, _>()
                post<Root.Utils.PreviewComment, _>()
+               
+               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 (file)
index 0000000..2e952d3
--- /dev/null
@@ -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<WebDavToken> = Id(),
+       val holder: Id<NationData>,
+       val validUntil: @Serializable(with = InstantSerializer::class) Instant
+) : DataDocument<WebDavToken> {
+       companion object : TableHolder<WebDavToken> {
+               override val Table = DocumentTable<WebDavToken>()
+               
+               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)
+                               }
+                       }
+               }
+       }
+}
index 44f78902dcebdd63ac72229743cfa2031fa85904..888c82b550e0d2abc512b8108de7c10654cd558d 100644 (file)
@@ -58,6 +58,7 @@ suspend fun ApplicationCall.standardNavBar(path: List<String>? = 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())
 
index 30cc0ae3b1a5b947c94f83a4bc5c19c0997b318a..3bdd9d91b9bd6bac6170ce658a34865163da3483 100644 (file)
@@ -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
 
index 3238b3b7ce7145871fd898994788eba559a7c79f..bb2c26eb619ad41e4faee0d3cf1ffaf33d710be5 100644 (file)
@@ -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<Unit, ApplicationCall>.handleCall() {
+                                       with(vfs) { filterCall() }
+                                       
+                                       call.respondHtml(HttpStatusCode.OK, call.adminRequestWebDavToken())
+                               }
+                       }
+                       
+                       @Resource("webdav-token")
+                       class WebDavTokenPost(val vfs: Vfs = Vfs()) : ResourceReceiver<AdminVfsRequestWebDavTokenPayload> {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminVfsRequestWebDavTokenPayload) {
+                                       with(vfs) { filterCall() }
+                                       with(payload) { call.verifyCsrfToken() }
+                                       
+                                       call.respondHtml(HttpStatusCode.Created, call.adminObtainWebDavToken())
+                               }
+                       }
+                       
                        @Resource("copy/{path...}")
                        class CopyPage(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
                                override suspend fun PipelineContext<Unit, ApplicationCall>.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 (file)
index 0000000..f735438
--- /dev/null
@@ -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 <T, C : XmlTagConsumer<T>> C.webDavProps(props: List<WebDavProperties>) = declaration()
+       .root("multistatus", namespace = "DAV:") {
+               for (propSet in props)
+                       +propSet
+       }
+
+private suspend fun getWebDavPropertiesWithIncludeTags(path: StoragePath, webRoot: String, depth: Int): List<Pair<WebDavProperties, Boolean>>? {
+       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<WebDavProperties>? {
+       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<Boolean>("Mechyrdia.WebDav")
+val ApplicationCall.isWebDav: Boolean
+       get() = attributes.getOrNull(WebDavAttributeKey) == true
+
+val WebDavAuthAttributeKey = AttributeKey<Id<NationData>>("Mechyrdia.WebDavAuth")
+val ApplicationCall.webDavAuth: Id<NationData>
+       get() = attributes.getOrNull(WebDavAuthAttributeKey) ?: throw WebDavAuthRequired()
+
+class WebDavAuthRequired : RuntimeException()
+
+private val base64Decoder = Base64.getDecoder()
+
+fun ApplicationRequest.basicAuth(): Pair<String, String>? {
+       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) } }
+       }
+}