From: Lanius Trolling Date: Tue, 9 Apr 2024 15:44:14 +0000 (-0400) Subject: Add VFS-management web UI X-Git-Url: https://gitweb.starshipfights.net/?a=commitdiff_plain;h=c43d9bd69bb72de3ef0900b8bcaf17ca54694283;p=factbooks Add VFS-management web UI --- diff --git a/build.gradle.kts b/build.gradle.kts index 91a43fc..c9f6d7f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -134,23 +134,23 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.6.3") - implementation("io.ktor:ktor-server-core-jvm:2.3.9") - implementation("io.ktor:ktor-server-cio-jvm:2.3.9") + implementation("io.ktor:ktor-server-core-jvm:2.3.10") + implementation("io.ktor:ktor-server-cio-jvm:2.3.10") - implementation("io.ktor:ktor-server-auto-head-response:2.3.9") - implementation("io.ktor:ktor-server-caching-headers:2.3.9") - implementation("io.ktor:ktor-server-call-id:2.3.9") - implementation("io.ktor:ktor-server-call-logging:2.3.9") - implementation("io.ktor:ktor-server-conditional-headers:2.3.9") - implementation("io.ktor:ktor-server-content-negotiation:2.3.9") - implementation("io.ktor:ktor-server-default-headers:2.3.9") - implementation("io.ktor:ktor-server-forwarded-header:2.3.9") - implementation("io.ktor:ktor-server-html-builder:2.3.9") - implementation("io.ktor:ktor-server-resources:2.3.9") - implementation("io.ktor:ktor-server-sessions-jvm:2.3.9") - implementation("io.ktor:ktor-server-status-pages:2.3.9") + implementation("io.ktor:ktor-server-auto-head-response:2.3.10") + implementation("io.ktor:ktor-server-caching-headers:2.3.10") + implementation("io.ktor:ktor-server-call-id:2.3.10") + implementation("io.ktor:ktor-server-call-logging:2.3.10") + implementation("io.ktor:ktor-server-conditional-headers:2.3.10") + implementation("io.ktor:ktor-server-content-negotiation:2.3.10") + implementation("io.ktor:ktor-server-default-headers:2.3.10") + implementation("io.ktor:ktor-server-forwarded-header:2.3.10") + implementation("io.ktor:ktor-server-html-builder:2.3.10") + implementation("io.ktor:ktor-server-resources:2.3.10") + implementation("io.ktor:ktor-server-sessions-jvm:2.3.10") + implementation("io.ktor:ktor-server-status-pages:2.3.10") - implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.9") + implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.10") implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0") @@ -279,9 +279,10 @@ tasks.register("migrateToGridFs", JavaExec::class) { group = "administration" val runShadow: JavaExec by tasks + val main by sourceSets javaLauncher.convention(runShadow.javaLauncher) - classpath = runShadow.classpath + classpath = main.runtimeClasspath mainClass.set("info.mechyrdia.data.MigrateFiles") setArgs(listOf("config", "gridfs")) } diff --git a/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt b/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt index c31a07e..9f02694 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt @@ -111,9 +111,21 @@ fun Application.factbooks() { } install(StatusPages) { + status(HttpStatusCode.BadRequest) { call, _ -> + call.respondHtml(HttpStatusCode.BadRequest, call.error400()) + } + status(HttpStatusCode.Forbidden) { call, _ -> + call.respondHtml(HttpStatusCode.Forbidden, call.error403()) + } status(HttpStatusCode.NotFound) { call, _ -> call.respondHtml(HttpStatusCode.NotFound, call.error404()) } + status(HttpStatusCode.Conflict) { call, _ -> + call.respondHtml(HttpStatusCode.Conflict, call.error409()) + } + status(HttpStatusCode.InternalServerError) { call, _ -> + call.respondHtml(HttpStatusCode.InternalServerError, call.error500()) + } exception { call, (url, permanent) -> call.respondRedirect(url, permanent) @@ -171,6 +183,16 @@ fun Application.factbooks() { get() post() post() + get() + get() + get() + post() + post() + get() + post() + post() + get() + post() post() post() post() diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/MigrateFiles.kt b/src/jvmMain/kotlin/info/mechyrdia/data/MigrateFiles.kt index bfd17d7..9cb6549 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/data/MigrateFiles.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/data/MigrateFiles.kt @@ -48,8 +48,10 @@ private suspend fun migrateDir(path: StoragePath, from: FileStorage, into: FileS if (!into.createDir(path)) return listOf("[Target Error] Directory at /$path cannot be created") + val inDir = from.listDir(path) ?: return listOf("[Source Error] Directory at /$path does not exist") + return coroutineScope { - from.listDir(path).map { entry -> + inDir.map { entry -> async { val entryPath = path / entry.name when (entry.type) { @@ -57,13 +59,16 @@ private suspend fun migrateDir(path: StoragePath, from: FileStorage, into: FileS StoredFileType.DIRECTORY -> migrateDir(entryPath, from, into) } } - }.toList().awaitAll().flatten() + }.awaitAll().flatten() } } private suspend fun migrateRoot(from: FileStorage, into: FileStorage): List { + val inRoot = from.listDir(StoragePath.Root) + ?: return listOf("[Source Error] Root directory does not exist") + return coroutineScope { - from.listDir(StoragePath.Root).map { entry -> + inRoot.map { entry -> async { val entryPath = StoragePath.Root / entry.name when (entry.type) { @@ -71,7 +76,7 @@ private suspend fun migrateRoot(from: FileStorage, into: FileStorage): List migrateDir(entryPath, from, into) } } - }.toList().awaitAll().flatten() + }.awaitAll().flatten() } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/comments.kt b/src/jvmMain/kotlin/info/mechyrdia/data/comments.kt index 5497a51..4cd6cb1 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/data/comments.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/data/comments.kt @@ -1,7 +1,6 @@ package info.mechyrdia.data -import com.mongodb.client.model.Filters -import com.mongodb.client.model.Sorts +import com.mongodb.client.model.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.toList import kotlinx.serialization.SerialName @@ -59,18 +58,26 @@ data class CommentReplyLink( Table.unique(CommentReplyLink::replyingPost, CommentReplyLink::originalPost) } - suspend fun updateComment(updatedReply: Id, repliesTo: Set>) { + suspend fun updateComment(updatedReply: Id, repliesTo: Set>, now: Instant) { Table.remove( Filters.and( Filters.nin(CommentReplyLink::originalPost.serialName, repliesTo), Filters.eq(CommentReplyLink::replyingPost.serialName, updatedReply) ) ) - Table.put( + + Table.insert( repliesTo.map { original -> - CommentReplyLink( - originalPost = original, - replyingPost = updatedReply + UpdateOneModel( + Filters.and( + Filters.eq(CommentReplyLink::originalPost.serialName, original), + Filters.eq(CommentReplyLink::replyingPost.serialName, updatedReply) + ), + Updates.combine( + Updates.set(CommentReplyLink::repliedAt.serialName, now), + Updates.setOnInsert(MONGODB_ID_KEY, Id()), + ), + UpdateOptions().upsert(true) ) } ) diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/data.kt b/src/jvmMain/kotlin/info/mechyrdia/data/data.kt index 0ff91bb..c82a623 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/data/data.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/data/data.kt @@ -136,7 +136,7 @@ class DocumentTable>(private val kClass: KClass) { } suspend fun set(id: Id, set: Bson): Boolean { - return collection().updateOne(Filters.eq(MONGODB_ID_KEY, id), set).matchedCount != 0L + return collection().updateOne(Filters.eq(MONGODB_ID_KEY, id), set).matchedCount > 0L } suspend fun get(id: Id): T? { @@ -151,6 +151,14 @@ class DocumentTable>(private val kClass: KClass) { return collection().find() } + suspend fun insert(docs: Collection>) { + if (docs.isNotEmpty()) + collection().bulkWrite( + if (docs is List) docs else docs.toList(), + BulkWriteOptions().ordered(false) + ) + } + suspend fun filter(where: Bson): Flow { return collection().find(where) } @@ -171,16 +179,16 @@ class DocumentTable>(private val kClass: KClass) { return collection().find(where).singleOrNull() } - suspend fun update(where: Bson, set: Bson) { - collection().updateMany(where, set) + suspend fun update(where: Bson, set: Bson): Long { + return collection().updateMany(where, set).matchedCount } suspend fun change(where: Bson, set: Bson) { collection().updateOne(where, set, UpdateOptions().upsert(true)) } - suspend fun remove(where: Bson) { - collection().deleteMany(where) + suspend fun remove(where: Bson): Long { + return collection().deleteMany(where).deletedCount } suspend fun aggregate(pipeline: List, resultClass: KClass): Flow { diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/data_files.kt b/src/jvmMain/kotlin/info/mechyrdia/data/data_files.kt index bcffa5e..48c35de 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/data/data_files.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/data/data_files.kt @@ -17,23 +17,29 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.reactive.asPublisher import kotlinx.coroutines.reactive.awaitFirst +import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.bson.types.ObjectId import java.io.ByteArrayOutputStream import java.io.File import java.nio.ByteBuffer +import java.nio.file.FileAlreadyExistsException import java.time.Instant import kotlin.String +import kotlin.time.Duration.Companion.hours suspend fun ApplicationCall.respondStoredFile(fileStorage: FileStorage, path: StoragePath) { val stat = fileStorage.statFile(path) ?: return respond(HttpStatusCode.NotFound) - attributes.put(StoragePathAttributeKey, path) - val type = ContentType.defaultForFileExtension(path.elements.last().substringAfterLast('.')) - respondBytesWriter(contentType = type, contentLength = stat.size) { - fileStorage.readFile(path, this) + val extension = path.elements.last().substringAfter('.', "") + val type = if (extension.isEmpty()) ContentType.Text.Plain else ContentType.defaultForFileExtension(extension) + val result = fileStorage.readFile(path) { producer -> + attributes.put(StoragePathAttributeKey, path) + respondBytesWriter(contentType = type, contentLength = stat.size, producer = producer) } + + if (!result) respond(HttpStatusCode.NotFound) } suspend fun ApplicationCall.respondStoredFile(path: StoragePath) { @@ -77,8 +83,8 @@ value class StoragePath(val elements: List) { } enum class StoredFileType { + DIRECTORY, FILE, - DIRECTORY; } data class StoredFileEntry(val name: String, val type: StoredFileType) @@ -95,17 +101,17 @@ interface FileStorage { suspend fun createDir(dir: StoragePath): Boolean - suspend fun listDir(dir: StoragePath): Flow + suspend fun listDir(dir: StoragePath): List? suspend fun deleteDir(dir: StoragePath): Boolean suspend fun statFile(path: StoragePath): StoredFileStats? - suspend fun writeFile(path: StoragePath, content: ByteReadChannel): Boolean + suspend fun writeFile(path: StoragePath, content: () -> ByteReadChannel): Boolean suspend fun writeFile(path: StoragePath, content: ByteArray): Boolean - suspend fun readFile(path: StoragePath, content: ByteWriteChannel): Boolean + suspend fun readFile(path: StoragePath, content: suspend (suspend ByteWriteChannel.() -> Unit) -> Unit): Boolean suspend fun readFile(path: StoragePath): ByteArray? @@ -119,6 +125,8 @@ interface FileStorage { lateinit var instance: FileStorage private set + private val maintenanceScope = CoroutineScope(SupervisorJob() + CoroutineName("file-storage-maintenance")) + suspend operator fun invoke(config: FileStorageConfig) = when (config) { is FileStorageConfig.Flat -> FlatFileStorage(File(config.baseDir)) FileStorageConfig.GridFs -> GridFsStorage( @@ -135,6 +143,16 @@ interface FileStorage { ConnectionHolder.getBucket() ) }.apply { prepare() } + + maintenanceScope.launch { + while (true) { + launch(SupervisorJob(currentCoroutineContext().job)) { + instance.performMaintenance() + } + + delay(8.hours) + } + } } fun initialize() = runBlocking { configure() } @@ -189,11 +207,12 @@ private class FlatFileStorage(val root: File) : FileStorage { return withContext(Dispatchers.IO) { createDir(resolveFile(dir)) } } - override suspend fun listDir(dir: StoragePath): Flow { - return withContext(Dispatchers.IO) { resolveFile(dir).listFiles()?.map { renderEntry(it) }.orEmpty() }.asFlow() + override suspend fun listDir(dir: StoragePath): List? { + return withContext(Dispatchers.IO) { resolveFile(dir).listFiles()?.map { renderEntry(it) } } } override suspend fun deleteDir(dir: StoragePath): Boolean { + if (dir.isRoot) return false val file = resolveFile(dir) if (!file.isDirectory) return true return withContext(Dispatchers.IO) { file.deleteRecursively() } @@ -206,12 +225,12 @@ private class FlatFileStorage(val root: File) : FileStorage { return StoredFileStats(Instant.ofEpochMilli(file.lastModified()), file.length()) } - override suspend fun writeFile(path: StoragePath, content: ByteReadChannel): Boolean { + override suspend fun writeFile(path: StoragePath, content: () -> ByteReadChannel): Boolean { val file = resolveFile(path) return withContext(Dispatchers.IO) { if (createFile(file)) { - file.writeChannel().use { content.copyTo(this) } + file.writeChannel().use { content().copyTo(this) } true } else false } @@ -228,11 +247,13 @@ private class FlatFileStorage(val root: File) : FileStorage { } } - override suspend fun readFile(path: StoragePath, content: ByteWriteChannel): Boolean { + override suspend fun readFile(path: StoragePath, content: suspend (suspend ByteWriteChannel.() -> Unit) -> Unit): Boolean { val file = resolveFile(path) if (!file.isFile) return false - file.readChannel().copyTo(content) + content { + file.readChannel().copyTo(this) + } return true } @@ -277,12 +298,18 @@ private data class GridFsEntry( ) : DataDocument private class GridFsStorage(val table: DocumentTable, val bucket: GridFSBucket) : FileStorage { - private suspend fun getExact(path: String) = table.locate(Filters.eq(GridFsEntry::path.serialName, path)) - private suspend fun updateExact(path: String, newFile: ObjectId) { + private fun toExactPath(path: StoragePath) = path.elements.joinToString(separator = "") { "/$it" } + private fun toPrefixPath(path: StoragePath) = "${toExactPath(path)}/" + + 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))) + private suspend fun deleteExact(path: StoragePath) = table.remove(Filters.eq(GridFsEntry::path.serialName, toExactPath(path))) > 0L + private suspend fun updateExact(path: StoragePath, newFile: ObjectId) { val now = Instant.now() + val exactPath = toExactPath(path) table.change( - Filters.eq(GridFsEntry::path.serialName, path), + Filters.eq(GridFsEntry::path.serialName, exactPath), Updates.combine( Updates.set(GridFsEntry::file.serialName, newFile), Updates.set(GridFsEntry::updated.serialName, now), @@ -292,93 +319,149 @@ private class GridFsStorage(val table: DocumentTable, val bucket: G ) } - private suspend fun getPrefix(path: String) = table.filter(Filters.regex(GridFsEntry::path.serialName, "^${Regex.fromLiteral(path)}")) - private suspend fun deletePrefix(path: String) = table.remove(Filters.regex(GridFsEntry::path.serialName, "^${Regex.fromLiteral(path)}")) + private suspend fun countPrefix(path: StoragePath) = table.number(Filters.regex(GridFsEntry::path.serialName, "^${Regex.fromLiteral(toPrefixPath(path))}")) + private suspend fun getPrefix(path: StoragePath) = table.filter(Filters.regex(GridFsEntry::path.serialName, "^${Regex.fromLiteral(toPrefixPath(path))}")) + private suspend fun deletePrefix(path: StoragePath) = table.remove(Filters.regex(GridFsEntry::path.serialName, "^${Regex.fromLiteral(toPrefixPath(path))}")) + private suspend fun createPrefix(path: StoragePath) { + val now = Instant.now() + val keepPath = path / GRID_FS_KEEP + + table.change( + Filters.eq(GridFsEntry::path.serialName, toExactPath(keepPath)), + Updates.combine( + Updates.setOnInsert(GridFsEntry::id.serialName, Id()), + Updates.setOnInsert(GridFsEntry::file.serialName, emptyFileId), + Updates.setOnInsert(GridFsEntry::created.serialName, now), + Updates.setOnInsert(GridFsEntry::updated.serialName, now), + ) + ) + } - private fun toExactPath(path: StoragePath) = path.elements.joinToString(separator = "") { "/$it" } - private fun toPrefixPath(path: StoragePath) = "${toExactPath(path)}/" + private suspend fun getSuffix(fullPath: StoragePath, forDir: Boolean = false) = try { + val pathParts = fullPath.elements + + coroutineScope { + val indices = (if (forDir) 0 else 1)..pathParts.lastIndex + + indices.map { index -> + val path = StoragePath(pathParts.dropLast(index)) + launch { + if (testExact(path)) throw FileAlreadyExistsException(path.toString()) + } + } + } + + null + } catch (ex: FileAlreadyExistsException) { + StoragePath(ex.file) + } + + private lateinit var emptyFileId: ObjectId + + private suspend fun getOrCreateEmptyFile(): ObjectId { + bucket + .find(Filters.and(Filters.eq("length", 0), Filters.eq("filename", GRID_FS_KEEP))) + .awaitFirstOrNull() + ?.objectId + ?.let { return it } + + val bytesPublisher = { ByteBuffer.allocate(0) } + .asFlow() + .asPublisher(CoroutineName("grid-fs-writer") + Dispatchers.IO) + + return bucket.uploadFromPublisher(GRID_FS_KEEP, bytesPublisher).awaitFirst() + } override suspend fun prepare() { table.unique(GridFsEntry::path) + emptyFileId = getOrCreateEmptyFile() } override suspend fun getType(path: StoragePath): StoredFileType? { - return if (getExact(toExactPath(path)) != null) + return if (getExact(path) != null) StoredFileType.FILE - else if (getPrefix(toPrefixPath(path)).count() > 0) + else if (countPrefix(path) > 0) StoredFileType.DIRECTORY else null } override suspend fun createDir(dir: StoragePath): Boolean { - return coroutineScope { - dir.elements.indices.map { index -> - async { - getExact(toExactPath(StoragePath(dir.elements.take(index)))) != null - } - }.awaitAll().none { it } - } + if (dir.isRoot) return true + if (getSuffix(dir, forDir = true) != null) return false + + createPrefix(dir) + return true } - override suspend fun listDir(dir: StoragePath): Flow { + override suspend fun listDir(dir: StoragePath): List? { val prefixPath = toPrefixPath(dir) - return getPrefix(prefixPath).map { + val allEntries = getPrefix(dir).map { val subPath = it.path.removePrefix(prefixPath) if (subPath.contains('/')) StoredFileEntry(subPath.substringBefore('/'), StoredFileType.DIRECTORY) else StoredFileEntry(subPath, StoredFileType.FILE) - }.distinctBy { it.name } + }.toList().distinctBy { it.name } + + if (allEntries.isEmpty()) + return null + + return allEntries.filter { it.name != GRID_FS_KEEP } } override suspend fun deleteDir(dir: StoragePath): Boolean { - deletePrefix(toPrefixPath(dir)) + if (dir.isRoot) return false + deletePrefix(dir) return true } override suspend fun statFile(path: StoragePath): StoredFileStats? { if (path.isRoot) return null - val file = getExact(toExactPath(path)) ?: return null + val file = getExact(path) ?: return null val gridFsFile = bucket.find(Filters.eq(MONGODB_ID_KEY, file.file)).awaitFirst() return StoredFileStats(file.updated, gridFsFile.length) } - override suspend fun writeFile(path: StoragePath, content: ByteReadChannel): Boolean { + override suspend fun writeFile(path: StoragePath, content: () -> ByteReadChannel): Boolean { if (path.isRoot) return false - if (getPrefix(toPrefixPath(path)).count() > 0) return false + if (getSuffix(path) != null) return false + if (countPrefix(path) > 0) return false val bytesPublisher = flow { - content.consumeEachBufferRange { buffer, last -> + content().consumeEachBufferRange { buffer, last -> emit(buffer.copy()) !last } }.asPublisher(CoroutineName("grid-fs-writer") + Dispatchers.IO) val newId = bucket.uploadFromPublisher(path.elements.last(), bytesPublisher).awaitFirst() - updateExact(toExactPath(path), newId) + updateExact(path, newId) return true } override suspend fun writeFile(path: StoragePath, content: ByteArray): Boolean { if (path.isRoot) return false - if (getPrefix(toPrefixPath(path)).count() > 0) return false + if (getSuffix(path) != null) return false + if (countPrefix(path) > 0) return false val bytesPublisher = flow { emit(ByteBuffer.wrap(content)) }.asPublisher(CoroutineName("grid-fs-writer") + Dispatchers.IO) val newId = bucket.uploadFromPublisher(path.elements.last(), bytesPublisher).awaitFirst() - updateExact(toExactPath(path), newId) + updateExact(path, newId) return true } - override suspend fun readFile(path: StoragePath, content: ByteWriteChannel): Boolean { + override suspend fun readFile(path: StoragePath, content: suspend (suspend ByteWriteChannel.() -> Unit) -> Unit): Boolean { if (path.isRoot) return false - val file = getExact(toExactPath(path)) ?: return false + val file = getExact(path) ?: return false val gridFsId = file.file - bucket.downloadToPublisher(gridFsId).asFlow().collect { buffer -> - content.writeFully(buffer) + content { + bucket.downloadToPublisher(gridFsId).asFlow().collect { buffer -> + writeFully(buffer) + } } return true @@ -386,7 +469,7 @@ private class GridFsStorage(val table: DocumentTable, val bucket: G override suspend fun readFile(path: StoragePath): ByteArray? { if (path.isRoot) return null - val file = getExact(toExactPath(path)) ?: return null + val file = getExact(path) ?: return null val gridFsId = file.file return ByteArrayOutputStream().also { content -> @@ -399,22 +482,26 @@ private class GridFsStorage(val table: DocumentTable, val bucket: G override suspend fun copyFile(source: StoragePath, target: StoragePath): Boolean { if (source.isRoot || target.isRoot) return false - val sourceFile = getExact(toExactPath(source)) ?: return false - updateExact(toExactPath(target), sourceFile.file) + if (getSuffix(target) != null) return false + val sourceFile = getExact(source) ?: return false + updateExact(target, sourceFile.file) return true } override suspend fun eraseFile(path: StoragePath): Boolean { if (path.isRoot) return false - val file = getExact(toExactPath(path)) ?: return false - bucket.delete(file.file).awaitFirst() - table.del(file.id) - return true + return deleteExact(path) } override suspend fun performMaintenance() { val allUsedIds = table.all().map { it.file }.toSet() - val unusedFiles = bucket.find(Filters.nin(MONGODB_ID_KEY, allUsedIds)).asFlow().map { it.objectId }.toSet() + val unusedFiles = bucket.find( + Filters.and( + Filters.nin(MONGODB_ID_KEY, allUsedIds), + Filters.ne("filename", GRID_FS_KEEP) + ) + ).asFlow().map { it.objectId }.toSet() + coroutineScope { unusedFiles.map { unusedFile -> launch { @@ -423,4 +510,8 @@ private class GridFsStorage(val table: DocumentTable, val bucket: G }.joinAll() } } + + companion object { + private const val GRID_FS_KEEP = ".grid-fs-keep" + } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt b/src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt index 257cab9..86e88f7 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt @@ -69,11 +69,12 @@ suspend fun ApplicationCall.newCommentRoute(pagePathParts: List, content if (contents.isBlank()) redirectHref(Root.LorePage(pagePathParts, root = Root(error = "Comments may not be blank"))) + val now = Instant.now() val comment = Comment( id = Id(), submittedBy = loggedInAs.id, submittedIn = pagePathParts.joinToString("/"), - submittedAt = Instant.now(), + submittedAt = now, numEdits = 0, lastEdit = null, @@ -82,7 +83,7 @@ suspend fun ApplicationCall.newCommentRoute(pagePathParts: List, content ) Comment.Table.put(comment) - CommentReplyLink.updateComment(comment.id, getReplies(contents)) + CommentReplyLink.updateComment(comment.id, getReplies(contents), now) redirectHref(Root.LorePage(pagePathParts), hash = "comment-${comment.id}") } @@ -116,14 +117,15 @@ suspend fun ApplicationCall.editCommentRoute(commentId: Id, newContents if (newContents == oldComment.contents) redirectHref(Root.Comments.ViewPage(oldComment.id)) + val now = Instant.now() val newComment = oldComment.copy( numEdits = oldComment.numEdits + 1, - lastEdit = Instant.now(), + lastEdit = now, contents = newContents ) Comment.Table.put(newComment) - CommentReplyLink.updateComment(commentId, getReplies(newContents)) + CommentReplyLink.updateComment(commentId, getReplies(newContents), now) redirectHref(Root.Comments.ViewPage(oldComment.id)) } @@ -143,7 +145,7 @@ suspend fun ApplicationCall.deleteCommentPage(commentId: Id): HTML.() - val commentDisplay = CommentRenderData(listOf(comment), nationCache).single() - return page("Confirm Deletion of Commment", standardNavBar()) { + return page("Confirm Deletion of Comment", standardNavBar()) { section { p { +"Are you sure you want to delete this comment? " diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/views_files.kt b/src/jvmMain/kotlin/info/mechyrdia/data/views_files.kt new file mode 100644 index 0000000..bca1901 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/data/views_files.kt @@ -0,0 +1,328 @@ +package info.mechyrdia.data + +import info.mechyrdia.auth.PageDoNotCacheAttributeKey +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.http.* +import io.ktor.http.content.* +import io.ktor.server.application.* +import io.ktor.server.html.* +import io.ktor.server.plugins.* +import io.ktor.server.response.* +import io.ktor.utils.io.jvm.javaio.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.html.* + +private sealed class TreeNode { + data class FileNode(val stats: StoredFileStats) : TreeNode() + data class DirNode(val children: Map) : TreeNode() +} + +private suspend fun fileTree(path: StoragePath): TreeNode? { + return FileStorage.instance.statFile(path)?.let { + TreeNode.FileNode(it) + } ?: coroutineScope { + FileStorage.instance.listDir(path)?.map { entry -> + async { + fileTree(path / entry.name)?.let { entry.name to it } + } + }?.awaitAll() + ?.filterNotNull() + ?.toMap() + ?.let { TreeNode.DirNode(it) } + } +} + +context(ApplicationCall) +private fun UL.render(path: StoragePath, childNodes: Map) { + val sortedChildren = childNodes.toList().sortedBy { it.first }.sortedBy { + when (it.second) { + is TreeNode.FileNode -> 1 + is TreeNode.DirNode -> 0 + } + } + + for ((name, child) in sortedChildren) + render(path / name, child) + + li { + form(action = href(Root.Admin.Vfs.Upload(path.elements)), method = FormMethod.post, encType = FormEncType.multipartFormData) { + installCsrfToken() + label { + fileInput(name = "uploaded") + +"Upload File" + } + submitInput() + } + } + + li { + form(action = href(Root.Admin.Vfs.MkDir(path.elements)), method = FormMethod.post) { + installCsrfToken() + textInput { + placeholder = "new-dir" + } + +Entities.nbsp + submitInput { + value = "Make Directory" + } + } + } + + if (!path.isRoot) + li { + form(action = href(Root.Admin.Vfs.RmDirConfirmPage(path.elements)), method = FormMethod.get) { + submitInput(classes = "evil") { + value = "Delete (Recursive)" + } + } + } +} + +context(ApplicationCall) +private fun UL.render(path: StoragePath, node: TreeNode) { + when (node) { + is TreeNode.FileNode -> li { + a(href = href(Root.Admin.Vfs.View(path.elements))) { + +path.elements.last() + } + } + + is TreeNode.DirNode -> li { + a(href = href(Root.Admin.Vfs.View(path.elements))) { + +path.elements.last() + } + ul { + render(path, node.children) + } + } + } +} + +suspend fun ApplicationCall.adminViewVfs(path: StoragePath): HTML.() -> Unit { + val tree = fileTree(path)!! + + return adminPage("VFS - /$path") { + main { + h1 { +"/$path" } + + when (tree) { + is TreeNode.FileNode -> table { + tr { + th { + colSpan = "2" + +"/$path" + } + } + tr { + td { + colSpan = "2" + iframe { + src = href(Root.Admin.Vfs.Inline(path.elements)) + } + } + } + tr { + th { +"Last updated" } + td { dateTime(tree.stats.updated) } + } + tr { + th { +"Size (bytes)" } + td { +"${tree.stats.size}" } + } + tr { + th { +"Actions" } + td { + ul { + li { + a(href = href(Root.Admin.Vfs.Download(path.elements))) { + +"Download" + } + } + li { + form(action = href(Root.Admin.Vfs.Overwrite(path.elements)), method = FormMethod.post, encType = FormEncType.multipartFormData) { + installCsrfToken() + label { + fileInput(name = "uploaded") + +"Upload New Version" + } + submitInput() + } + } + li { + a(href = href(Root.Admin.Vfs.DeleteConfirmPage(path.elements))) { + +"Delete" + } + } + } + } + } + tr { + th { +"Navigate" } + td { + ul { + path.elements.indices.forEach { index -> + val parent = path.elements.take(index) + li { + a(href = href(Root.Admin.Vfs.View(parent))) { + +"/${StoragePath(parent)}" + } + } + } + } + } + } + } + + is TreeNode.DirNode -> ul { + if (!path.isRoot) + li { + a(href = href(Root.Admin.Vfs.View(path.elements.dropLast(1)))) { + +".." + } + } + + render(path, tree.children) + } + } + } + } +} + +private val textExtensions = listOf( + "", + "groovy", + "html", + "js.map", + "mtl", + "obj", + "old", + "tpl", + "wip", +) + +suspend fun ApplicationCall.adminPreviewFile(path: StoragePath) { + attributes.put(PageDoNotCacheAttributeKey, true) + val stat = FileStorage.instance.statFile(path) ?: return respond(HttpStatusCode.NotFound) + + val extension = path.elements.last().substringAfter('.', "") + val type = if (extension in textExtensions) ContentType.Text.Plain else ContentType.defaultForFileExtension(extension) + val result = FileStorage.instance.readFile(path) { producer -> + respondBytesWriter(contentType = type, contentLength = stat.size, producer = producer) + } + + if (!result) respond(HttpStatusCode.NotFound) +} + +suspend fun ApplicationCall.adminUploadFile(path: StoragePath, part: PartData.FileItem) { + val name = part.originalFileName ?: throw MissingRequestParameterException("originalFileName") + val filePath = path / name + + if (FileStorage.instance.writeFile(filePath) { part.streamProvider().toByteReadChannel(Dispatchers.IO) }) + redirectHref(Root.Admin.Vfs.View(filePath.elements)) + else + respond(HttpStatusCode.Conflict) +} + +suspend fun ApplicationCall.adminOverwriteFile(path: StoragePath, part: PartData.FileItem) { + if (FileStorage.instance.writeFile(path) { part.streamProvider().toByteReadChannel(Dispatchers.IO) }) + redirectHref(Root.Admin.Vfs.View(path.elements)) + else + respond(HttpStatusCode.Conflict) +} + +suspend fun ApplicationCall.adminConfirmDeleteFile(path: StoragePath) { + val stats = FileStorage.instance.statFile(path) + if (stats == null) + respond(HttpStatusCode.Conflict) + else + respondHtml(block = adminPage("Confirm Deletion of /$path") { + main { + p { + +"Are you sure you want to delete the file at /$path? " + strong { +"It will be gone forever!" } + } + table { + tr { + th { +"Last Updated" } + td { dateTime(stats.updated) } + } + tr { + th { +"Size (bytes)" } + td { +"${stats.size}" } + } + } + + form(method = FormMethod.get, action = href(Root.Admin.Vfs.View(path.elements))) { + submitInput { value = "No, take me back" } + } + +Entities.nbsp + form(method = FormMethod.post, action = href(Root.Admin.Vfs.DeleteConfirmPost(path.elements))) { + installCsrfToken() + submitInput(classes = "evil") { value = "Yes, delete it" } + } + } + }) +} + +suspend fun ApplicationCall.adminDeleteFile(path: StoragePath) { + if (FileStorage.instance.eraseFile(path)) + redirectHref(Root.Admin.Vfs.View(path.elements.dropLast(1))) + else + respond(HttpStatusCode.Conflict) +} + +suspend fun ApplicationCall.adminMakeDirectory(path: StoragePath, name: String) { + val dirPath = path / name + + if (FileStorage.instance.createDir(dirPath)) + redirectHref(Root.Admin.Vfs.View(dirPath.elements)) + else + respond(HttpStatusCode.Conflict) +} + +suspend fun ApplicationCall.adminConfirmRemoveDirectory(path: StoragePath) { + val entries = FileStorage.instance.listDir(path)?.sortedBy { it.name }?.sortedBy { it.type } + if (entries == null) + respond(HttpStatusCode.Conflict) + else + respondHtml(block = adminPage("Confirm Deletion of /$path") { + main { + p { + +"Are you sure you want to delete the directory at /$path? " + strong { +"It, and all of its contents, will be gone forever!" } + } + ul { + for (entry in entries) + li { + +entry.name + if (entry.type == StoredFileType.DIRECTORY) + +"/" + } + } + + form(method = FormMethod.get, action = href(Root.Admin.Vfs.View(path.elements))) { + submitInput { value = "No, take me back" } + } + +Entities.nbsp + form(method = FormMethod.post, action = href(Root.Admin.Vfs.RmDirConfirmPost(path.elements))) { + installCsrfToken() + submitInput(classes = "evil") { value = "Yes, delete it" } + } + } + }) +} + +suspend fun ApplicationCall.adminRemoveDirectory(path: StoragePath) { + if (FileStorage.instance.deleteDir(path)) + redirectHref(Root.Admin.Vfs.View(path.elements.dropLast(1))) + else + respond(HttpStatusCode.Conflict) +} diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/article_listing.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/article_listing.kt index 5dc7ab2..18f5808 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/article_listing.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/article_listing.kt @@ -24,10 +24,10 @@ suspend fun StoragePath.toArticleNode(): ArticleNode = ArticleNode( name, coroutineScope { val path = this@toArticleNode - FileStorage.instance.listDir(path).map { + FileStorage.instance.listDir(path)?.map { val subPath = path / it.name async { subPath.toArticleNode() } - }.toList().awaitAll() + }?.awaitAll().orEmpty() }.sortedBy { it.name }.sortedBy { it.subNodes.isEmpty() } ) diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/asset_hashing.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/asset_hashing.kt index 40d7579..eab3a87 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/asset_hashing.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/asset_hashing.kt @@ -54,10 +54,12 @@ private class DigestingOutputStream(stomach: MessageDigest) : OutputStream() { private class FileHashCache(val hashAlgo: String) : FileDependentCache() { private val hashinator: ThreadLocal = ThreadLocal.withInitial { MessageDigest.getInstance(hashAlgo) } - override suspend fun processFile(path: StoragePath): ByteArray { + override suspend fun processFile(path: StoragePath): ByteArray? { + val fileContents = FileStorage.instance.readFile(path) ?: return null + return withContext(Dispatchers.IO) { DigestingOutputStream(hashinator.get()).useAndGet { oStream -> - oStream.write(FileStorage.instance.readFile(path) ?: ByteArray(0)) + oStream.write(fileContents) } } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess.kt index 1b35be9..db892fd 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess.kt @@ -1,6 +1,7 @@ package info.mechyrdia.lore import info.mechyrdia.JsonStorageCodec +import info.mechyrdia.data.StoragePath import io.ktor.server.application.* import io.ktor.server.request.* import kotlinx.coroutines.async @@ -9,11 +10,11 @@ import kotlinx.coroutines.coroutineScope import java.time.Instant import kotlin.math.roundToInt -class PreProcessingContext private constructor( +class PreProcessorContext private constructor( val variables: MutableMap, - val parent: PreProcessingContext? = null, + val parent: PreProcessorContext? = null, ) { - constructor(parent: PreProcessingContext? = null, vararg variables: Pair) : this(mutableMapOf(*variables), parent) + constructor(parent: PreProcessorContext? = null, vararg variables: Pair) : this(mutableMapOf(*variables), parent) operator fun get(name: String): ParserTree = variables[name] ?: parent?.get(name) ?: formatErrorToParserTree("Unable to resolve variable $name") @@ -37,29 +38,32 @@ class PreProcessingContext private constructor( operator fun contains(name: String): Boolean = name in variables || (parent?.contains(name) == true) - operator fun plus(other: Map) = PreProcessingContext(other.toMutableMap(), this) + operator fun plus(other: Map) = PreProcessorContext(other.toMutableMap(), this) fun toMap(): Map = parent?.toMap().orEmpty() + variables companion object { - operator fun invoke(variables: Map, parent: PreProcessingContext? = null) = PreProcessingContext(variables.toMutableMap(), parent) + operator fun invoke(variables: Map, parent: PreProcessorContext? = null) = PreProcessorContext(variables.toMutableMap(), parent) const val PAGE_PATH_KEY = "PAGE_PATH" const val INSTANT_NOW_KEY = "INSTANT_NOW" context(ApplicationCall) - fun defaults() = PreProcessingContext( - null, - PAGE_PATH_KEY to request.path().removePrefix("/").removePrefix("lore").textToTree(), + fun defaults() = defaults(StoragePath(request.path())) + + fun defaults(lorePath: StoragePath) = defaults(lorePath.elements.drop(1)) + + fun defaults(lorePath: List) = mapOf( + PAGE_PATH_KEY to "/${lorePath.joinToString(separator = "/")}".textToTree(), INSTANT_NOW_KEY to Instant.now().toEpochMilli().toString().textToTree(), ) } } -typealias PreProcessingSubject = ParserTree +typealias PreProcessorSubject = ParserTree -object PreProcessorUtils : AsyncLexerTagFallback, AsyncLexerTextProcessor, AsyncLexerLineBreakProcessor, AsyncLexerCombiner { - override suspend fun processInvalidTag(env: AsyncLexerTagEnvironment, tag: String, param: String?, subNodes: ParserTree): PreProcessingSubject { +object PreProcessorUtils : AsyncLexerTagFallback, AsyncLexerTextProcessor, AsyncLexerLineBreakProcessor, AsyncLexerCombiner { + override suspend fun processInvalidTag(env: AsyncLexerTagEnvironment, tag: String, param: String?, subNodes: ParserTree): PreProcessorSubject { return listOf( ParserTreeNode.Tag( tag = tag, @@ -69,23 +73,23 @@ object PreProcessorUtils : AsyncLexerTagFallback, text: String): PreProcessingSubject { + override suspend fun processText(env: AsyncLexerTagEnvironment, text: String): PreProcessorSubject { return text.textToTree() } - override suspend fun processLineBreak(env: AsyncLexerTagEnvironment): PreProcessingSubject { + override suspend fun processLineBreak(env: AsyncLexerTagEnvironment): PreProcessorSubject { return listOf(ParserTreeNode.LineBreak) } - override suspend fun combine(env: AsyncLexerTagEnvironment, subjects: List): PreProcessingSubject { + override suspend fun combine(env: AsyncLexerTagEnvironment, subjects: List): PreProcessorSubject { return subjects.flatten() } - fun withContext(env: AsyncLexerTagEnvironment, newContext: PreProcessingContext): AsyncLexerTagEnvironment { + fun withContext(env: AsyncLexerTagEnvironment, newContext: PreProcessorContext): AsyncLexerTagEnvironment { return env.copy(context = newContext) } - suspend fun processWithContext(env: AsyncLexerTagEnvironment, newContext: PreProcessingContext, input: ParserTree): ParserTree { + suspend fun processWithContext(env: AsyncLexerTagEnvironment, newContext: PreProcessorContext, input: ParserTree): ParserTree { return withContext(env, newContext).processTree(input) } @@ -108,7 +112,7 @@ object PreProcessorUtils : AsyncLexerTagFallback +fun interface PreProcessorLexerTag : AsyncLexerTagProcessor inline fun T?.requireParam(tag: String, block: (T) -> ParserTree): ParserTree { return if (this == null) @@ -135,7 +139,7 @@ fun ParserTree.isNull() = all { it.isWhitespace() || (it is ParserTreeNode.Tag & fun String.textToTree(): ParserTree = listOf(ParserTreeNode.Text(this)) fun interface PreProcessorFunction { - suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree + suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree } interface PreProcessorFunctionProvider : PreProcessorLexerTag { @@ -143,10 +147,10 @@ interface PreProcessorFunctionProvider : PreProcessorLexerTag { suspend fun provideFunction(param: String?): PreProcessorFunction? - override suspend fun processTag(env: AsyncLexerTagEnvironment, param: String?, subNodes: ParserTree): PreProcessingSubject { + override suspend fun processTag(env: AsyncLexerTagEnvironment, param: String?, subNodes: ParserTree): PreProcessorSubject { return param?.let { provideFunction(it) }.requireParam(tagName) { val args = subNodes.asPreProcessorMap().mapValuesSuspend { _, value -> env.processTree(value) } - val ctx = PreProcessingContext(args, env.context) + val ctx = PreProcessorContext(args, env.context) val func = provideFunction(param) ?: return emptyList() func.execute(PreProcessorUtils.withContext(env, ctx)) @@ -168,7 +172,7 @@ abstract class PreProcessorFunctionLibrary(override val tagName: String) : PrePr @JvmInline value class PreProcessorVariableFunction(private val variable: String) : PreProcessorFunction { - override suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree { + override suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree { return env.processTree(env.context[variable]) } } @@ -183,7 +187,7 @@ object PreProcessorVariableInvoker : PreProcessorFunctionProvider { @JvmInline value class PreProcessorScopeFilter(private val variable: String) : PreProcessorFilter { - override suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment): ParserTree { + override suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment): ParserTree { return env.copy(context = env.context + env.context[variable].asPreProcessorMap()).processTree(input) } } @@ -197,7 +201,7 @@ object PreProcessorScopeInvoker : PreProcessorFilterProvider { } fun interface PreProcessorFilter { - suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment): ParserTree + suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment): ParserTree } interface PreProcessorFilterProvider : PreProcessorLexerTag { @@ -205,7 +209,7 @@ interface PreProcessorFilterProvider : PreProcessorLexerTag { suspend fun provideFilter(param: String?): PreProcessorFilter? - override suspend fun processTag(env: AsyncLexerTagEnvironment, param: String?, subNodes: ParserTree): PreProcessingSubject { + override suspend fun processTag(env: AsyncLexerTagEnvironment, param: String?, subNodes: ParserTree): PreProcessorSubject { return param?.let { provideFilter(it) }.requireParam(tagName) { val filter = provideFilter(param) ?: return emptyList() filter.execute(subNodes, env) @@ -451,9 +455,9 @@ enum class PreProcessorTags(val type: PreProcessorLexerTag) { } } -suspend fun ParserTree.preProcess(context: Map): ParserTree { +suspend fun ParserTree.preProcess(context: PreProcessorContext): ParserTree { return AsyncLexerTagEnvironment( - PreProcessingContext(context, null), + context, PreProcessorTags.asTags, PreProcessorUtils, PreProcessorUtils, diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_include.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_include.kt index e745e69..ee15e45 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_include.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_include.kt @@ -26,10 +26,10 @@ object PreProcessorTemplateLoader { } suspend fun runTemplateWith(name: String, args: Map): ParserTree { - return loadTemplate(name).preProcess(args) + return loadTemplate(name).preProcess(PreProcessorContext(args)) } - suspend fun runTemplateHere(name: String, env: AsyncLexerTagEnvironment): ParserTree { + suspend fun runTemplateHere(name: String, env: AsyncLexerTagEnvironment): ParserTree { return env.processTree(loadTemplate(name)) } } @@ -76,7 +76,7 @@ object PreProcessorScriptLoader { else -> throw ClassCastException("Expected null, String, Number, Boolean, List, Set, or Map for converted data, got $data of type ${data::class.qualifiedName}") } - suspend fun runScriptInternal(script: CompiledScript, bind: Map, env: AsyncLexerTagEnvironment): Any? { + suspend fun runScriptInternal(script: CompiledScript, bind: Map, env: AsyncLexerTagEnvironment): Any? { return suspendCancellableCoroutine { continuation -> val bindings = SimpleBindings() bindings.putAll(bind) @@ -88,7 +88,7 @@ object PreProcessorScriptLoader { } } - private suspend fun runScriptWithBindings(scriptName: String, bind: Map, env: AsyncLexerTagEnvironment, errorHandler: (Exception) -> ParserTree): ParserTree { + private suspend fun runScriptWithBindings(scriptName: String, bind: Map, env: AsyncLexerTagEnvironment, errorHandler: (Exception) -> ParserTree): ParserTree { return try { val script = loadFunction(scriptName)!! val result = runScriptInternal(script, bind, env) @@ -101,12 +101,12 @@ object PreProcessorScriptLoader { } } - suspend fun runScriptSafe(scriptName: String, args: Map, env: AsyncLexerTagEnvironment, errorHandler: (Exception) -> ParserTree): ParserTree { + suspend fun runScriptSafe(scriptName: String, args: Map, env: AsyncLexerTagEnvironment, errorHandler: (Exception) -> ParserTree): ParserTree { val groovyArgs = args.mapValuesTo(mutableMapOf()) { (_, it) -> jsonToGroovy(it.toPreProcessJson()) } return runScriptWithBindings(scriptName, mapOf("args" to groovyArgs), env, errorHandler) } - suspend fun runScriptSafe(scriptName: String, input: ParserTree, env: AsyncLexerTagEnvironment, errorHandler: (Exception) -> ParserTree): ParserTree { + suspend fun runScriptSafe(scriptName: String, input: ParserTree, env: AsyncLexerTagEnvironment, errorHandler: (Exception) -> ParserTree): ParserTree { return runScriptWithBindings(scriptName, mapOf("text" to input.unparse()), env, errorHandler) } } @@ -115,7 +115,7 @@ fun interface PreProcessorScriptVarContext { operator fun get(name: String): Any? } -class PreProcessorScriptStdlib(private val env: AsyncLexerTagEnvironment, private val context: CoroutineContext, private val onError: (Throwable) -> Unit) { +class PreProcessorScriptStdlib(private val env: AsyncLexerTagEnvironment, private val context: CoroutineContext, private val onError: (Throwable) -> Unit) { fun jsonStringify(data: Any?): String { return PreProcessorScriptLoader.groovyToJson(data).toString() } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_json.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_json.kt index ea0890a..d688147 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_json.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_json.kt @@ -75,8 +75,9 @@ object FactbookLoader { } suspend fun loadFactbook(lorePath: List): ParserTree? { - val bytes = FileStorage.instance.readFile(StoragePath.articleDir / lorePath) ?: return null + val filePath = StoragePath.articleDir / lorePath + val bytes = FileStorage.instance.readFile(filePath) ?: return null val inputTree = ParserState.parseText(String(bytes)) - return inputTree.preProcess(loadFactbookContext(lorePath)) + return inputTree.preProcess(PreProcessorContext(loadFactbookContext(lorePath) + PreProcessorContext.defaults(lorePath))) } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_math.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_math.kt index c7b8c73..afc0f3c 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_math.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_math.kt @@ -59,7 +59,7 @@ object PreProcessorMathOperators : PreProcessorFunctionLibrary("math") { } fun interface PreProcessorMathUnaryOperator : PreProcessorFunction { - override suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree { + override suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree { val input = env.processTree(env.context["in"]) return input.treeToNumberOrNull(String::toDoubleOrNull) @@ -72,7 +72,7 @@ fun interface PreProcessorMathUnaryOperator : PreProcessorFunction { } fun interface PreProcessorMathBinaryOperator : PreProcessorFunction { - override suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree { + override suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree { val leftValue = env.processTree(env.context["left"]) val rightValue = env.processTree(env.context["right"]) @@ -89,7 +89,7 @@ fun interface PreProcessorMathBinaryOperator : PreProcessorFunction { } fun interface PreProcessorMathVariadicOperator : PreProcessorFunction { - override suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree { + override suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree { val argsList = env.processTree(env.context["in"]) val args = argsList.asPreProcessorList().mapNotNull { it.treeToNumberOrNull(String::toDoubleOrNull) } @@ -103,7 +103,7 @@ fun interface PreProcessorMathVariadicOperator : PreProcessorFunction { } fun interface PreProcessorMathPredicate : PreProcessorFunction { - override suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree { + override suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree { val leftValue = env.processTree(env.context["left"]) val rightValue = env.processTree(env.context["right"]) @@ -120,7 +120,7 @@ fun interface PreProcessorMathPredicate : PreProcessorFunction { } fun interface PreProcessorLogicBinaryOperator : PreProcessorFunction { - override suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree { + override suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree { val leftValue = env.processTree(env.context["left"]) val rightValue = env.processTree(env.context["right"]) @@ -137,7 +137,7 @@ fun interface PreProcessorLogicBinaryOperator : PreProcessorFunction { } fun interface PreProcessorLogicOperator : PreProcessorFunction { - override suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree { + override suspend fun execute(env: AsyncLexerTagEnvironment): ParserTree { val argsList = env.processTree(env.context["in"]) val args = argsList.asPreProcessorList().mapNotNull { it.treeToBooleanOrNull() } @@ -186,7 +186,7 @@ fun interface PreProcessorLogicOperator : PreProcessorFunction { } fun interface PreProcessorFormatter : PreProcessorFilter { - override suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment): ParserTree { + override suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment): ParserTree { return calculate(input.treeToText()) } @@ -211,7 +211,7 @@ fun interface PreProcessorFormatter : PreProcessorFilter { } fun interface PreProcessorInputTest : PreProcessorFilter { - override suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment): ParserTree { + override suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment): ParserTree { return calculate(input).booleanToTree() } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt index fc706cf..61d7479 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt @@ -1,6 +1,7 @@ package info.mechyrdia.lore import info.mechyrdia.JsonFileCodec +import info.mechyrdia.OwnerNationId import info.mechyrdia.data.FileStorage import info.mechyrdia.data.StoragePath import info.mechyrdia.data.currentNation @@ -53,7 +54,12 @@ suspend fun ApplicationCall.standardNavBar(path: List? = null) = listOf( NavHead("Useful Links"), NavLink(href(Root.Comments.HelpPage()), "Commenting Help"), NavLink(href(Root.Comments.RecentPage()), "Recent Comments"), -) + loadExternalLinks() +) + loadExternalLinks() + (if (currentNation()?.id == OwnerNationId) + listOf( + NavHead("Administration"), + NavLink(href(Root.Admin.Vfs.View(emptyList())), "View VFS"), + ) +else emptyList()) sealed class NavItem { protected abstract fun DIV.display() diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt index 3797586..3618db1 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt @@ -8,7 +8,7 @@ import io.ktor.util.* import kotlinx.html.* import java.time.Instant -val preloadFonts = listOf( +private val preloadFonts = listOf( "DejaVuSans-Bold.woff", "DejaVuSans-BoldOblique.woff", "DejaVuSans-Oblique.woff", @@ -27,7 +27,7 @@ val preloadFonts = listOf( "kishari-language-alphabet.woff", ) -val preloadImages = listOf( +private val preloadImages = listOf( "external-link-dark.png", "external-link.png", "icon.png", @@ -184,6 +184,39 @@ fun ApplicationCall.rawPage(pageTitle: String, ogData: OpenGraphData? = null, co } } +private val adminPreloadFonts = listOf( + "JetBrainsMono-ExtraBold.woff", + "JetBrainsMono-ExtraBoldItalic.woff", + "JetBrainsMono-Medium.woff", + "JetBrainsMono-MediumItalic.woff", +) + +fun ApplicationCall.adminPage(pageTitle: String, content: BODY.() -> Unit): HTML.() -> Unit { + return { + lang = "en" + + head { + initialHead(pageTitle, null) + + for (font in adminPreloadFonts) + link( + rel = "preload", + href = "/static/font/$font", + type = "font/woff" + ) { + attributes["as"] = "font" + } + + link(rel = "stylesheet", type = "text/css", href = "/static/admin.css") + + script(src = "/static/admin.js") {} + } + body { + content() + } + } +} + fun FlowOrPhrasingContent.dateTime(instant: Instant) { span(classes = "moment") { style = "display:none" diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/views_error.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/views_error.kt index 728d770..cb7a837 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/views_error.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/views_error.kt @@ -11,28 +11,34 @@ import kotlinx.html.* suspend fun ApplicationCall.errorPage(title: String, body: FlowContent.() -> Unit): HTML.() -> Unit { return if (request.queryParameters["format"] == "raw") rawPage(title) { + h1 { +title } body() } + else if (request.uri.startsWith("/admin/vfs")) + adminPage(title) { + div(classes = "message") { + h1 { +title } + body() + } + } else page(title, standardNavBar()) { section { + h1 { +title } body() } } } suspend fun ApplicationCall.error400(): HTML.() -> Unit = errorPage("400 Bad Request") { - h1 { +"400 Bad Request" } p { +"The request your browser sent was improperly formatted." } } suspend fun ApplicationCall.error403(): HTML.() -> Unit = errorPage("403 Forbidden") { - h1 { +"403 Forbidden" } p { +"You are not allowed to do that." } } suspend fun ApplicationCall.error403PageExpired(payload: CsrfProtectedResourcePayload): HTML.() -> Unit = errorPage("Page Expired") { - h1 { +"Page Expired" } with(payload) { displayRetryData() } p { +"The page you were on has expired." @@ -45,7 +51,6 @@ suspend fun ApplicationCall.error403PageExpired(payload: CsrfProtectedResourcePa } suspend fun ApplicationCall.error404(): HTML.() -> Unit = errorPage("404 Not Found") { - h1 { +"404 Not Found" } p { +"Unfortunately, we could not find what you were looking for. Would you like to " a(href = href(Root())) { +"return to the index page" } @@ -53,7 +58,17 @@ suspend fun ApplicationCall.error404(): HTML.() -> Unit = errorPage("404 Not Fou } } +suspend fun ApplicationCall.error409(): HTML.() -> Unit = errorPage("409 Conflict") { + p { + +"Your attempted action conflicts with an existing resource." + request.header(HttpHeaders.Referrer)?.let { referrer -> + +" You can " + a(href = referrer) { +"return to the previous page" } + +" and retry your action." + } + } +} + suspend fun ApplicationCall.error500(): HTML.() -> Unit = errorPage("500 Internal Error") { - h1 { +"500 Internal Error" } p { +"The servers made a bit of a mistake. Please be patient while we clean up our mess." } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt index d82246e..1a53259 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt @@ -62,7 +62,8 @@ suspend fun Appendable.generateRecentPageEdits() { items = coroutineScope { pages.map { page -> async { - val pageMarkup = FactbookLoader.loadFactbook(page.path.elements.drop(1)) ?: return@async null + val pageLink = page.path.elements.drop(1) + val pageMarkup = FactbookLoader.loadFactbook(pageLink) ?: return@async null val pageToC = TableOfContentsBuilder() pageMarkup.buildToC(pageToC) @@ -81,9 +82,9 @@ suspend fun Appendable.generateRecentPageEdits() { RssItem( title = pageToC.toPageTitle(), description = pageOg?.desc, - link = "https://mechyrdia.info/lore/${page.path}", + link = "https://mechyrdia.info/lore${pageLink.joinToString { "/$it" }}", author = null, - comments = "https://mechyrdia.info/lore/${page.path}#comments", + comments = "https://mechyrdia.info/lore${pageLink.joinToString { "/$it" }}#comments", enclosure = imageEnclosure, pubDate = page.stat.updated ) diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/resource_bodies.kt b/src/jvmMain/kotlin/info/mechyrdia/route/resource_bodies.kt index acf71c6..21508f8 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/route/resource_bodies.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/route/resource_bodies.kt @@ -7,13 +7,13 @@ import kotlinx.html.textArea import kotlinx.serialization.Serializable @Serializable -class LoginPayload(override val csrfToken: String, val nation: String, val checksum: String, val token: String) : CsrfProtectedResourcePayload +class LoginPayload(override val csrfToken: String? = null, val nation: String, val checksum: String, val token: String) : CsrfProtectedResourcePayload @Serializable -class LogoutPayload(override val csrfToken: String) : CsrfProtectedResourcePayload +class LogoutPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload @Serializable -class NewCommentPayload(override val csrfToken: String, val comment: String) : CsrfProtectedResourcePayload { +class NewCommentPayload(override val csrfToken: String? = null, val comment: String) : CsrfProtectedResourcePayload { override fun FlowContent.displayRetryData() { p { +"The comment you tried to submit had been preserved here:" } textArea { @@ -24,7 +24,7 @@ class NewCommentPayload(override val csrfToken: String, val comment: String) : C } @Serializable -class EditCommentPayload(override val csrfToken: String, val comment: String) : CsrfProtectedResourcePayload { +class EditCommentPayload(override val csrfToken: String? = null, val comment: String) : CsrfProtectedResourcePayload { override fun FlowContent.displayRetryData() { p { +"The comment you tried to submit had been preserved here:" } textArea { @@ -35,13 +35,22 @@ class EditCommentPayload(override val csrfToken: String, val comment: String) : } @Serializable -class DeleteCommentPayload(override val csrfToken: String) : CsrfProtectedResourcePayload +class DeleteCommentPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload @Serializable -class AdminBanUserPayload(override val csrfToken: String) : CsrfProtectedResourcePayload +class AdminBanUserPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload @Serializable -class AdminUnbanUserPayload(override val csrfToken: String) : CsrfProtectedResourcePayload +class AdminUnbanUserPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload + +@Serializable +class AdminVfsDeleteFilePayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload + +@Serializable +class AdminVfsMkDirPayload(override val csrfToken: String? = null, val directory: String) : CsrfProtectedResourcePayload + +@Serializable +class AdminVfsRmDirPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload @Serializable class MechyrdiaSansPayload(val bold: Boolean = false, val italic: Boolean = false, val align: TextAlignment = TextAlignment.LEFT, val lines: List) diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/resource_csrf.kt b/src/jvmMain/kotlin/info/mechyrdia/route/resource_csrf.kt index 6b4da06..0d17cfe 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/route/resource_csrf.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/route/resource_csrf.kt @@ -38,10 +38,11 @@ private val csrfMap = ConcurrentHashMap() data class CsrfFailedException(override val message: String, val payload: CsrfProtectedResourcePayload) : RuntimeException(message) interface CsrfProtectedResourcePayload { - val csrfToken: String + val csrfToken: String? suspend fun ApplicationCall.verifyCsrfToken(route: String = request.uri) { - val check = csrfMap.remove(csrfToken) ?: throw CsrfFailedException("The submitted CSRF token is not valid", this@CsrfProtectedResourcePayload) + val token = csrfToken ?: throw CsrfFailedException("The submitted CSRF token is not present", this@CsrfProtectedResourcePayload) + val check = csrfMap.remove(token) ?: throw CsrfFailedException("The submitted CSRF token is not valid", this@CsrfProtectedResourcePayload) val payload = csrfPayload(route, check.expires) if (check != payload) throw CsrfFailedException("The submitted CSRF token does not match", this@CsrfProtectedResourcePayload) diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/resource_handler.kt b/src/jvmMain/kotlin/info/mechyrdia/route/resource_handler.kt index 83c8565..e824086 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/route/resource_handler.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/route/resource_handler.kt @@ -1,10 +1,12 @@ package info.mechyrdia.route import io.ktor.http.* +import io.ktor.http.content.* import io.ktor.resources.serialization.* import io.ktor.server.application.* +import io.ktor.server.request.* import io.ktor.server.resources.* -import io.ktor.server.routing.* +import io.ktor.server.routing.Route import io.ktor.util.pipeline.* import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.KSerializer @@ -46,6 +48,12 @@ inline fun , reified P : Any> Route.post() { } } +inline fun , reified P : MultiPartPayload> Route.postMultipart() { + post { resource -> + with(resource) { handleCall(payloadProcessor

