Skip to content

Commit bb815b3

Browse files
authored
Merge pull request #1215 from GenSpectrum/main
chore: update prod from main
2 parents a8c9ee2 + 6b1fc74 commit bb815b3

92 files changed

Lines changed: 2205 additions & 766 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/build.gradle.kts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ dependencies {
3131
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6")
3232
implementation("org.flywaydb:flyway-database-postgresql:11.0.0")
3333
implementation("org.postgresql:postgresql:42.7.11")
34-
implementation("org.jetbrains.exposed:exposed-spring-boot-starter:1.2.0")
35-
implementation("org.jetbrains.exposed:exposed-json:1.2.0")
36-
implementation("org.jetbrains.exposed:exposed-jdbc:1.2.0")
37-
implementation("org.jetbrains.exposed:exposed-kotlin-datetime:1.2.0")
34+
implementation("org.jetbrains.exposed:exposed-spring-boot-starter:1.3.0")
35+
implementation("org.jetbrains.exposed:exposed-json:1.3.0")
36+
implementation("org.jetbrains.exposed:exposed-jdbc:1.3.0")
37+
implementation("org.jetbrains.exposed:exposed-kotlin-datetime:1.3.0")
3838
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0-0.6.x-compat")
3939
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
4040

backend/gradle/wrapper/gradle-wrapper.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
3-
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
44
networkTimeout=10000
55
retries=0
66
retryBackOffMs=500
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package org.genspectrum.dashboardsbackend.api
2+
3+
import kotlin.time.Instant
4+
5+
/** Public metadata about a user's API key. The raw key is never returned after creation. */
6+
data class ApiKeyMetadata(val createdAt: Instant, val lastUsedAt: Instant?)
7+
8+
/** Returned once when a key is first generated. The raw key is never stored and cannot be retrieved again. */
9+
data class GeneratedApiKey(val key: String, val createdAt: Instant)
10+
11+
/** Request body for the internal validate endpoint called by the proxy. */
12+
data class ValidateApiKeyRequest(val key: String)
13+
14+
/** Response from the internal validate endpoint — contains the internal user ID for the validated key. */
15+
data class ValidateApiKeyResponse(val userId: Long)

backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.genspectrum.dashboardsbackend.api
22

3+
import com.fasterxml.jackson.annotation.JsonInclude
34
import io.swagger.v3.oas.annotations.media.Schema
45
import kotlin.time.Instant
56

@@ -9,10 +10,10 @@ import kotlin.time.Instant
910
{
1011
"id": 1,
1112
"name": "My Collection",
12-
"ownedBy": "user123",
13+
"ownedBy": 123,
1314
"organism": "covid",
1415
"description": "A collection of interesting variants",
15-
"variants": [],
16+
"variantCount": 1,
1617
"createdAt": "2026-01-01T00:00:00Z",
1718
"updatedAt": "2026-01-02T00:00:00Z"
1819
}
@@ -21,10 +22,12 @@ import kotlin.time.Instant
2122
data class Collection(
2223
val id: Long,
2324
val name: String,
24-
val ownedBy: String,
25+
val ownedBy: Long,
2526
val organism: String,
2627
val description: String?,
27-
val variants: List<Variant>,
28+
val variantCount: Int,
29+
@JsonInclude(JsonInclude.Include.NON_NULL)
30+
val variants: List<Variant>?,
2831
val createdAt: Instant,
2932
val updatedAt: Instant,
3033
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package org.genspectrum.dashboardsbackend.api
2+
3+
import kotlin.time.Instant
4+
5+
data class User(
6+
val id: Long,
7+
val githubId: String?,
8+
val name: String,
9+
val email: String?,
10+
val createdAt: Instant,
11+
val updatedAt: Instant,
12+
)
13+
14+
data class UserSyncRequest(val githubId: String, val name: String, val email: String?)
15+
16+
data class PublicUser(val id: Long, val name: String)

backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import org.genspectrum.dashboardsbackend.controller.BadRequestException
44
import org.springframework.boot.context.properties.ConfigurationProperties
55

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

@@ -35,3 +35,17 @@ data class LapisConfig(
3535
)
3636

3737
data class ExternalNavigationLink(val url: String, val label: String, val menuIcon: String, val description: String)
38+
39+
data class SystemUserConfig(
40+
val githubId: String,
41+
val name: String,
42+
val email: String? = null,
43+
val apiKey: String? = null,
44+
) {
45+
init {
46+
require(apiKey == null || apiKey.length >= 32) { "systemUser.apiKey must be at least 32 characters" }
47+
}
48+
49+
override fun toString() =
50+
"SystemUserConfig(githubId=$githubId, name=$name, email=$email, apiKey=${if (apiKey != null) "***" else "null"})"
51+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package org.genspectrum.dashboardsbackend.config
2+
3+
import mu.KotlinLogging
4+
import org.genspectrum.dashboardsbackend.api.UserSyncRequest
5+
import org.genspectrum.dashboardsbackend.model.apikey.ApiKeyModel
6+
import org.genspectrum.dashboardsbackend.model.user.UserModel
7+
import org.springframework.boot.ApplicationArguments
8+
import org.springframework.boot.ApplicationRunner
9+
import org.springframework.stereotype.Component
10+
11+
private val log = KotlinLogging.logger {}
12+
13+
@Component
14+
class SystemUserInitializer(
15+
private val dashboardsConfig: DashboardsConfig,
16+
private val userModel: UserModel,
17+
private val apiKeyModel: ApiKeyModel,
18+
) : ApplicationRunner {
19+
override fun run(args: ApplicationArguments) {
20+
val config = dashboardsConfig.systemUser ?: return
21+
val user = userModel.syncUser(
22+
UserSyncRequest(githubId = config.githubId, name = config.name, email = config.email),
23+
)
24+
log.info { "System user ready: id=${user.id}, $config" }
25+
config.apiKey?.let {
26+
apiKeyModel.upsertApiKey(userId = user.id, rawKey = it)
27+
log.info { "System user API key upserted for userId=${user.id}" }
28+
}
29+
}
30+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package org.genspectrum.dashboardsbackend.controller
2+
3+
import io.swagger.v3.oas.annotations.Operation
4+
import org.genspectrum.dashboardsbackend.api.ApiKeyMetadata
5+
import org.genspectrum.dashboardsbackend.api.GeneratedApiKey
6+
import org.genspectrum.dashboardsbackend.api.ValidateApiKeyRequest
7+
import org.genspectrum.dashboardsbackend.api.ValidateApiKeyResponse
8+
import org.genspectrum.dashboardsbackend.model.apikey.ApiKeyModel
9+
import org.springframework.http.HttpStatus
10+
import org.springframework.http.MediaType
11+
import org.springframework.web.bind.annotation.DeleteMapping
12+
import org.springframework.web.bind.annotation.GetMapping
13+
import org.springframework.web.bind.annotation.PostMapping
14+
import org.springframework.web.bind.annotation.RequestBody
15+
import org.springframework.web.bind.annotation.RequestParam
16+
import org.springframework.web.bind.annotation.ResponseStatus
17+
import org.springframework.web.bind.annotation.RestController
18+
19+
@RestController
20+
class ApiKeyController(private val apiKeyModel: ApiKeyModel) {
21+
@GetMapping("/api-keys", produces = [MediaType.APPLICATION_JSON_VALUE])
22+
@Operation(
23+
summary = "Get API key metadata",
24+
description = "Returns metadata for the user's current API key. Returns 404 if no key exists.",
25+
)
26+
fun getApiKey(@RequestParam userId: Long): ApiKeyMetadata = apiKeyModel.getApiKey(userId)
27+
28+
@PostMapping("/api-keys", produces = [MediaType.APPLICATION_JSON_VALUE])
29+
@ResponseStatus(HttpStatus.CREATED)
30+
@Operation(
31+
summary = "Generate a new API key",
32+
description = "Generates a new API key for the user. Returns the raw key once - it is never stored. " +
33+
"Returns 409 if a key already exists.",
34+
)
35+
fun generateApiKey(@RequestParam userId: Long): GeneratedApiKey = apiKeyModel.generateApiKey(userId)
36+
37+
@DeleteMapping("/api-keys")
38+
@ResponseStatus(HttpStatus.NO_CONTENT)
39+
@Operation(
40+
summary = "Revoke API key",
41+
description = "Deletes the user's current API key. Returns 404 if no key exists.",
42+
)
43+
fun revokeApiKey(@RequestParam userId: Long) = apiKeyModel.revokeApiKey(userId)
44+
45+
@PostMapping("/internal/api-keys/validate", produces = [MediaType.APPLICATION_JSON_VALUE])
46+
@Operation(
47+
summary = "Validate an API key",
48+
description = """Takes a raw API key and returns the associated userId on success (200).
49+
Returns 404 if the key does not match any stored hash - the proxy treats this as an invalid
50+
credential and falls through to the session cookie check. Only reachable within the internal
51+
Docker network, so no additional access control is applied.""",
52+
)
53+
fun validateApiKey(@RequestBody request: ValidateApiKeyRequest): ValidateApiKeyResponse {
54+
val userId = apiKeyModel.validateApiKey(request.key)
55+
return ValidateApiKeyResponse(userId = userId)
56+
}
57+
}

backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,17 @@ class CollectionsController(private val collectionModel: CollectionModel) {
2323
@GetMapping("/collections", produces = [MediaType.APPLICATION_JSON_VALUE])
2424
@Operation(
2525
summary = "Get collections",
26-
description = "Returns collections filtered by optional userId and/or organism parameters.",
26+
description = "Returns collections filtered by optional userId and/or organism parameters. " +
27+
"Set includeVariants=true to include the full variant list; by default only variantCount is returned.",
2728
)
2829
fun getCollections(
29-
@RequestParam(required = false) userId: String?,
30+
@RequestParam(required = false) userId: Long?,
3031
@RequestParam(required = false) organism: String?,
32+
@RequestParam(required = false, defaultValue = "false") includeVariants: Boolean,
3133
): List<Collection> = collectionModel.getCollections(
3234
userId = userId,
3335
organism = organism,
36+
includeVariants = includeVariants,
3437
)
3538

3639
@GetMapping("/collections/{id}", produces = [MediaType.APPLICATION_JSON_VALUE])
@@ -50,7 +53,7 @@ class CollectionsController(private val collectionModel: CollectionModel) {
5053
)
5154
fun postCollection(
5255
@RequestBody collection: CollectionRequest,
53-
@UserIdParameter @RequestParam userId: String,
56+
@UserIdParameter @RequestParam userId: Long,
5457
): Collection = collectionModel.createCollection(
5558
request = collection,
5659
userId = userId,
@@ -65,7 +68,7 @@ class CollectionsController(private val collectionModel: CollectionModel) {
6568
fun putCollection(
6669
@RequestBody collection: CollectionUpdate,
6770
@Parameter(description = "The ID of the collection", example = "1") @PathVariable id: Long,
68-
@UserIdParameter @RequestParam userId: String,
71+
@UserIdParameter @RequestParam userId: Long,
6972
): Collection = collectionModel.putCollection(id, collection, userId)
7073

7174
@DeleteMapping("/collections/{id}")
@@ -76,6 +79,6 @@ class CollectionsController(private val collectionModel: CollectionModel) {
7679
)
7780
fun deleteCollection(
7881
@Parameter(description = "The ID of the collection", example = "1") @PathVariable id: Long,
79-
@UserIdParameter @RequestParam userId: String,
82+
@UserIdParameter @RequestParam userId: Long,
8083
) = collectionModel.deleteCollection(id, userId)
8184
}

backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/ExceptionHandler.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@ class ExceptionHandler : ResponseEntityExceptionHandler() {
5858
)
5959
}
6060

61+
@ExceptionHandler(ConflictException::class)
62+
@ResponseStatus(HttpStatus.CONFLICT)
63+
fun handleConflictException(e: Exception): ResponseEntity<ProblemDetail> {
64+
log.info { "Caught ${e.javaClass}: ${e.message}" }
65+
66+
return responseEntity(
67+
HttpStatus.CONFLICT,
68+
e.message,
69+
)
70+
}
71+
6172
private fun responseEntity(httpStatus: HttpStatus, detail: String?): ResponseEntity<ProblemDetail> =
6273
responseEntity(httpStatus, httpStatus.reasonPhrase, detail)
6374

@@ -92,3 +103,4 @@ class ExceptionHandler : ResponseEntityExceptionHandler() {
92103
class BadRequestException(message: String) : RuntimeException(message)
93104
class NotFoundException(message: String) : RuntimeException(message)
94105
class ForbiddenException(message: String) : RuntimeException(message)
106+
class ConflictException(message: String) : RuntimeException(message)

0 commit comments

Comments
 (0)