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")
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"))
}
}
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)
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, _>()
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) {
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) {
StoredFileType.DIRECTORY -> migrateDir(entryPath, from, into)
}
}
- }.toList().awaitAll().flatten()
+ }.awaitAll().flatten()
}
}
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
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)
)
}
)
}
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? {
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)
}
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> {
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) {
}
enum class StoredFileType {
+ DIRECTORY,
FILE,
- DIRECTORY;
}
data class StoredFileEntry(val name: String, val type: StoredFileType)
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?
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(
ConnectionHolder.getBucket()
)
}.apply { prepare() }
+
+ maintenanceScope.launch {
+ while (true) {
+ launch(SupervisorJob(currentCoroutineContext().job)) {
+ instance.performMaintenance()
+ }
+
+ delay(8.hours)
+ }
+ }
}
fun initialize() = runBlocking { configure() }
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() }
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
}
}
}
- 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
}
) : 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),
)
}
- 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
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 ->
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 {
}.joinAll()
}
}
+
+ companion object {
+ private const val GRID_FS_KEEP = ".grid-fs-keep"
+ }
}
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,
)
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}")
}
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))
}
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? "
--- /dev/null
+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)
+}
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() }
)
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)
}
}
}
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
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")
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,
)
}
- 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)
}
}
}
-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)
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 {
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))
@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])
}
}
@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)
}
}
}
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 {
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)
}
}
-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,
}
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))
}
}
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)
}
}
- 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)
}
}
- 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)
}
}
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()
}
}
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)))
}
}
}
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)
}
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"])
}
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) }
}
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"])
}
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"])
}
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() }
}
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())
}
}
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()
}
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
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()
import kotlinx.html.*
import java.time.Instant
-val preloadFonts = listOf(
+private val preloadFonts = listOf(
"DejaVuSans-Bold.woff",
"DejaVuSans-BoldOblique.woff",
"DejaVuSans-Oblique.woff",
"kishari-language-alphabet.woff",
)
-val preloadImages = listOf(
+private val preloadImages = listOf(
"external-link-dark.png",
"external-link.png",
"icon.png",
}
}
+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"
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."
}
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" }
}
}
+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." }
}
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)
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
)
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 {
}
@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 {
}
@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>)
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)
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
}
}
+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)
--- /dev/null
+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())
+ }
+ }
+}
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.*
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")
--- /dev/null
+@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;
+}
--- /dev/null
+(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();
+ });
+ }
+ });
+})();