Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#60 Store photos in MinIO #77

Merged
merged 13 commits into from
Jan 15, 2025
14 changes: 3 additions & 11 deletions backend/config/env/.env
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,8 @@ ITMO_DATING_POSTGRES_DB="postgres"
ITMO_DATING_POSTGRES_USER="postgres"
ITMO_DATING_POSTGRES_PASSWORD="postgres"

ITMO_DATING_AUTHIK_POSTGRES_DB="postgres"
ITMO_DATING_AUTHIK_POSTGRES_USER="postgres"
ITMO_DATING_AUTHIK_POSTGRES_PASSWORD="postgres"
ITMO_DATING_MINIO_ROOT_USER="minioadmin"
ITMO_DATING_MINIO_ROOT_PASSWORD="minioadmin"

ITMO_DATING_AUTHIK_TELEGRAM_BOT_TOKEN="bot_token_here"
ITMO_DATING_AUTHIK_TOKEN_SIGN_KEY_PRIVATE="RSA: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"

ITMO_DATING_MATCHMAKER_POSTGRES_DB="postgres"
ITMO_DATING_MATCHMAKER_POSTGRES_USER="postgres"
ITMO_DATING_MATCHMAKER_POSTGRES_PASSWORD="postgres"

ITMO_DATING_PEOPLE_POSTGRES_DB="postgres"
ITMO_DATING_PEOPLE_POSTGRES_USER="postgres"
ITMO_DATING_PEOPLE_POSTGRES_PASSWORD="postgres"
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package ru.ifmo.se.dating.gateway

import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
Expand All @@ -10,30 +11,46 @@ import reactor.core.publisher.Mono

@Component
class CorsFilter : WebFilter {
// FIXME
private val allowMethods = listOf(
HttpMethod.GET,
HttpMethod.PUT,
HttpMethod.POST,
HttpMethod.PATCH,
HttpMethod.DELETE,
HttpMethod.OPTIONS,
)

private val exposeHeaders = listOf(
"DNT",
"X-CustomHeader",
"Keep-Alive",
"User-Agent",
"X-Requested-With",
"If-Modified-Since",
"Cache-Control",
"Content-Type",
"Content-Range",
"Range",
)

override fun filter(ctx: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
ctx.response.headers.add("Access-Control-Allow-Origin", "*")
ctx.response.headers.add(
"Access-Control-Allow-Methods",
"GET, PUT, POST, PATCH, DELETE, OPTIONS"
)
ctx.response.headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*")
ctx.response.headers.add(
"Access-Control-Allow-Headers",
// "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With," +
// "If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Authorization"
"*"
HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS,
allowMethods.joinToString(", ") { it.name() },
)
ctx.response.headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "*")

if (ctx.request.method == HttpMethod.OPTIONS) {
ctx.response.headers.add("Access-Control-Max-Age", "1728000")
ctx.response.headers.add(HttpHeaders.ACCESS_CONTROL_MAX_AGE, 1_728_000.toString())
ctx.response.statusCode = HttpStatus.NO_CONTENT
return Mono.empty()
} else {
ctx.response.headers.add(
"Access-Control-Expose-Headers",
"DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With," +
"If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range"
)
return chain.filter(ctx) ?: Mono.empty()
}

ctx.response.headers.add(
HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS,
exposeHeaders.joinToString(","),
)
return chain.filter(ctx) ?: Mono.empty()
}
}
18 changes: 18 additions & 0 deletions backend/gateway/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,24 @@ spring:
- Method=PATCH
- Path=/api/people/*

- id: post-people-person-id-photos
uri: lb://people
predicates:
- Method=POST
- Path=/api/people/*/photos

