feat(server): add REST-API for resources (CRUD + download)

Closes #120
This commit is contained in:
Jens Reinemann 2026-05-18 22:06:24 +02:00
parent ab2cbff8ba
commit ab5aad8f3e
3 changed files with 216 additions and 2 deletions

View file

@ -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

View file

@ -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"
)
}

View file

@ -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)
}
}
}