parent
ab2cbff8ba
commit
ab5aad8f3e
3 changed files with 216 additions and 2 deletions
|
|
@ -4,9 +4,11 @@ import de.bollwerk.server.model.ErrorResponse
|
||||||
import de.bollwerk.server.repository.InventoryRepository
|
import de.bollwerk.server.repository.InventoryRepository
|
||||||
import de.bollwerk.server.repository.MessageRepository
|
import de.bollwerk.server.repository.MessageRepository
|
||||||
import de.bollwerk.server.repository.UserRepository
|
import de.bollwerk.server.repository.UserRepository
|
||||||
|
import de.bollwerk.server.repository.ResourceRepository
|
||||||
import de.bollwerk.server.routes.adminRoutes
|
import de.bollwerk.server.routes.adminRoutes
|
||||||
import de.bollwerk.server.routes.authRoutes
|
import de.bollwerk.server.routes.authRoutes
|
||||||
import de.bollwerk.server.routes.adminMessageRoutes
|
import de.bollwerk.server.routes.adminMessageRoutes
|
||||||
|
import de.bollwerk.server.routes.resourceRoutes
|
||||||
import de.bollwerk.server.routes.inventoryRoutes
|
import de.bollwerk.server.routes.inventoryRoutes
|
||||||
import de.bollwerk.server.routes.messageRoutes
|
import de.bollwerk.server.routes.messageRoutes
|
||||||
import de.bollwerk.server.routes.userRoutes
|
import de.bollwerk.server.routes.userRoutes
|
||||||
|
|
@ -62,11 +64,13 @@ internal fun Application.configureRouting(
|
||||||
|
|
||||||
routing {
|
routing {
|
||||||
intercept(ApplicationCallPipeline.Plugins) {
|
intercept(ApplicationCallPipeline.Plugins) {
|
||||||
|
val path = call.request.path()
|
||||||
|
val effectiveMaxSize = if (path.startsWith("/api/admin/resources")) 25L * 1024 * 1024 else MAX_BODY_SIZE
|
||||||
val contentLength = call.request.header(HttpHeaders.ContentLength)?.toLongOrNull()
|
val contentLength = call.request.header(HttpHeaders.ContentLength)?.toLongOrNull()
|
||||||
if (contentLength != null && contentLength > MAX_BODY_SIZE) {
|
if (contentLength != null && contentLength > effectiveMaxSize) {
|
||||||
call.respond(
|
call.respond(
|
||||||
HttpStatusCode.PayloadTooLarge,
|
HttpStatusCode.PayloadTooLarge,
|
||||||
ErrorResponse(status = 413, message = "Request body too large (max 1 MB)")
|
ErrorResponse(status = 413, message = "Request body too large")
|
||||||
)
|
)
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
@ -99,6 +103,7 @@ internal fun Application.configureRouting(
|
||||||
messageRoutes(messageRepository, userRepository, wsManager)
|
messageRoutes(messageRepository, userRepository, wsManager)
|
||||||
}
|
}
|
||||||
userRoutes(userRepository, wsManager)
|
userRoutes(userRepository, wsManager)
|
||||||
|
resourceRoutes(ResourceRepository())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token-only endpoint for E2EE general test messages
|
// Token-only endpoint for E2EE general test messages
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package de.bollwerk.server.repository
|
||||||
|
|
||||||
|
import de.bollwerk.server.db.Resources
|
||||||
|
import de.bollwerk.shared.model.ResourceDto
|
||||||
|
import org.jetbrains.exposed.sql.*
|
||||||
|
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||||
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
|
||||||
|
internal class ResourceRepository {
|
||||||
|
|
||||||
|
fun getAll(): List<ResourceDto> = transaction {
|
||||||
|
Resources.selectAll().map { it.toDto() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getByGuid(guid: String): ResourceDto? = transaction {
|
||||||
|
Resources.selectAll().where { Resources.guid eq guid }.singleOrNull()?.toDto()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun create(dto: ResourceDto): Unit = transaction {
|
||||||
|
Resources.insert {
|
||||||
|
it[guid] = dto.guid
|
||||||
|
it[title] = dto.title
|
||||||
|
it[description] = dto.description
|
||||||
|
it[tags] = Json.encodeToString(dto.tags)
|
||||||
|
it[fileFormat] = dto.fileFormat
|
||||||
|
it[mimeType] = dto.mimeType
|
||||||
|
it[fileSize] = dto.fileSize
|
||||||
|
it[releaseDate] = dto.releaseDate
|
||||||
|
it[createdAt] = dto.createdAt
|
||||||
|
it[updatedAt] = dto.updatedAt
|
||||||
|
it[author] = dto.author
|
||||||
|
it[language] = dto.language
|
||||||
|
it[edition] = dto.edition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(guid: String, dto: ResourceDto): Boolean = transaction {
|
||||||
|
Resources.update({ Resources.guid eq guid }) {
|
||||||
|
it[title] = dto.title
|
||||||
|
it[description] = dto.description
|
||||||
|
it[tags] = Json.encodeToString(dto.tags)
|
||||||
|
it[fileFormat] = dto.fileFormat
|
||||||
|
it[mimeType] = dto.mimeType
|
||||||
|
it[fileSize] = dto.fileSize
|
||||||
|
it[releaseDate] = dto.releaseDate
|
||||||
|
it[updatedAt] = dto.updatedAt
|
||||||
|
it[author] = dto.author
|
||||||
|
it[language] = dto.language
|
||||||
|
it[edition] = dto.edition
|
||||||
|
} > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(guid: String): Boolean = transaction {
|
||||||
|
Resources.deleteWhere { Resources.guid eq guid } > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ResultRow.toDto(): ResourceDto = ResourceDto(
|
||||||
|
guid = this[Resources.guid],
|
||||||
|
title = this[Resources.title],
|
||||||
|
description = this[Resources.description],
|
||||||
|
tags = try { Json.decodeFromString<List<String>>(this[Resources.tags]) } catch (_: Exception) { emptyList() },
|
||||||
|
fileFormat = this[Resources.fileFormat],
|
||||||
|
mimeType = this[Resources.mimeType],
|
||||||
|
fileSize = this[Resources.fileSize],
|
||||||
|
releaseDate = this[Resources.releaseDate],
|
||||||
|
createdAt = this[Resources.createdAt],
|
||||||
|
updatedAt = this[Resources.updatedAt],
|
||||||
|
author = this[Resources.author],
|
||||||
|
language = this[Resources.language],
|
||||||
|
edition = this[Resources.edition],
|
||||||
|
downloadUrl = "/api/resources/${this[Resources.guid]}/download"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
package de.bollwerk.server.routes
|
||||||
|
|
||||||
|
import de.bollwerk.server.model.ErrorResponse
|
||||||
|
import de.bollwerk.server.repository.ResourceRepository
|
||||||
|
import de.bollwerk.server.security.UserPrincipal
|
||||||
|
import de.bollwerk.shared.model.ResourceDto
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.http.content.*
|
||||||
|
import io.ktor.server.auth.*
|
||||||
|
import io.ktor.server.request.*
|
||||||
|
import io.ktor.server.response.*
|
||||||
|
import io.ktor.server.routing.*
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
private const val MAX_RESOURCE_UPLOAD_SIZE = 25L * 1024 * 1024 // 25 MB
|
||||||
|
private val RESOURCE_STORAGE_DIR = File(System.getenv("BOLLWERK_RESOURCE_DIR") ?: "/opt/bollwerk/resources")
|
||||||
|
|
||||||
|
internal fun Route.resourceRoutes(resourceRepository: ResourceRepository) {
|
||||||
|
// Authenticated: catalog + download
|
||||||
|
route("/api/resources") {
|
||||||
|
get {
|
||||||
|
call.principal<UserPrincipal>()
|
||||||
|
?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(401, "Unauthorized"))
|
||||||
|
val resources = resourceRepository.getAll()
|
||||||
|
call.respond(HttpStatusCode.OK, resources)
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/{guid}/download") {
|
||||||
|
call.principal<UserPrincipal>()
|
||||||
|
?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(401, "Unauthorized"))
|
||||||
|
val guid = call.parameters["guid"]
|
||||||
|
?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse(400, "Missing guid"))
|
||||||
|
val resource = resourceRepository.getByGuid(guid)
|
||||||
|
?: return@get call.respond(HttpStatusCode.NotFound, ErrorResponse(404, "Resource not found"))
|
||||||
|
val file = File(RESOURCE_STORAGE_DIR, "${guid}.${resource.fileFormat}")
|
||||||
|
if (!file.exists()) {
|
||||||
|
return@get call.respond(HttpStatusCode.NotFound, ErrorResponse(404, "File not found on disk"))
|
||||||
|
}
|
||||||
|
call.response.header(HttpHeaders.ContentDisposition, "attachment; filename=\"${resource.title}.${resource.fileFormat}\"")
|
||||||
|
call.respondFile(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-only: CRUD
|
||||||
|
route("/api/admin/resources") {
|
||||||
|
post {
|
||||||
|
val principal = call.principal<UserPrincipal>()
|
||||||
|
?: return@post call.respond(HttpStatusCode.Unauthorized, ErrorResponse(401, "Unauthorized"))
|
||||||
|
if (!principal.isAdmin) {
|
||||||
|
return@post call.respond(HttpStatusCode.Forbidden, ErrorResponse(403, "Admin access required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val multipart = call.receiveMultipart()
|
||||||
|
var metadata: ResourceDto? = null
|
||||||
|
var fileBytes: ByteArray? = null
|
||||||
|
|
||||||
|
multipart.forEachPart { part ->
|
||||||
|
when (part) {
|
||||||
|
is PartData.FormItem -> {
|
||||||
|
if (part.name == "metadata") {
|
||||||
|
metadata = Json.decodeFromString<ResourceDto>(part.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is PartData.FileItem -> {
|
||||||
|
if (part.name == "file") {
|
||||||
|
fileBytes = part.streamProvider().readBytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
part.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
val meta = metadata ?: return@post call.respond(HttpStatusCode.BadRequest, ErrorResponse(400, "Missing metadata part"))
|
||||||
|
val bytes = fileBytes ?: return@post call.respond(HttpStatusCode.BadRequest, ErrorResponse(400, "Missing file part"))
|
||||||
|
|
||||||
|
if (bytes.size > MAX_RESOURCE_UPLOAD_SIZE) {
|
||||||
|
return@post call.respond(HttpStatusCode.PayloadTooLarge, ErrorResponse(413, "File too large (max 25 MB)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val guid = meta.guid.ifBlank { UUID.randomUUID().toString() }
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val dto = meta.copy(
|
||||||
|
guid = guid,
|
||||||
|
fileSize = bytes.size.toLong(),
|
||||||
|
createdAt = now,
|
||||||
|
updatedAt = now,
|
||||||
|
downloadUrl = "/api/resources/$guid/download"
|
||||||
|
)
|
||||||
|
|
||||||
|
RESOURCE_STORAGE_DIR.mkdirs()
|
||||||
|
File(RESOURCE_STORAGE_DIR, "${guid}.${dto.fileFormat}").writeBytes(bytes)
|
||||||
|
resourceRepository.create(dto)
|
||||||
|
call.respond(HttpStatusCode.Created, dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
put("/{guid}") {
|
||||||
|
val principal = call.principal<UserPrincipal>()
|
||||||
|
?: return@put call.respond(HttpStatusCode.Unauthorized, ErrorResponse(401, "Unauthorized"))
|
||||||
|
if (!principal.isAdmin) {
|
||||||
|
return@put call.respond(HttpStatusCode.Forbidden, ErrorResponse(403, "Admin access required"))
|
||||||
|
}
|
||||||
|
val guid = call.parameters["guid"]
|
||||||
|
?: return@put call.respond(HttpStatusCode.BadRequest, ErrorResponse(400, "Missing guid"))
|
||||||
|
val existing = resourceRepository.getByGuid(guid)
|
||||||
|
?: return@put call.respond(HttpStatusCode.NotFound, ErrorResponse(404, "Resource not found"))
|
||||||
|
val updated = call.receive<ResourceDto>()
|
||||||
|
val dto = updated.copy(guid = guid, updatedAt = System.currentTimeMillis(), downloadUrl = "/api/resources/$guid/download")
|
||||||
|
resourceRepository.update(guid, dto)
|
||||||
|
call.respond(HttpStatusCode.OK, dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete("/{guid}") {
|
||||||
|
val principal = call.principal<UserPrincipal>()
|
||||||
|
?: return@delete call.respond(HttpStatusCode.Unauthorized, ErrorResponse(401, "Unauthorized"))
|
||||||
|
if (!principal.isAdmin) {
|
||||||
|
return@delete call.respond(HttpStatusCode.Forbidden, ErrorResponse(403, "Admin access required"))
|
||||||
|
}
|
||||||
|
val guid = call.parameters["guid"]
|
||||||
|
?: return@delete call.respond(HttpStatusCode.BadRequest, ErrorResponse(400, "Missing guid"))
|
||||||
|
val resource = resourceRepository.getByGuid(guid)
|
||||||
|
?: return@delete call.respond(HttpStatusCode.NotFound, ErrorResponse(404, "Resource not found"))
|
||||||
|
|
||||||
|
// Delete file from disk
|
||||||
|
val file = File(RESOURCE_STORAGE_DIR, "${guid}.${resource.fileFormat}")
|
||||||
|
if (file.exists()) file.delete()
|
||||||
|
|
||||||
|
resourceRepository.delete(guid)
|
||||||
|
call.respond(HttpStatusCode.NoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue