diff --git a/server/src/main/kotlin/de/bollwerk/server/plugins/Routing.kt b/server/src/main/kotlin/de/bollwerk/server/plugins/Routing.kt index 4307369..b9a7665 100644 --- a/server/src/main/kotlin/de/bollwerk/server/plugins/Routing.kt +++ b/server/src/main/kotlin/de/bollwerk/server/plugins/Routing.kt @@ -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 diff --git a/server/src/main/kotlin/de/bollwerk/server/repository/ResourceRepository.kt b/server/src/main/kotlin/de/bollwerk/server/repository/ResourceRepository.kt new file mode 100644 index 0000000..5a2e2be --- /dev/null +++ b/server/src/main/kotlin/de/bollwerk/server/repository/ResourceRepository.kt @@ -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 = 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>(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" + ) +} diff --git a/server/src/main/kotlin/de/bollwerk/server/routes/ResourceRoutes.kt b/server/src/main/kotlin/de/bollwerk/server/routes/ResourceRoutes.kt new file mode 100644 index 0000000..8521697 --- /dev/null +++ b/server/src/main/kotlin/de/bollwerk/server/routes/ResourceRoutes.kt @@ -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() + ?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(401, "Unauthorized")) + val resources = resourceRepository.getAll() + call.respond(HttpStatusCode.OK, resources) + } + + get("/{guid}/download") { + call.principal() + ?: 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() + ?: 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(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() + ?: 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() + 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() + ?: 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) + } + } +}