Add VFS-management web UI
authorLanius Trolling <lanius@laniustrolling.dev>
Tue, 9 Apr 2024 15:44:14 +0000 (11:44 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Tue, 9 Apr 2024 15:44:14 +0000 (11:44 -0400)
25 files changed:
build.gradle.kts
src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt
src/jvmMain/kotlin/info/mechyrdia/data/MigrateFiles.kt
src/jvmMain/kotlin/info/mechyrdia/data/comments.kt
src/jvmMain/kotlin/info/mechyrdia/data/data.kt
src/jvmMain/kotlin/info/mechyrdia/data/data_files.kt
src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt
src/jvmMain/kotlin/info/mechyrdia/data/views_files.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/article_listing.kt
src/jvmMain/kotlin/info/mechyrdia/lore/asset_hashing.kt
src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess.kt
src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_include.kt
src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_json.kt
src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_math.kt
src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt
src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt
src/jvmMain/kotlin/info/mechyrdia/lore/views_error.kt
src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt
src/jvmMain/kotlin/info/mechyrdia/route/resource_bodies.kt
src/jvmMain/kotlin/info/mechyrdia/route/resource_csrf.kt
src/jvmMain/kotlin/info/mechyrdia/route/resource_handler.kt
src/jvmMain/kotlin/info/mechyrdia/route/resource_multipart.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt
src/jvmMain/resources/static/admin.css [new file with mode: 0644]
src/jvmMain/resources/static/admin.js [new file with mode: 0644]

index 91a43fca8c048bf7ee3db1f4cdb832b2288662e6..c9f6d7f6490c0ad9d76b31fb8ee5a1a544064e85 100644 (file)
@@ -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"))
 }
index c31a07edbe646cd7c7449b4d4f8824d94cd045d3..9f026949898b93d3afb020edc14441e14bd142d4 100644 (file)
@@ -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<HttpRedirectException> { call, (url, permanent) ->
                        call.respondRedirect(url, permanent)
@@ -171,6 +183,16 @@ fun Application.factbooks() {
                get<Root.User.ById>()
                post<Root.Admin.Ban, _>()
                post<Root.Admin.Unban, _>()
+               get<Root.Admin.Vfs.Inline>()
+               get<Root.Admin.Vfs.Download>()
+               get<Root.Admin.Vfs.View>()
+               post<Root.Admin.Vfs.Upload, _>()
+               post<Root.Admin.Vfs.Overwrite, _>()
+               get<Root.Admin.Vfs.DeleteConfirmPage>()
+               post<Root.Admin.Vfs.DeleteConfirmPost, _>()
+               post<Root.Admin.Vfs.MkDir, _>()
+               get<Root.Admin.Vfs.RmDirConfirmPage>()
+               post<Root.Admin.Vfs.RmDirConfirmPost, _>()
                post<Root.Utils.MechyrdiaSans, _>()
                post<Root.Utils.TylanLanguage, _>()
                post<Root.Utils.PokhwalishLanguage, _>()
index bfd17d707d7dec3a861678e1775b06c69a126380..9cb65496c50c651353818881b9f8de36f703508b 100644 (file)
@@ -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<String> {
+       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<Stri
                                        StoredFileType.DIRECTORY -> migrateDir(entryPath, from, into)
                                }
                        }
-               }.toList().awaitAll().flatten()
+               }.awaitAll().flatten()
        }
 }
 
index 5497a51500f04b49631aa8268227df991b6b4854..4cd6cb11108fd6d78fe7b34ececbf41ad9b0d2f5 100644 (file)
@@ -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<Comment>, repliesTo: Set<Id<Comment>>) {
+               suspend fun updateComment(updatedReply: Id<Comment>, repliesTo: Set<Id<Comment>>, 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<CommentReplyLink>()),
+                                               ),
+                                               UpdateOptions().upsert(true)
                                        )
                                }
                        )
