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 ->
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, _>()
post<Root.Utils.TylanLanguage, _>()
post<Root.Utils.PokhwalishLanguage, _>()
post<Root.Utils.PreviewComment, _>()
+
+ route("/webdav") { installWebDav() }
}
}
--- /dev/null
+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) } }
+ }
+}