Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ dependencies {
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6")
implementation("org.flywaydb:flyway-database-postgresql:11.0.0")
implementation("org.postgresql:postgresql:42.7.11")
implementation("org.jetbrains.exposed:exposed-spring-boot-starter:1.2.0")
implementation("org.jetbrains.exposed:exposed-json:1.2.0")
implementation("org.jetbrains.exposed:exposed-jdbc:1.2.0")
implementation("org.jetbrains.exposed:exposed-kotlin-datetime:1.2.0")
implementation("org.jetbrains.exposed:exposed-spring-boot-starter:1.3.0")
implementation("org.jetbrains.exposed:exposed-json:1.3.0")
implementation("org.jetbrains.exposed:exposed-jdbc:1.3.0")
implementation("org.jetbrains.exposed:exposed-kotlin-datetime:1.3.0")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0-0.6.x-compat")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

Expand Down
2 changes: 1 addition & 1 deletion backend/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.genspectrum.dashboardsbackend.api

import kotlin.time.Instant

/** Public metadata about a user's API key. The raw key is never returned after creation. */
data class ApiKeyMetadata(val createdAt: Instant, val lastUsedAt: Instant?)

/** Returned once when a key is first generated. The raw key is never stored and cannot be retrieved again. */
data class GeneratedApiKey(val key: String, val createdAt: Instant)

/** Request body for the internal validate endpoint called by the proxy. */
data class ValidateApiKeyRequest(val key: String)

/** Response from the internal validate endpoint — contains the internal user ID for the validated key. */
data class ValidateApiKeyResponse(val userId: Long)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.genspectrum.dashboardsbackend.api

import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import kotlin.time.Instant

Expand All @@ -9,10 +10,10 @@ import kotlin.time.Instant
{
"id": 1,
"name": "My Collection",
"ownedBy": "user123",
"ownedBy": 123,
"organism": "covid",
"description": "A collection of interesting variants",
"variants": [],
"variantCount": 1,
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-02T00:00:00Z"
}
Expand All @@ -21,10 +22,12 @@ import kotlin.time.Instant
data class Collection(
val id: Long,
val name: String,
val ownedBy: String,
val ownedBy: Long,
val organism: String,
val description: String?,
val variants: List<Variant>,
val variantCount: Int,
@JsonInclude(JsonInclude.Include.NON_NULL)
val variants: List<Variant>?,
val createdAt: Instant,
val updatedAt: Instant,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.genspectrum.dashboardsbackend.api

import kotlin.time.Instant

data class User(
val id: Long,
val githubId: String?,
val name: String,
val email: String?,
val createdAt: Instant,
val updatedAt: Instant,
)

data class UserSyncRequest(val githubId: String, val name: String, val email: String?)

data class PublicUser(val id: Long, val name: String)
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import org.genspectrum.dashboardsbackend.controller.BadRequestException
import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties(prefix = "dashboards")
data class DashboardsConfig(val organisms: Map<String, OrganismConfig>) {
data class DashboardsConfig(val organisms: Map<String, OrganismConfig>, val systemUser: SystemUserConfig? = null) {
fun getOrganismConfig(organism: String) = organisms[organism]
?: throw IllegalArgumentException("No configuration found for organism $organism")

Expand Down Expand Up @@ -35,3 +35,17 @@ data class LapisConfig(
)

data class ExternalNavigationLink(val url: String, val label: String, val menuIcon: String, val description: String)

data class SystemUserConfig(
val githubId: String,
val name: String,
val email: String? = null,
val apiKey: String? = null,
) {
init {
require(apiKey == null || apiKey.length >= 32) { "systemUser.apiKey must be at least 32 characters" }
}

override fun toString() =
"SystemUserConfig(githubId=$githubId, name=$name, email=$email, apiKey=${if (apiKey != null) "***" else "null"})"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.genspectrum.dashboardsbackend.config

import mu.KotlinLogging
import org.genspectrum.dashboardsbackend.api.UserSyncRequest
import org.genspectrum.dashboardsbackend.model.apikey.ApiKeyModel
import org.genspectrum.dashboardsbackend.model.user.UserModel
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
import org.springframework.stereotype.Component

private val log = KotlinLogging.logger {}

@Component
class SystemUserInitializer(
private val dashboardsConfig: DashboardsConfig,
private val userModel: UserModel,
private val apiKeyModel: ApiKeyModel,
) : ApplicationRunner {
override fun run(args: ApplicationArguments) {
val config = dashboardsConfig.systemUser ?: return
val user = userModel.syncUser(
UserSyncRequest(githubId = config.githubId, name = config.name, email = config.email),
)
log.info { "System user ready: id=${user.id}, $config" }
config.apiKey?.let {
apiKeyModel.upsertApiKey(userId = user.id, rawKey = it)
log.info { "System user API key upserted for userId=${user.id}" }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package org.genspectrum.dashboardsbackend.controller

import io.swagger.v3.oas.annotations.Operation
import org.genspectrum.dashboardsbackend.api.ApiKeyMetadata
import org.genspectrum.dashboardsbackend.api.GeneratedApiKey
import org.genspectrum.dashboardsbackend.api.ValidateApiKeyRequest
import org.genspectrum.dashboardsbackend.api.ValidateApiKeyResponse
import org.genspectrum.dashboardsbackend.model.apikey.ApiKeyModel
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController

@RestController
class ApiKeyController(private val apiKeyModel: ApiKeyModel) {
@GetMapping("/api-keys", produces = [MediaType.APPLICATION_JSON_VALUE])
@Operation(
summary = "Get API key metadata",
description = "Returns metadata for the user's current API key. Returns 404 if no key exists.",
)
fun getApiKey(@RequestParam userId: Long): ApiKeyMetadata = apiKeyModel.getApiKey(userId)

@PostMapping("/api-keys", produces = [MediaType.APPLICATION_JSON_VALUE])
@ResponseStatus(HttpStatus.CREATED)
@Operation(
summary = "Generate a new API key",
description = "Generates a new API key for the user. Returns the raw key once - it is never stored. " +
"Returns 409 if a key already exists.",
)
fun generateApiKey(@RequestParam userId: Long): GeneratedApiKey = apiKeyModel.generateApiKey(userId)

@DeleteMapping("/api-keys")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(
summary = "Revoke API key",
description = "Deletes the user's current API key. Returns 404 if no key exists.",
)
fun revokeApiKey(@RequestParam userId: Long) = apiKeyModel.revokeApiKey(userId)

@PostMapping("/internal/api-keys/validate", produces = [MediaType.APPLICATION_JSON_VALUE])
@Operation(
summary = "Validate an API key",
description = """Takes a raw API key and returns the associated userId on success (200).
Returns 404 if the key does not match any stored hash - the proxy treats this as an invalid
credential and falls through to the session cookie check. Only reachable within the internal
Docker network, so no additional access control is applied.""",
)
fun validateApiKey(@RequestBody request: ValidateApiKeyRequest): ValidateApiKeyResponse {
val userId = apiKeyModel.validateApiKey(request.key)
return ValidateApiKeyResponse(userId = userId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,17 @@ class CollectionsController(private val collectionModel: CollectionModel) {
@GetMapping("/collections", produces = [MediaType.APPLICATION_JSON_VALUE])
@Operation(
summary = "Get collections",
description = "Returns collections filtered by optional userId and/or organism parameters.",
description = "Returns collections filtered by optional userId and/or organism parameters. " +
"Set includeVariants=true to include the full variant list; by default only variantCount is returned.",
)
fun getCollections(
@RequestParam(required = false) userId: String?,
@RequestParam(required = false) userId: Long?,
@RequestParam(required = false) organism: String?,
@RequestParam(required = false, defaultValue = "false") includeVariants: Boolean,
): List<Collection> = collectionModel.getCollections(
userId = userId,
organism = organism,
includeVariants = includeVariants,
)

@GetMapping("/collections/{id}", produces = [MediaType.APPLICATION_JSON_VALUE])
Expand All @@ -50,7 +53,7 @@ class CollectionsController(private val collectionModel: CollectionModel) {
)
fun postCollection(
@RequestBody collection: CollectionRequest,
@UserIdParameter @RequestParam userId: String,
@UserIdParameter @RequestParam userId: Long,
): Collection = collectionModel.createCollection(
request = collection,
userId = userId,
Expand All @@ -65,7 +68,7 @@ class CollectionsController(private val collectionModel: CollectionModel) {
fun putCollection(
@RequestBody collection: CollectionUpdate,
@Parameter(description = "The ID of the collection", example = "1") @PathVariable id: Long,
@UserIdParameter @RequestParam userId: String,
@UserIdParameter @RequestParam userId: Long,
): Collection = collectionModel.putCollection(id, collection, userId)

@DeleteMapping("/collections/{id}")
Expand All @@ -76,6 +79,6 @@ class CollectionsController(private val collectionModel: CollectionModel) {
)
fun deleteCollection(
@Parameter(description = "The ID of the collection", example = "1") @PathVariable id: Long,
@UserIdParameter @RequestParam userId: String,
@UserIdParameter @RequestParam userId: Long,
) = collectionModel.deleteCollection(id, userId)
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ class ExceptionHandler : ResponseEntityExceptionHandler() {
)
}

@ExceptionHandler(ConflictException::class)
@ResponseStatus(HttpStatus.CONFLICT)
fun handleConflictException(e: Exception): ResponseEntity<ProblemDetail> {
log.info { "Caught ${e.javaClass}: ${e.message}" }

return responseEntity(
HttpStatus.CONFLICT,
e.message,
)
}

private fun responseEntity(httpStatus: HttpStatus, detail: String?): ResponseEntity<ProblemDetail> =
responseEntity(httpStatus, httpStatus.reasonPhrase, detail)

Expand Down Expand Up @@ -92,3 +103,4 @@ class ExceptionHandler : ResponseEntityExceptionHandler() {
class BadRequestException(message: String) : RuntimeException(message)
class NotFoundException(message: String) : RuntimeException(message)
class ForbiddenException(message: String) : RuntimeException(message)
class ConflictException(message: String) : RuntimeException(message)
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class SubscriptionsController(
)
fun getSubscription(
@IdParameter @PathVariable id: String,
@UserIdParameter @RequestParam userId: String,
@UserIdParameter @RequestParam userId: Long,
): Subscription = subscriptionModel.getSubscription(
subscriptionId = id,
userId = userId,
Expand All @@ -47,7 +47,7 @@ class SubscriptionsController(
summary = "Get all subscriptions of a user",
description = "Returns a list of all subscriptions of a user.",
)
fun getSubscriptions(@UserIdParameter @RequestParam userId: String): List<Subscription> =
fun getSubscriptions(@UserIdParameter @RequestParam userId: Long): List<Subscription> =
subscriptionModel.getSubscriptions(userId)

@PostMapping("/subscriptions")
Expand All @@ -58,7 +58,7 @@ class SubscriptionsController(
)
fun postSubscriptions(
@RequestBody subscription: SubscriptionRequest,
@UserIdParameter @RequestParam userId: String,
@UserIdParameter @RequestParam userId: Long,
): Subscription = subscriptionModel.postSubscriptions(
request = subscription,
userId = userId,
Expand All @@ -70,7 +70,7 @@ class SubscriptionsController(
summary = "Delete a subscription",
description = "Deletes a specific subscription of a user by its uuid.",
)
fun deleteSubscription(@IdParameter @PathVariable id: String, @UserIdParameter @RequestParam userId: String) {
fun deleteSubscription(@IdParameter @PathVariable id: String, @UserIdParameter @RequestParam userId: Long) {
subscriptionModel.deleteSubscription(
subscriptionId = id,
userId = userId,
Expand All @@ -85,7 +85,7 @@ class SubscriptionsController(
fun putSubscription(
@RequestBody subscription: SubscriptionUpdate,
@IdParameter @PathVariable id: String,
@UserIdParameter @RequestParam userId: String,
@UserIdParameter @RequestParam userId: Long,
): Subscription = subscriptionModel.putSubscription(
subscriptionId = id,
subscriptionUpdate = subscription,
Expand All @@ -99,7 +99,7 @@ class SubscriptionsController(
)
fun evaluateTrigger(
@IdParameter @RequestParam id: String,
@UserIdParameter @RequestParam userId: String,
@UserIdParameter @RequestParam userId: Long,
): TriggerEvaluationResponse {
val triggerEvaluationResult = triggerEvaluationModel.evaluateSubscriptionTrigger(
subscriptionId = id,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.genspectrum.dashboardsbackend.controller

import io.swagger.v3.oas.annotations.Operation
import org.genspectrum.dashboardsbackend.api.PublicUser
import org.genspectrum.dashboardsbackend.api.User
import org.genspectrum.dashboardsbackend.api.UserSyncRequest
import org.genspectrum.dashboardsbackend.model.user.UserModel
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController

@RestController
class UsersController(private val userModel: UserModel) {
@PostMapping("/users/sync", produces = [MediaType.APPLICATION_JSON_VALUE])
@Operation(
summary = "Sync user from external auth provider",
description = "Upserts a user record by github_id. Returns the user with their internal ID.",
)
fun syncUser(@RequestBody request: UserSyncRequest): User = userModel.syncUser(request)

@GetMapping("/users/{id}", produces = [MediaType.APPLICATION_JSON_VALUE])
@Operation(
summary = "Get user by internal ID",
description = "Returns public user info by internal ID.",
)
fun getUser(@PathVariable id: Long): PublicUser = userModel.getUser(id)
}
Loading