index 0ff91bb4c0c466d889317baba0abc18636999b8e..c82a623d5a22dccac5df02d5e27b8dc8c1f766ae 100644 (file)
@@ -136,7 +136,7 @@ class DocumentTable<T : DataDocument<T>>(private val kClass: KClass<T>) {
        }
        
        suspend fun set(id: Id<T>, 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>): T? {
@@ -151,6 +151,14 @@ class DocumentTable<T : DataDocument<T>>(private val kClass: KClass<T>) {
                return collection().find()
        }
        
+       suspend fun insert(docs: Collection<WriteModel<T>>) {
+               if (docs.isNotEmpty())
+                       collection().bulkWrite(
+                               if (docs is List) docs else docs.toList(),
+                               BulkWriteOptions().ordered(false)
+                       )
+       }
+       
        suspend fun filter(where: Bson): Flow<T> {
                return collection().find(where)
        }
@@ -171,16 +179,16 @@ class DocumentTable<T : DataDocument<T>>(private val kClass: KClass<T>) {
                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 <T : Any> aggregate(pipeline: List<Bson>, resultClass: KClass<T>): Flow<T> {
index bcffa5e13bfa4bde0bd9456fc4d7fe6ecde6290a..48c35de2acb956c60c5477b57d50ab078ed4eeee 100644 (file)
@@ -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<String>) {
 }
 
 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<StoredFileEntry>
+       suspend fun listDir(dir: StoragePath): List<StoredFileEntry>?
        
        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<StoredFileEntry> {
-               return withContext(Dispatchers.IO) { resolveFile(dir).listFiles()?.map { renderEntry(it) }.orEmpty() }.asFlow()
+       override suspend fun listDir(dir: StoragePath): List<StoredFileEntry>? {
+               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<GridFsEntry>
 
 private class GridFsStorage(val table: DocumentTable<GridFsEntry>, 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<GridFsEntry>, 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<GridFsEntry>()),
+                               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<StoredFileEntry> {
+       override suspend fun listDir(dir: StoragePath): List<StoredFileEntry>? {
                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<GridFsEntry>, 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<GridFsEntry>, 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<GridFsEntry>, val bucket: G
                        }.joinAll()
                }
        }
+       
+       companion object {
+               private const val GRID_FS_KEEP = ".grid-fs-keep"
+       }
 }
index 257cab9c03ca48ebd99ae5d3f6920cb29f42505a..86e88f71c5fe52296aceee94c8648683e41a8703 100644 (file)
@@ -69,11 +69,12 @@ suspend fun ApplicationCall.newCommentRoute(pagePathParts: List<String>, 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<String>, 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<Comment>, 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<Comment>): 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 (file)
index 0000000..bca1901
--- /dev/null
@@ -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<String, TreeNode>) : 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<String, TreeNode>) {
+       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)
+}
index 5dc7ab21b2f7ed5f75e6862991d8963f4eda5db9..18f58088395dadb286f15ec65bc59a90cfdfdb57 100644 (file)
@@ -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() }
 )
 
index 40d757970a7d37110694639edc070a0a3bbfa4c1..eab3a879897ff446e46e4acf4fad302840a095b3 100644 (file)
@@ -54,10 +54,12 @@ private class DigestingOutputStream(stomach: MessageDigest) : OutputStream() {
 private class FileHashCache(val hashAlgo: String) : FileDependentCache<ByteArray>() {
        private val hashinator: ThreadLocal<MessageDigest> = 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)
                        }
                }
        }
