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

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