Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 342ceee28d | |||
| ecf82b0e2e | |||
| c100b52720 | |||
| 8d4a87f077 |
@@ -33,6 +33,7 @@ dependencies {
|
|||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-jdbc")
|
implementation("org.springframework.boot:spring-boot-starter-jdbc")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-flyway")
|
implementation("org.springframework.boot:spring-boot-starter-flyway")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||||
implementation("org.postgresql:postgresql")
|
implementation("org.postgresql:postgresql")
|
||||||
implementation("org.flywaydb:flyway-database-postgresql")
|
implementation("org.flywaydb:flyway-database-postgresql")
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ data:
|
|||||||
forward-headers-strategy: framework
|
forward-headers-strategy: framework
|
||||||
|
|
||||||
app:
|
app:
|
||||||
|
api-key: ${API_KEY}
|
||||||
jellyfin:
|
jellyfin:
|
||||||
url: {{ $.Values.app.jellyfin.url | quote }}
|
url: {{ $.Values.app.jellyfin.url | quote }}
|
||||||
|
api-key: ${JELLYFIN_API_KEY}
|
||||||
s3:
|
s3:
|
||||||
url: {{ $.Values.app.s3.url | quote }}
|
url: {{ $.Values.app.s3.url | quote }}
|
||||||
access-key: ${S3_ACCESS_KEY}
|
access-key: ${S3_ACCESS_KEY}
|
||||||
|
|||||||
@@ -69,16 +69,26 @@ spec:
|
|||||||
key: dbPassword
|
key: dbPassword
|
||||||
- name: DB_URL
|
- name: DB_URL
|
||||||
value: "jdbc:postgresql://{{ $.Values.db.host }}:{{ $.Values.db.port }}/{{ $.Values.db.name }}"
|
value: "jdbc:postgresql://{{ $.Values.db.host }}:{{ $.Values.db.port }}/{{ $.Values.db.name }}"
|
||||||
|
- name: API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-secret
|
||||||
|
key: apiKey
|
||||||
|
- name: JELLYFIN_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-secret
|
||||||
|
key: jellyfinApiKey
|
||||||
- name: S3_ACCESS_KEY
|
- name: S3_ACCESS_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-secret
|
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-secret
|
||||||
key: app.s3.accessKey
|
key: accessKey
|
||||||
- name: S3_SECRET_KEY
|
- name: S3_SECRET_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-secret
|
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-secret
|
||||||
key: app.s3.secretKey
|
key: secretKey
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- mountPath: '/opt/app/config'
|
- mountPath: '/opt/app/config'
|
||||||
name: application-properties-volume
|
name: application-properties-volume
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}"
|
||||||
|
annotations: {}
|
||||||
|
spec:
|
||||||
|
rules:
|
||||||
|
- host: {{.Values.ingress.host }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- backend:
|
||||||
|
service:
|
||||||
|
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}"
|
||||||
|
port:
|
||||||
|
number: {{ .Values.port }}
|
||||||
|
path: /
|
||||||
|
pathType: Prefix
|
||||||
@@ -8,3 +8,5 @@ data:
|
|||||||
dbPassword: {{ (required "DB password не установлен" $.Values.db.password) | b64enc | quote }}
|
dbPassword: {{ (required "DB password не установлен" $.Values.db.password) | b64enc | quote }}
|
||||||
accessKey: {{ (required "Accesskey не установлен" $.Values.app.s3.accessKey) | b64enc | quote }}
|
accessKey: {{ (required "Accesskey не установлен" $.Values.app.s3.accessKey) | b64enc | quote }}
|
||||||
secretKey: {{ (required "Secretkey не установлен" $.Values.app.s3.secretKey) | b64enc | quote }}
|
secretKey: {{ (required "Secretkey не установлен" $.Values.app.s3.secretKey) | b64enc | quote }}
|
||||||
|
apiKey: {{ (required "ApiKey не установлен" $.Values.app.apiKey) | b64enc | quote }}
|
||||||
|
jellyfinApiKey: {{ (required "Jellyfin ApiKey не установлен" $.Values.app.jellyfin.apiKey) | b64enc | quote }}
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ db:
|
|||||||
maxConnections: 10
|
maxConnections: 10
|
||||||
|
|
||||||
app:
|
app:
|
||||||
|
apiKey: null
|
||||||
jellyfin:
|
jellyfin:
|
||||||
url: null
|
url: null
|
||||||
|
apiKey: null
|
||||||
s3:
|
s3:
|
||||||
url: null
|
url: null
|
||||||
accessKey: null
|
accessKey: null
|
||||||
@@ -35,3 +37,5 @@ app:
|
|||||||
|
|
||||||
securityContext:
|
securityContext:
|
||||||
privileged: false
|
privileged: false
|
||||||
|
ingress:
|
||||||
|
host: null
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package pw.binom.mirror.api.config
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.core.Ordered
|
||||||
|
import org.springframework.core.annotation.Order
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter
|
||||||
|
import pw.binom.mirror.api.dto.ErrorResponse
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
class ApiKeyFilter(
|
||||||
|
private val properties: AppProperties,
|
||||||
|
private val json: Json,
|
||||||
|
) : OncePerRequestFilter() {
|
||||||
|
|
||||||
|
private val log = LoggerFactory.getLogger(ApiKeyFilter::class.java)
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (properties.apiKey.isEmpty()) {
|
||||||
|
log.warn("app.api-key is empty, API is unprotected (dev mode)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun doFilterInternal(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
filterChain: FilterChain,
|
||||||
|
) {
|
||||||
|
val apiKey = properties.apiKey
|
||||||
|
if (apiKey.isEmpty()) {
|
||||||
|
filterChain.doFilter(request, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (request.requestURI.startsWith("/api/") && request.getHeader("X-API-Key") != apiKey) {
|
||||||
|
response.status = HttpStatus.UNAUTHORIZED.value()
|
||||||
|
response.contentType = MediaType.APPLICATION_JSON_VALUE
|
||||||
|
response.writer.write(json.encodeToString(ErrorResponse("Invalid or missing X-API-Key")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filterChain.doFilter(request, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ data class AppProperties(
|
|||||||
val jellyfin: Jellyfin,
|
val jellyfin: Jellyfin,
|
||||||
@param:DefaultValue
|
@param:DefaultValue
|
||||||
val s3: S3,
|
val s3: S3,
|
||||||
|
@param:DefaultValue("")
|
||||||
|
val apiKey: String,
|
||||||
) {
|
) {
|
||||||
data class Jellyfin(
|
data class Jellyfin(
|
||||||
@param:DefaultValue("https://jellyfin.binom.pw/")
|
@param:DefaultValue("https://jellyfin.binom.pw/")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
app:
|
app:
|
||||||
|
api-key: ${MIRROR_API_KEY:}
|
||||||
jellyfin:
|
jellyfin:
|
||||||
url: https://jellyfin.binom.pw/
|
url: https://jellyfin.binom.pw/
|
||||||
api-key: ${JELLYFIN_API_KEY}
|
api-key: ${JELLYFIN_API_KEY}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package pw.binom.mirror.api
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
|
||||||
|
@SpringBootTest(properties = ["app.api-key=test-secret"])
|
||||||
|
class ApiKeyFilterTest : AbstractIntegrationTest() {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
lateinit var mockMvc: MockMvc
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing X-API-Key returns 401`() {
|
||||||
|
mockMvc.perform(get("/api/mirror"))
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invalid X-API-Key returns 401`() {
|
||||||
|
mockMvc.perform(get("/api/mirror").header("X-API-Key", "wrong-key"))
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid X-API-Key is accepted`() {
|
||||||
|
mockMvc.perform(get("/api/mirror").header("X-API-Key", "test-secret"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `actuator health is not protected`() {
|
||||||
|
mockMvc.perform(get("/actuator/health"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package pw.binom.mirror.api
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
|
||||||
|
class DevModeFilterTest : AbstractIntegrationTest() {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
lateinit var mockMvc: MockMvc
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty api key allows requests without X-API-Key`() {
|
||||||
|
mockMvc.perform(get("/api/mirror"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user