index 1b35be9213cd4f92069f795aa83e0dbde80a5900..db892fd75cc50ce176d00227254d7c3cbc30b97a 100644 (file)
@@ -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<String, ParserTree>,
-       val parent: PreProcessingContext? = null,
+       val parent: PreProcessorContext? = null,
 ) {
-       constructor(parent: PreProcessingContext? = null, vararg variables: Pair<String, ParserTree>) : this(mutableMapOf(*variables), parent)
+       constructor(parent: PreProcessorContext? = null, vararg variables: Pair<String, ParserTree>) : 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<String, ParserTree>) = PreProcessingContext(other.toMutableMap(), this)
+       operator fun plus(other: Map<String, ParserTree>) = PreProcessorContext(other.toMutableMap(), this)
        
        fun toMap(): Map<String, ParserTree> = parent?.toMap().orEmpty() + variables
        
        companion object {
-               operator fun invoke(variables: Map<String, ParserTree>, parent: PreProcessingContext? = null) = PreProcessingContext(variables.toMutableMap(), parent)
+               operator fun invoke(variables: Map<String, ParserTree>, 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<String>) = 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<PreProcessingContext, PreProcessingSubject>, AsyncLexerTextProcessor<PreProcessingContext, PreProcessingSubject>, AsyncLexerLineBreakProcessor<PreProcessingContext, PreProcessingSubject>, AsyncLexerCombiner<PreProcessingContext, PreProcessingSubject> {
-       override suspend fun processInvalidTag(env: AsyncLexerTagEnvironment<PreProcessingContext, PreProcessingSubject>, tag: String, param: String?, subNodes: ParserTree): PreProcessingSubject {
+object PreProcessorUtils : AsyncLexerTagFallback<PreProcessorContext, PreProcessorSubject>, AsyncLexerTextProcessor<PreProcessorContext, PreProcessorSubject>, AsyncLexerLineBreakProcessor<PreProcessorContext, PreProcessorSubject>, AsyncLexerCombiner<PreProcessorContext, PreProcessorSubject> {
+       override suspend fun processInvalidTag(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, tag: String, param: String?, subNodes: ParserTree): PreProcessorSubject {
                return listOf(
                        ParserTreeNode.Tag(
                                tag = tag,
@@ -69,23 +73,23 @@ object PreProcessorUtils : AsyncLexerTagFallback<PreProcessingContext, PreProces
                )
        }
        
-       override suspend fun processText(env: AsyncLexerTagEnvironment<PreProcessingContext, PreProcessingSubject>, text: String): PreProcessingSubject {
+       override suspend fun processText(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, text: String): PreProcessorSubject {
                return text.textToTree()
        }
        
-       override suspend fun processLineBreak(env: AsyncLexerTagEnvironment<PreProcessingContext, PreProcessingSubject>): PreProcessingSubject {
+       override suspend fun processLineBreak(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): PreProcessorSubject {
                return listOf(ParserTreeNode.LineBreak)
        }
        
-       override suspend fun combine(env: AsyncLexerTagEnvironment<PreProcessingContext, PreProcessingSubject>, subjects: List<PreProcessingSubject>): PreProcessingSubject {
+       override suspend fun combine(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, subjects: List<PreProcessorSubject>): PreProcessorSubject {
                return subjects.flatten()
        }
        
-       fun withContext(env: AsyncLexerTagEnvironment<PreProcessingContext, PreProcessingSubject>, newContext: PreProcessingContext): AsyncLexerTagEnvironment<PreProcessingContext, PreProcessingSubject> {
+       fun withContext(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, newContext: PreProcessorContext): AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject> {
                return env.copy(context = newContext)
        }
        
-       suspend fun processWithContext(env: AsyncLexerTagEnvironment<PreProcessingContext, PreProcessingSubject>, newContext: PreProcessingContext, input: ParserTree): ParserTree {
+       suspend fun processWithContext(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, newContext: PreProcessorContext, input: ParserTree): ParserTree {
                return withContext(env, newContext).processTree(input)
        }
        
@@ -108,7 +112,7 @@ object PreProcessorUtils : AsyncLexerTagFallback<PreProcessingContext, PreProces
        }
 }
 
-fun interface PreProcessorLexerTag : AsyncLexerTagProcessor<PreProcessingContext, PreProcessingSubject>
+fun interface PreProcessorLexerTag : AsyncLexerTagProcessor<PreProcessorContext, PreProcessorSubject>
 
 inline fun <T : Any> 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<PreProcessingContext, PreProcessingSubject>): ParserTree
+       suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree
 }
 
 interface PreProcessorFunctionProvider : PreProcessorLexerTag {
@@ -143,10 +147,10 @@ interface PreProcessorFunctionProvider : PreProcessorLexerTag {
        
        suspend fun provideFunction(param: String?): PreProcessorFunction?
        
-       override suspend fun processTag(env: AsyncLexerTagEnvironment<PreProcessingContext, PreProcessingSubject>, param: String?, subNodes: ParserTree): PreProcessingSubject {
+       override suspend fun processTag(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, 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<PreProcessingContext, PreProcessingSubject>): ParserTree {
+       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): 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<PreProcessingContext, PreProcessingSubject>): ParserTree {
+       override suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): 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<PreProcessingContext, PreProcessingSubject>): ParserTree
+       suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree
 }
 
 interface PreProcessorFilterProvider : PreProcessorLexerTag {
@@ -205,7 +209,7 @@ interface PreProcessorFilterProvider : PreProcessorLexerTag {
        
        suspend fun provideFilter(param: String?): PreProcessorFilter?
        
-       override suspend fun processTag(env: AsyncLexerTagEnvironment<PreProcessingContext, PreProcessingSubject>, param: String?, subNodes: ParserTree): PreProcessingSubject {
+       override suspend fun processTag(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, 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<String, ParserTree>): ParserTree {
+suspend fun ParserTree.preProcess(context: PreProcessorContext): ParserTree {
        return AsyncLexerTagEnvironment(
-               PreProcessingContext(context, null),
+               context,
                PreProcessorTags.asTags,
                PreProcessorUtils,
                PreProcessorUtils,
index e745e69f06d28d36298aa1a0bdaa5c5b75c12b04..ee15e457f3a70c2c18082f41acd0b1a83d1f9953 100644 (file)
@@ -26,10 +26,10 @@ object PreProcessorTemplateLoader {
        }
        
        suspend fun runTemplateWith(name: String, args: Map<String, ParserTree>): ParserTree {
-               return loadTemplate(name).preProcess(args)
+               return loadTemplate(name).preProcess(PreProcessorContext(args))
        }
        
-       suspend fun runTemplateHere(name: String, env: AsyncLexerTagEnvironment<PreProcessingContext, PreProcessingSubject>): ParserTree {
+       suspend fun runTemplateHere(name: String, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): 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<String, Any?>, env: AsyncLexerTagEnvironment<PreProcessingContext, PreProcessingSubject>): Any? {
+       suspend fun runScriptInternal(script: CompiledScript, bind: Map<String, Any?>, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): Any? {
                return suspendCancellableCoroutine { continuation ->
                        val bindings = SimpleBindings()
                        bindings.putAll(bind)
@@ -88,7 +88,7 @@ object PreProcessorScriptLoader {
                }
        }
        
-       private suspend fun runScriptWithBindings(scriptName: String, bind: Map<String, Any?>, env: AsyncLexerTagEnvironment<PreProcessingContext, PreProcessingSubject>, errorHandler: (Exception) -> ParserTree): ParserTree {
+       private suspend fun runScriptWithBindings(scriptName: String, bind: Map<String, Any?>, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, 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<String, ParserTree>, env: AsyncLexerTagEnvironment<PreProcessingContext, PreProcessingSubject>, errorHandler: (Exception) -> ParserTree): ParserTree {
+       suspend fun runScriptSafe(scriptName: String, args: Map<String, ParserTree>, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, 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<PreProcessingContext, PreProcessingSubject>, errorHandler: (Exception) -> ParserTree): ParserTree {
+       suspend fun runScriptSafe(scriptName: String, input: ParserTree, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, 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<PreProcessingContext, PreProcessingSubject>, private val context: CoroutineContext, private val onError: (Throwable) -> Unit) {
+class PreProcessorScriptStdlib(private val env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, private val context: CoroutineContext, private val onError: (Throwable) -> Unit) {
        fun jsonStringify(data: Any?): String {
                return PreProcessorScriptLoader.groovyToJson(data).toString()
        }
index ea0890ac2009627658f2398de3775a7c1c05103d..d688147d3b91aa073a7dfaca6c9af3646ca9d7d9 100644 (file)
@@ -75,8 +75,9 @@ object FactbookLoader {
        }
        
        suspend fun loadFactbook(lorePath: List<String>): 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)))
        }
 }
index c7b8c73b9dae807dbdb766249306f348d584a673..afc0f3c40e226ccd6c75c328b14ef016b1d063ce 100644 (file)
@@ -59,7 +59,7 @@ object PreProcessorMathOperators : PreProcessorFunctionLibrary("math") {
 }
 
 fun interface PreProcessorMathUnaryOperator : PreProcessorFunction {
-       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessingContext, PreProcessingSubject>): ParserTree {
+       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): 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<PreProcessingContext, PreProcessingSubject>): ParserTree {
+       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): 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<PreProcessingContext, PreProcessingSubject>): ParserTree {
+       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): 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<PreProcessingContext, PreProcessingSubject>): ParserTree {
+       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): 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<PreProcessingContext, PreProcessingSubject>): ParserTree {
+       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): 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<PreProcessingContext, PreProcessingSubject>): ParserTree {
+       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): 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<PreProcessingContext, PreProcessingSubject>): ParserTree {
+       override suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): 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<PreProcessingContext, PreProcessingSubject>): ParserTree {
+       override suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
                return calculate(input).booleanToTree()
        }
        
index fc706cf057c33d4770b10aeb911a8a6eeca270c0..61d7479955a3e5c5f01c9c19b2dfc2c3e162495c 100644 (file)
@@ -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<String>? = 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()
index 379758657b2a40aaf72d122f91e411c590030a9d..3618db1e6f46f0401fa2f1b46bc375ca2da785ac 100644 (file)
@@ -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"
index 728d77005af8618ff6b10ba25fe1369cbfa0cf44..cb7a837673c4f217420cc64fc3b81d6af0574195 100644 (file)
@@ -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." }
 }
index d82246e82dfe75a59bf450d9dc562c6abe415dc3..1a532597afeb9b417bd9b2c5fdac0af1585d3cea 100644 (file)
@@ -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
                                        )
index acf71c6560151afe278f662c986e9373c7ecea11..21508f8b75f9007880d6012878f3064c8ae313f4 100644 (file)
@@ -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<String>)
index 6b4da063585fb52f339c521c0a13ecd36a01b7ac..0d17cfec54b97d950282cd5f334c2224f9ec2560 100644 (file)
@@ -38,10 +38,11 @@ private val csrfMap = ConcurrentHashMap<String, CsrfPayload>()
 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)
index 83c856528c12190139375afd43d8c574918fbe77..e82408614c95f69777d0568e183314632c8c3a86 100644 (file)
@@ -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 T : ResourceReceiver<P>, reified P : Any> Route.post() {
        }
 }
 
+inline fun <reified T : ResourceReceiver<P>, reified P : MultiPartPayload> Route.postMultipart() {
+       post<T> { resource ->
+               with(resource) { handleCall(payloadProcessor<P>().process(call.receiveMultipart())) }
+       }
+}
+
 abstract class KeyedEnumSerializer<E : Enum<E>>(val entries: EnumEntries<E>, val getKey: (E) -> String? = { it.name }) : KSerializer<E> {
        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 (file)
index 0000000..51ac7b6
--- /dev/null
@@ -0,0 +1,52 @@
+package info.mechyrdia.route
+
+import io.ktor.http.content.*
+import kotlin.reflect.full.companionObjectInstance
+
+interface MultiPartPayload : AutoCloseable {
+       val payload: List<PartData>
+       
+       override fun close() {
+               for (data in payload)
+                       data.dispose()
+       }
+}
+
+interface MultiPartPayloadProcessor<P : MultiPartPayload> {
+       suspend fun process(data: MultiPartData): P
+}
+
+inline fun <reified P : MultiPartPayload> payloadProcessor(): MultiPartPayloadProcessor<P> {
+       @Suppress("UNCHECKED_CAST")
+       return P::class.companionObjectInstance as MultiPartPayloadProcessor<P>
+}
+
+data class CsrfProtectedMultiPartPayload(
+       override val csrfToken: String? = null,
+       override val payload: List<PartData>
+) : CsrfProtectedResourcePayload, MultiPartPayload {
+       companion object : MultiPartPayloadProcessor<CsrfProtectedMultiPartPayload> {
+               override suspend fun process(data: MultiPartData): CsrfProtectedMultiPartPayload {
+                       var csrfToken: String? = null
+                       val payload = mutableListOf<PartData>()
+                       
+                       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<PartData>
+) : MultiPartPayload {
+       companion object : MultiPartPayloadProcessor<PlainMultiPartPayload> {
+               override suspend fun process(data: MultiPartData): PlainMultiPartPayload {
+                       return PlainMultiPartPayload(data.readAllParts())
+               }
+       }
+}
index 363c4f6ad65a08640e5b9a72bdf070d8609435c1..e2f9549c05dae5acb0f292b4e708d2d603b2043e 100644 (file)
@@ -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<Unit, ApplicationCall>.filterCall() {
+                               with(admin) { filterCall() }
+                       }
+                       
+                       @Resource("inline/{path...}")
+                       class Inline(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                                       with(vfs) { filterCall() }
+                                       
+                                       call.response.header(HttpHeaders.ContentDisposition, "inline")
+                                       call.adminPreviewFile(StoragePath(path))
+                               }
+                       }
+                       
+                       @Resource("download/{path...}")
+                       class Download(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.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<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                                       with(vfs) { filterCall() }
+                                       
+                                       call.respondHtml(HttpStatusCode.OK, call.adminViewVfs(StoragePath(path)))
+                               }
+                       }
+                       
+                       @Resource("upload/{path...}")
+                       class Upload(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<CsrfProtectedMultiPartPayload> {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: CsrfProtectedMultiPartPayload) {
+                                       with(vfs) { filterCall() }
+                                       with(payload) { call.verifyCsrfToken() }
+                                       
+                                       val fileItem = payload.payload.filterIsInstance<PartData.FileItem>().singleOrNull()
+                                               ?: throw MissingRequestParameterException("file")
+                                       
+                                       call.adminUploadFile(StoragePath(path), fileItem)
+                               }
+                       }
+                       
+                       @Resource("overwrite/{path...}")
+                       class Overwrite(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<CsrfProtectedMultiPartPayload> {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: CsrfProtectedMultiPartPayload) {
+                                       with(vfs) { filterCall() }
+                                       with(payload) { call.verifyCsrfToken() }
+                                       
+                                       val fileItem = payload.payload.filterIsInstance<PartData.FileItem>().singleOrNull()
+                                               ?: throw MissingRequestParameterException("file")
+                                       
+                                       call.adminOverwriteFile(StoragePath(path), fileItem)
+                               }
+                       }
+                       
+                       @Resource("delete/{path...}")
+                       class DeleteConfirmPage(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                                       with(vfs) { filterCall() }
+                                       
+                                       call.adminConfirmDeleteFile(StoragePath(path))
+                               }
+                       }
+                       
+                       @Resource("delete/{path...}")
+                       class DeleteConfirmPost(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<AdminVfsDeleteFilePayload> {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminVfsDeleteFilePayload) {
+                                       with(vfs) { filterCall() }
+                                       with(payload) { call.verifyCsrfToken() }
+                                       
+                                       call.adminDeleteFile(StoragePath(path))
+                               }
+                       }
+                       
+                       @Resource("mkdir/{path...}")
+                       class MkDir(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<AdminVfsMkDirPayload> {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminVfsMkDirPayload) {
+                                       with(vfs) { filterCall() }
+                                       with(payload) { call.verifyCsrfToken() }
+                                       
+                                       call.adminMakeDirectory(StoragePath(path), payload.directory)
+                               }
+                       }
+                       
+                       @Resource("rmdir/{path...}")
+                       class RmDirConfirmPage(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                                       with(vfs) { filterCall() }
+                                       
+                                       call.adminConfirmRemoveDirectory(StoragePath(path))
+                               }
+                       }
+                       
+                       @Resource("rmdir/{path...}")
+                       class RmDirConfirmPost(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<AdminVfsRmDirPayload> {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.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 (file)
index 0000000..842cae4
--- /dev/null
@@ -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 (file)
index 0000000..000b9fd
--- /dev/null
@@ -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();
+                       });
+               }
+       });
+})();