().process(call.receiveMultipart())) } + } +} + abstract class KeyedEnumSerializer>(val entries: EnumEntries, val getKey: (E) -> String? = { it.name }) : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("KeyedEnumSerializer<${entries.first()::class.qualifiedName}>", PrimitiveKind.STRING) diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/resource_multipart.kt b/src/jvmMain/kotlin/info/mechyrdia/route/resource_multipart.kt new file mode 100644 index 0000000..51ac7b6 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/route/resource_multipart.kt @@ -0,0 +1,52 @@ +package info.mechyrdia.route + +import io.ktor.http.content.* +import kotlin.reflect.full.companionObjectInstance + +interface MultiPartPayload : AutoCloseable { + val payload: List + + override fun close() { + for (data in payload) + data.dispose() + } +} + +interface MultiPartPayloadProcessor

{ + suspend fun process(data: MultiPartData): P +} + +inline fun payloadProcessor(): MultiPartPayloadProcessor

{ + @Suppress("UNCHECKED_CAST") + return P::class.companionObjectInstance as MultiPartPayloadProcessor

+} + +data class CsrfProtectedMultiPartPayload( + override val csrfToken: String? = null, + override val payload: List +) : CsrfProtectedResourcePayload, MultiPartPayload { + companion object : MultiPartPayloadProcessor { + override suspend fun process(data: MultiPartData): CsrfProtectedMultiPartPayload { + var csrfToken: String? = null + val payload = mutableListOf() + + data.forEachPart { part -> + if (part is PartData.FormItem && part.name == "csrfToken") + csrfToken = part.value + else payload.add(part) + } + + return CsrfProtectedMultiPartPayload(csrfToken, payload) + } + } +} + +data class PlainMultiPartPayload( + override val payload: List +) : MultiPartPayload { + companion object : MultiPartPayloadProcessor { + override suspend fun process(data: MultiPartData): PlainMultiPartPayload { + return PlainMultiPartPayload(data.readAllParts()) + } + } +} diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt b/src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt index 363c4f6..e2f9549 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt @@ -6,9 +6,11 @@ import info.mechyrdia.auth.logoutRoute import info.mechyrdia.data.* import info.mechyrdia.lore.* import io.ktor.http.* +import io.ktor.http.content.* import io.ktor.resources.* import io.ktor.server.application.* import io.ktor.server.html.* +import io.ktor.server.plugins.* import io.ktor.server.response.* import io.ktor.util.* import io.ktor.util.pipeline.* @@ -266,6 +268,116 @@ class Root(val error: String? = null) : ResourceHandler, ResourceFilter { call.adminUnbanUserRoute(id) } } + + @Resource("vfs") + class Vfs(val admin: Admin = Admin()) : ResourceFilter { + override suspend fun PipelineContext.filterCall() { + with(admin) { filterCall() } + } + + @Resource("inline/{path...}") + class Inline(val path: List, val vfs: Vfs = Vfs()) : ResourceHandler { + override suspend fun PipelineContext.handleCall() { + with(vfs) { filterCall() } + + call.response.header(HttpHeaders.ContentDisposition, "inline") + call.adminPreviewFile(StoragePath(path)) + } + } + + @Resource("download/{path...}") + class Download(val path: List, val vfs: Vfs = Vfs()) : ResourceHandler { + override suspend fun PipelineContext.handleCall() { + with(vfs) { filterCall() } + + call.response.header(HttpHeaders.ContentDisposition, "attachment; filename=\"${path.last()}\"") + call.adminPreviewFile(StoragePath(path)) + } + } + + @Resource("view/{path...}") + class View(val path: List, val vfs: Vfs = Vfs()) : ResourceHandler { + override suspend fun PipelineContext.handleCall() { + with(vfs) { filterCall() } + + call.respondHtml(HttpStatusCode.OK, call.adminViewVfs(StoragePath(path))) + } + } + + @Resource("upload/{path...}") + class Upload(val path: List, val vfs: Vfs = Vfs()) : ResourceReceiver { + override suspend fun PipelineContext.handleCall(payload: CsrfProtectedMultiPartPayload) { + with(vfs) { filterCall() } + with(payload) { call.verifyCsrfToken() } + + val fileItem = payload.payload.filterIsInstance().singleOrNull() + ?: throw MissingRequestParameterException("file") + + call.adminUploadFile(StoragePath(path), fileItem) + } + } + + @Resource("overwrite/{path...}") + class Overwrite(val path: List, val vfs: Vfs = Vfs()) : ResourceReceiver { + override suspend fun PipelineContext.handleCall(payload: CsrfProtectedMultiPartPayload) { + with(vfs) { filterCall() } + with(payload) { call.verifyCsrfToken() } + + val fileItem = payload.payload.filterIsInstance().singleOrNull() + ?: throw MissingRequestParameterException("file") + + call.adminOverwriteFile(StoragePath(path), fileItem) + } + } + + @Resource("delete/{path...}") + class DeleteConfirmPage(val path: List, val vfs: Vfs = Vfs()) : ResourceHandler { + override suspend fun PipelineContext.handleCall() { + with(vfs) { filterCall() } + + call.adminConfirmDeleteFile(StoragePath(path)) + } + } + + @Resource("delete/{path...}") + class DeleteConfirmPost(val path: List, val vfs: Vfs = Vfs()) : ResourceReceiver { + override suspend fun PipelineContext.handleCall(payload: AdminVfsDeleteFilePayload) { + with(vfs) { filterCall() } + with(payload) { call.verifyCsrfToken() } + + call.adminDeleteFile(StoragePath(path)) + } + } + + @Resource("mkdir/{path...}") + class MkDir(val path: List, val vfs: Vfs = Vfs()) : ResourceReceiver { + override suspend fun PipelineContext.handleCall(payload: AdminVfsMkDirPayload) { + with(vfs) { filterCall() } + with(payload) { call.verifyCsrfToken() } + + call.adminMakeDirectory(StoragePath(path), payload.directory) + } + } + + @Resource("rmdir/{path...}") + class RmDirConfirmPage(val path: List, val vfs: Vfs = Vfs()) : ResourceHandler { + override suspend fun PipelineContext.handleCall() { + with(vfs) { filterCall() } + + call.adminConfirmRemoveDirectory(StoragePath(path)) + } + } + + @Resource("rmdir/{path...}") + class RmDirConfirmPost(val path: List, val vfs: Vfs = Vfs()) : ResourceReceiver { + override suspend fun PipelineContext.handleCall(payload: AdminVfsRmDirPayload) { + with(vfs) { filterCall() } + with(payload) { call.verifyCsrfToken() } + + call.adminRemoveDirectory(StoragePath(path)) + } + } + } } @Resource("utils") diff --git a/src/jvmMain/resources/static/admin.css b/src/jvmMain/resources/static/admin.css new file mode 100644 index 0000000..842cae4 --- /dev/null +++ b/src/jvmMain/resources/static/admin.css @@ -0,0 +1,223 @@ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: normal; + font-display: block; + src: url("/static/font/JetBrainsMono-Medium.woff"); +} + +@font-face { + font-family: 'JetBrains Mono'; + font-style: italic; + font-weight: normal; + font-display: block; + src: url("/static/font/JetBrainsMono-MediumItalic.woff"); +} + +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: bold; + font-display: block; + src: url("/static/font/JetBrainsMono-ExtraBold.woff"); +} + +@font-face { + font-family: 'JetBrains Mono'; + font-style: italic; + font-weight: bold; + font-display: block; + src: url("/static/font/JetBrainsMono-ExtraBoldItalic.woff"); +} + +html { + width: 100vw; + height: 100vh; + margin: 0; + padding: 0; + + font-family: 'JetBrains Mono', monospace; +} + +body { + width: 100vw; + height: 100vh; + margin: 0; + + background-color: #541; + box-shadow: inset 0 0 15vmin 10vmin #000; + color: #fd7; + text-shadow: 0 0 0.25em #ca4; +} + +body::after { + content: ""; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: repeating-linear-gradient( + to bottom, + rgba(0, 0, 0, 0.2), + rgba(0, 0, 0, 0.2) 2px, + transparent 2px, + transparent 4px + ); + pointer-events: none; +} + +main { + position: fixed; + top: 0; + left: 20vmin; + right: 20vmin; + bottom: 0; + + overflow-y: auto; +} + +div.message { + position: fixed; + top: 50vh; + left: 50vw; + transform: translate(-50%, -50%); + + max-width: 60vw; + max-height: 80vw; + overflow-y: auto; +} + +::selection { + background: #db5; + color: #feb; + text-shadow: none; +} + +iframe { + background-color: #fff; + width: 100%; + height: 50vh; +} + +table { + border-collapse: separate; + table-layout: fixed; + width: 100%; +} + +th, td { + border: 1px solid #ec6; + font-size: 1em; + padding: 0.75em 1.25em; + + text-align: center; +} + +th { + font-variant: small-caps; + font-weight: bold; +} + +td > p, td > ul { + text-align: left; +} + +a, a:visited { + color: #7df; + text-shadow: 0 0 0.25em #4ac; +} + +form { + display: inline; +} + +input[type=file] { + display: none; +} + +label:has(> input[type=file]) { + border: 1px solid #ec6; + background-color: #541; + + display: inline-block; + vertical-align: middle; + padding: 0.375em 0.75em; + + cursor: pointer; +} + +label:has(> input[type=file]):hover { + background-color: #a82; +} + +label:has(> input[type=file]) ~ input[type=submit] { + display: none; +} + +input[type=text] { + color: inherit; + border: 1px solid #ca4; + background-color: #430; + + font-family: 'JetBrains Mono', monospace; + font-size: 1rem; + + display: inline-block; + vertical-align: middle; + padding: 0.375em 0.75em; +} + +input[type=text]:hover { + border: 1px solid #fd7; +} + +input[type=text]:focus { + outline: none; + background-color: #860; +} + +input[type=submit] { + color: #fff; + border: 0 none transparent; + background-color: #971; + + font-family: 'JetBrains Mono', monospace; + font-size: 1rem; + + display: inline-block; + vertical-align: middle; + padding: 0.375em 0.75em; +} + +input[type=submit].evil { + background-color: #922; +} + +input[type=submit]:hover { + color: #fff; + border: 0 none transparent; + background-color: #b93; + + display: inline-block; + vertical-align: middle; + padding: 0.375em 0.75em; +} + +input[type=submit].evil:hover { + background-color: #b44; +} + +input[type=submit]:active { + color: #fff; + border: 0 none transparent; + background-color: #ec6; + + display: inline-block; + vertical-align: middle; + padding: 0.375em 0.75em; +} + +input[type=submit].evil:active { + background-color: #e77; +} diff --git a/src/jvmMain/resources/static/admin.js b/src/jvmMain/resources/static/admin.js new file mode 100644 index 0000000..000b9fd --- /dev/null +++ b/src/jvmMain/resources/static/admin.js @@ -0,0 +1,10 @@ +(function () { + window.addEventListener("load", function () { + const fileInputs = document.querySelectorAll("input[type=file]"); + for (const fileInput of fileInputs) { + fileInput.addEventListener("change", e => { + e.currentTarget.form.submit(); + }); + } + }); +})();