- id: get-people-person-id-photos-picture-id
uri: lb://people
predicates:
- Method=GET
- Path=/api/people/*/photos/*

- id: delete-people-person-id-photos-picture-id
uri: lb://people
predicates:
- Method=DELETE
- Path=/api/people/*/photos/*

- id: get-people-faculties
uri: lb://people
predicates:
Expand Down
4 changes: 4 additions & 0 deletions backend/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ org-liquibase-liquibase-core = "4.29.2"
org-postgresql-postgresql = "42.7.4"
org-postgresql-r2dbc-postgresql = "1.0.7.RELEASE"

io-minio-minio = "8.5.15"

com-fasterxml-jackson = "2.18.2"

jakarta-validation-jakarta-validation-api = "3.1.0"
Expand Down Expand Up @@ -64,6 +66,8 @@ org-jooq-jooq-kotlin = { module = "org.jooq:jooq-kotlin", version.ref = "org-joo
org-jooq-jooq-meta-extensions = { module = "org.jooq:jooq-meta-extensions", version.ref = "org-jooq" }
org-jooq-jooq-meta-kotlin = { module = "org.jooq:jooq-meta-kotlin", version.ref = "org-jooq" }

io-minio-minio = { module = "io.minio:minio", version.ref = "io-minio-minio" }

org-liquibase-liquibase-core = { module = "org.liquibase:liquibase-core", version.ref = "org-liquibase-liquibase-core" }
org-postgresql-postgresql = { module = "org.postgresql:postgresql", version.ref = "org-postgresql-postgresql" }
org-postgresql-r2dbc-postgresql = { module = "org.postgresql:r2dbc-postgresql", version.ref = "org-postgresql-r2dbc-postgresql" }
Expand Down
1 change: 1 addition & 0 deletions backend/people/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ plugins {
}

dependencies {
implementation(libs.io.minio.minio)
implementation(project(":foundation"))
testImplementation(project(":foundation-test"))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package ru.ifmo.se.dating.people.api

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.springframework.core.io.ByteArrayResource
import org.springframework.core.io.Resource
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
import ru.ifmo.se.dating.exception.AuthorizationException
Expand All @@ -11,15 +13,20 @@ import ru.ifmo.se.dating.people.api.generated.PeopleApiDelegate
import ru.ifmo.se.dating.people.api.mapping.toMessage
import ru.ifmo.se.dating.people.api.mapping.toModel
import ru.ifmo.se.dating.people.logic.PersonService
import ru.ifmo.se.dating.people.logic.PictureService
import ru.ifmo.se.dating.people.model.Faculty
import ru.ifmo.se.dating.people.model.Picture
import ru.ifmo.se.dating.people.model.generated.*
import ru.ifmo.se.dating.security.auth.User
import ru.ifmo.se.dating.spring.security.auth.SpringSecurityContext
import java.time.LocalDate
import java.time.OffsetDateTime

@Controller
class HttpPeopleApi(private val service: PersonService) : PeopleApiDelegate {
class HttpPeopleApi(
private val personService: PersonService,
private val pictureService: PictureService,
) : PeopleApiDelegate {
override fun peopleGet(
offset: Long,
limit: Long,
Expand Down Expand Up @@ -57,7 +64,7 @@ class HttpPeopleApi(private val service: PersonService) : PeopleApiDelegate {
TODO("Unsupported GET /people query parameter was provided")
}

return service.getFiltered(
return personService.getFiltered(
Page(offset = offset.toInt(), limit = limit.toInt()),
PersonService.Filter(
firstName = firstName?.let { Regex(it) } ?: Regex(".*"),
Expand All @@ -76,13 +83,13 @@ class HttpPeopleApi(private val service: PersonService) : PeopleApiDelegate {
throw AuthorizationException("caller $callerId can't delete $targetId")
}

service.delete(targetId)
personService.delete(targetId)

return ResponseEntity.ok(Unit)
}

override suspend fun peoplePersonIdGet(personId: Long): ResponseEntity<PersonVariantMessage> =
service.getById(User.Id(personId.toInt()))
personService.getById(User.Id(personId.toInt()))
?.toMessage()
.orThrowNotFound("person with id $personId not found")
.let { ResponseEntity.ok(it) }
Expand All @@ -95,10 +102,50 @@ class HttpPeopleApi(private val service: PersonService) : PeopleApiDelegate {
val model = personPatchMessage.toModel(personId.toInt())

when (status) {
PersonStatusMessage.draft -> service.edit(model)
PersonStatusMessage.ready -> service.save(model)
PersonStatusMessage.draft -> personService.edit(model)
PersonStatusMessage.ready -> personService.save(model)
}

return ResponseEntity.ok(Unit)
}

override suspend fun peoplePersonIdPhotosPost(
personId: Long,
body: Resource?,
): ResponseEntity<PictureMessage> {
require(body != null)

val callerId = SpringSecurityContext.principal()
val targetId = User.Id(personId.toInt())
if (callerId != targetId) {
throw AuthorizationException("caller $callerId can't post photo to $targetId profile")
}

val pictureId = pictureService.save(Picture.Content(body.contentAsByteArray))

return PictureMessage(id = pictureId.number.toLong()).let { ResponseEntity.ok(it) }
}

override suspend fun peoplePersonIdPhotosPictureIdGet(
personId: Long,
pictureId: Long,
): ResponseEntity<Resource> {
val picture = pictureService.getById(Picture.Id(pictureId.toInt()))
return ByteArrayResource(picture.bytes).let { ResponseEntity.ok(it) }
}

override suspend fun peoplePersonIdPhotosPictureIdDelete(
personId: Long,
pictureId: Long,
): ResponseEntity<Unit> {
val callerId = SpringSecurityContext.principal()
val targetId = User.Id(personId.toInt())
if (callerId != targetId) {
throw AuthorizationException("caller $callerId can't remove photo of $targetId profile")
}

pictureService.remove(Picture.Id(pictureId.toInt()))

return ResponseEntity.ok(Unit)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package ru.ifmo.se.dating.people.logic

import ru.ifmo.se.dating.people.model.Picture

interface PictureService {
suspend fun getById(id: Picture.Id): Picture.Content
suspend fun save(content: Picture.Content): Picture.Id
suspend fun remove(id: Picture.Id)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package ru.ifmo.se.dating.people.logic.basic

import org.springframework.stereotype.Service
import ru.ifmo.se.dating.people.logic.PictureService
import ru.ifmo.se.dating.people.model.Picture
import ru.ifmo.se.dating.people.storage.PictureContentStorage
import ru.ifmo.se.dating.people.storage.PictureRecordStorage

@Service
class BasicPictureService(
private val recordStorage: PictureRecordStorage,
private val contentStorage: PictureContentStorage,
) : PictureService {
override suspend fun getById(id: Picture.Id): Picture.Content =
contentStorage.download(id)

override suspend fun save(content: Picture.Content): Picture.Id {
val picture = recordStorage.insert()
contentStorage.upload(picture.id, content)
recordStorage.setIsReferenced(picture.id, isReferenced = true)
return picture.id
}

override suspend fun remove(id: Picture.Id) {
recordStorage.setIsReferenced(id, isReferenced = false)
contentStorage.remove(id)
recordStorage.delete(id)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package ru.ifmo.se.dating.people.model

import ru.ifmo.se.dating.validation.expectId

data class Picture(val id: Id, val isReferenced: Boolean) {
@JvmInline
value class Id(val number: Int) {
init {
expectId(number)
}

override fun toString(): String = number.toString()
}

data class Content(val bytes: ByteArray) {
override fun equals(other: Any?): Boolean =
this === other ||
javaClass == other?.javaClass &&
bytes.contentEquals((other as Content).bytes)

override fun hashCode(): Int =
bytes.contentHashCode()
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
package ru.ifmo.se.dating.people.security

import org.springframework.http.HttpMethod
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher
import org.springframework.stereotype.Component
import ru.ifmo.se.dating.spring.security.auth.And
import ru.ifmo.se.dating.spring.security.auth.Not
import ru.ifmo.se.dating.spring.security.auth.Path
import ru.ifmo.se.dating.spring.security.auth.SpringSecuredPaths

@Component
class PeopleSecuredPaths : SpringSecuredPaths {
override val matcher: ServerWebExchangeMatcher = Path("")
override val matcher: ServerWebExchangeMatcher = And(
Path("/api/**"),
Not(Path("/api/monitoring/healthcheck", HttpMethod.GET)),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package ru.ifmo.se.dating.people.storage

import ru.ifmo.se.dating.people.model.Picture

interface PictureContentStorage {
suspend fun upload(id: Picture.Id, content: Picture.Content)
suspend fun download(id: Picture.Id): Picture.Content
suspend fun remove(id: Picture.Id)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package ru.ifmo.se.dating.people.storage

import ru.ifmo.se.dating.people.model.Picture

interface PictureRecordStorage {
suspend fun insert(): Picture
suspend fun setIsReferenced(id: Picture.Id, isReferenced: Boolean)
suspend fun delete(id: Picture.Id)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package ru.ifmo.se.dating.people.storage.jooq

import org.jooq.generated.tables.references.PICTURE
import org.springframework.stereotype.Repository
import ru.ifmo.se.dating.people.model.Picture
import ru.ifmo.se.dating.people.storage.PictureRecordStorage
import ru.ifmo.se.dating.people.storage.jooq.mapping.toModel
import ru.ifmo.se.dating.storage.jooq.JooqDatabase

@Repository
class JooqPictureRecordStorage(
private val database: JooqDatabase,
) : PictureRecordStorage {
override suspend fun insert(): Picture = database.only {
insertInto(PICTURE)
.set(PICTURE.IS_REFERENCED, false)
.returning()
}.toModel()

override suspend fun setIsReferenced(id: Picture.Id, isReferenced: Boolean) = database.only {
update(PICTURE)
.set(PICTURE.IS_REFERENCED, isReferenced)
.where(PICTURE.ID.eq(id.number))
}.let { }

override suspend fun delete(id: Picture.Id) = database.only {
delete(PICTURE)
.where(PICTURE.ID.eq(id.number))
}.let { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package ru.ifmo.se.dating.people.storage.jooq.mapping

import org.jooq.generated.tables.records.PictureRecord
import ru.ifmo.se.dating.people.model.Picture

fun PictureRecord.toModel(): Picture =
Picture(
id = Picture.Id(id!!),
isReferenced = isReferenced,
)
Loading
Loading