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.MessageRepository
|
||||
import de.bollwerk.server.repository.UserRepository
|
||||
import de.bollwerk.server.repository.ResourceRepository
|
||||
import de.bollwerk.server.routes.adminRoutes
|
||||
import de.bollwerk.server.routes.authRoutes
|
||||
import de.bollwerk.server.routes.adminMessageRoutes
|
||||
import de.bollwerk.server.routes.resourceRoutes
|
||||
import de.bollwerk.server.routes.inventoryRoutes
|
||||
import de.bollwerk.server.routes.messageRoutes
|
||||
import de.bollwerk.server.routes.userRoutes
|
||||
|
|
@ -62,11 +64,13 @@ internal fun Application.configureRouting(
|
|||
|
||||
routing {
|
||||
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()
|
||||
if (contentLength != null && contentLength > MAX_BODY_SIZE) {
|
||||
if (contentLength != null && contentLength > effectiveMaxSize) {
|
||||
call.respond(
|
||||
HttpStatusCode.PayloadTooLarge,
|
||||
ErrorResponse(status = 413, message = "Request body too large (max 1 MB)")
|
||||
ErrorResponse(status = 413, message = "Request body too large")
|
||||
)
|
||||
finish()
|
||||
}
|
||||
|
|
@ -99,6 +103,7 @@ internal fun Application.configureRouting(
|
|||
messageRoutes(messageRepository, userRepository, wsManager)
|
||||
}
|
||||
userRoutes(userRepository, wsManager)
|
||||
resourceRoutes(ResourceRepository())
|
||||
}
|
||||
|
||||
// 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