Skip to content

Commit 3a3949d

Browse files
authored
#60 Store photos in MinIO (#77)
Signed-off-by: vityaman <vityaman.dev@yandex.ru>
1 parent bbccbce commit 3a3949d

23 files changed

+536
-70
lines changed

backend/config/env/.env

+3-11
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,8 @@ ITMO_DATING_POSTGRES_DB="postgres"
55
ITMO_DATING_POSTGRES_USER="postgres"
66
ITMO_DATING_POSTGRES_PASSWORD="postgres"
77

8-
ITMO_DATING_AUTHIK_POSTGRES_DB="postgres"
9-
ITMO_DATING_AUTHIK_POSTGRES_USER="postgres"
10-
ITMO_DATING_AUTHIK_POSTGRES_PASSWORD="postgres"
8+
ITMO_DATING_MINIO_ROOT_USER="minioadmin"
9+
ITMO_DATING_MINIO_ROOT_PASSWORD="minioadmin"
10+
1111
ITMO_DATING_AUTHIK_TELEGRAM_BOT_TOKEN="bot_token_here"
1212
ITMO_DATING_AUTHIK_TOKEN_SIGN_KEY_PRIVATE="RSA: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"
13-
14-
ITMO_DATING_MATCHMAKER_POSTGRES_DB="postgres"
15-
ITMO_DATING_MATCHMAKER_POSTGRES_USER="postgres"
16-
ITMO_DATING_MATCHMAKER_POSTGRES_PASSWORD="postgres"
17-
18-
ITMO_DATING_PEOPLE_POSTGRES_DB="postgres"
19-
ITMO_DATING_PEOPLE_POSTGRES_USER="postgres"
20-
ITMO_DATING_PEOPLE_POSTGRES_PASSWORD="postgres"
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package ru.ifmo.se.dating.gateway
22

3+
import org.springframework.http.HttpHeaders
34
import org.springframework.http.HttpMethod
45
import org.springframework.http.HttpStatus
56
import org.springframework.stereotype.Component
@@ -10,30 +11,46 @@ import reactor.core.publisher.Mono
1011

1112
@Component
1213
class CorsFilter : WebFilter {
13-
// FIXME
14+
private val allowMethods = listOf(
15+
HttpMethod.GET,
16+
HttpMethod.PUT,
17+
HttpMethod.POST,
18+
HttpMethod.PATCH,
19+
HttpMethod.DELETE,
20+
HttpMethod.OPTIONS,
21+
)
22+
23+
private val exposeHeaders = listOf(
24+
"DNT",
25+
"X-CustomHeader",
26+
"Keep-Alive",
27+
"User-Agent",
28+
"X-Requested-With",
29+
"If-Modified-Since",
30+
"Cache-Control",
31+
"Content-Type",
32+
"Content-Range",
33+
"Range",
34+
)
35+
1436
override fun filter(ctx: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
15-
ctx.response.headers.add("Access-Control-Allow-Origin", "*")
16-
ctx.response.headers.add(
17-
"Access-Control-Allow-Methods",
18-
"GET, PUT, POST, PATCH, DELETE, OPTIONS"
19-
)
37+
ctx.response.headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2038
ctx.response.headers.add(
21-
"Access-Control-Allow-Headers",
22-
// "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With," +
23-
// "If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Authorization"
24-
"*"
39+
HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS,
40+
allowMethods.joinToString(", ") { it.name() },
2541
)
42+
ctx.response.headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "*")
43+
2644
if (ctx.request.method == HttpMethod.OPTIONS) {
27-
ctx.response.headers.add("Access-Control-Max-Age", "1728000")
45+
ctx.response.headers.add(HttpHeaders.ACCESS_CONTROL_MAX_AGE, 1_728_000.toString())
2846
ctx.response.statusCode = HttpStatus.NO_CONTENT
2947
return Mono.empty()
30-
} else {
31-
ctx.response.headers.add(
32-
"Access-Control-Expose-Headers",
33-
"DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With," +
34-
"If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range"
35-
)
36-
return chain.filter(ctx) ?: Mono.empty()
3748
}
49+
50+
ctx.response.headers.add(
51+
HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS,
52+
exposeHeaders.joinToString(","),
53+
)
54+
return chain.filter(ctx) ?: Mono.empty()
3855
}
3956
}

backend/gateway/src/main/resources/application.yml

+18
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,24 @@ spring:
7979
- Method=PATCH
8080
- Path=/api/people/*
8181

82+
- id: post-people-person-id-photos
83+
uri: lb://people
84+
predicates:
85+
- Method=POST
86+
- Path=/api/people/*/photos
87+
88+
- id: get-people-person-id-photos-picture-id
89+
uri: lb://people
90+
predicates:
91+
- Method=GET
92+
- Path=/api/people/*/photos/*
93+
94+
- id: delete-people-person-id-photos-picture-id
95+
uri: lb://people
96+
predicates:
97+
- Method=DELETE
98+
- Path=/api/people/*/photos/*
99+
82100
- id: get-people-faculties
83101
uri: lb://people
84102
predicates:

backend/gradle/libs.versions.toml

+4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ org-liquibase-liquibase-core = "4.29.2"
1414
org-postgresql-postgresql = "42.7.4"
1515
org-postgresql-r2dbc-postgresql = "1.0.7.RELEASE"
1616

17+
io-minio-minio = "8.5.15"
18+
1719
com-fasterxml-jackson = "2.18.2"
1820

1921
jakarta-validation-jakarta-validation-api = "3.1.0"
@@ -64,6 +66,8 @@ org-jooq-jooq-kotlin = { module = "org.jooq:jooq-kotlin", version.ref = "org-joo
6466
org-jooq-jooq-meta-extensions = { module = "org.jooq:jooq-meta-extensions", version.ref = "org-jooq" }
6567
org-jooq-jooq-meta-kotlin = { module = "org.jooq:jooq-meta-kotlin", version.ref = "org-jooq" }
6668

69+
io-minio-minio = { module = "io.minio:minio", version.ref = "io-minio-minio" }
70+
6771
org-liquibase-liquibase-core = { module = "org.liquibase:liquibase-core", version.ref = "org-liquibase-liquibase-core" }
6872
org-postgresql-postgresql = { module = "org.postgresql:postgresql", version.ref = "org-postgresql-postgresql" }
6973
org-postgresql-r2dbc-postgresql = { module = "org.postgresql:r2dbc-postgresql", version.ref = "org-postgresql-r2dbc-postgresql" }

backend/people/build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ plugins {
55
}
66

77
dependencies {
8+
implementation(libs.io.minio.minio)
89
implementation(project(":foundation"))
910
testImplementation(project(":foundation-test"))
1011
}

backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt

+53-6
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package ru.ifmo.se.dating.people.api
22

33
import kotlinx.coroutines.flow.Flow
44
import kotlinx.coroutines.flow.map
5+
import org.springframework.core.io.ByteArrayResource
6+
import org.springframework.core.io.Resource
57
import org.springframework.http.ResponseEntity
68
import org.springframework.stereotype.Controller
79
import ru.ifmo.se.dating.exception.AuthorizationException
@@ -11,15 +13,20 @@ import ru.ifmo.se.dating.people.api.generated.PeopleApiDelegate
1113
import ru.ifmo.se.dating.people.api.mapping.toMessage
1214
import ru.ifmo.se.dating.people.api.mapping.toModel
1315
import ru.ifmo.se.dating.people.logic.PersonService
16+
import ru.ifmo.se.dating.people.logic.PictureService
1417
import ru.ifmo.se.dating.people.model.Faculty
18+
import ru.ifmo.se.dating.people.model.Picture
1519
import ru.ifmo.se.dating.people.model.generated.*
1620
import ru.ifmo.se.dating.security.auth.User
1721
import ru.ifmo.se.dating.spring.security.auth.SpringSecurityContext
1822
import java.time.LocalDate
1923
import java.time.OffsetDateTime
2024

2125
@Controller
22-
class HttpPeopleApi(private val service: PersonService) : PeopleApiDelegate {
26+
class HttpPeopleApi(
27+
private val personService: PersonService,
28+
private val pictureService: PictureService,
29+
) : PeopleApiDelegate {
2330
override fun peopleGet(
2431
offset: Long,
2532
limit: Long,
@@ -57,7 +64,7 @@ class HttpPeopleApi(private val service: PersonService) : PeopleApiDelegate {
5764
TODO("Unsupported GET /people query parameter was provided")
5865
}
5966

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

79-
service.delete(targetId)
86+
personService.delete(targetId)
8087

8188
return ResponseEntity.ok(Unit)
8289
}
8390

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

97104
when (status) {
98-
PersonStatusMessage.draft -> service.edit(model)
99-
PersonStatusMessage.ready -> service.save(model)
105+
PersonStatusMessage.draft -> personService.edit(model)
106+
PersonStatusMessage.ready -> personService.save(model)
100107
}
101108

102109
return ResponseEntity.ok(Unit)
103110
}
111+
112+
override suspend fun peoplePersonIdPhotosPost(
113+
personId: Long,
114+
body: Resource?,
115+
): ResponseEntity<PictureMessage> {
116+
require(body != null)
117+
118+
val callerId = SpringSecurityContext.principal()
119+
val targetId = User.Id(personId.toInt())
120+
if (callerId != targetId) {
121+
throw AuthorizationException("caller $callerId can't post photo to $targetId profile")
122+
}
123+
124+
val pictureId = pictureService.save(Picture.Content(body.contentAsByteArray))
125+
126+
return PictureMessage(id = pictureId.number.toLong()).let { ResponseEntity.ok(it) }
127+
}
128+
129+
override suspend fun peoplePersonIdPhotosPictureIdGet(
130+
personId: Long,
131+
pictureId: Long,
132+
): ResponseEntity<Resource> {
133+
val picture = pictureService.getById(Picture.Id(pictureId.toInt()))
134+
return ByteArrayResource(picture.bytes).let { ResponseEntity.ok(it) }
135+
}
136+
137+
override suspend fun peoplePersonIdPhotosPictureIdDelete(
138+
personId: Long,
139+
pictureId: Long,
140+
): ResponseEntity<Unit> {
141+
val callerId = SpringSecurityContext.principal()
142+
val targetId = User.Id(personId.toInt())
143+
if (callerId != targetId) {
144+
throw AuthorizationException("caller $callerId can't remove photo of $targetId profile")
145+
}
146+
147+
pictureService.remove(Picture.Id(pictureId.toInt()))
148+
149+
return ResponseEntity.ok(Unit)
150+
}
104151
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package ru.ifmo.se.dating.people.logic
2+
3+
import ru.ifmo.se.dating.people.model.Picture
4+
5+
interface PictureService {
6+
suspend fun getById(id: Picture.Id): Picture.Content
7+
suspend fun save(content: Picture.Content): Picture.Id
8+
suspend fun remove(id: Picture.Id)
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package ru.ifmo.se.dating.people.logic.basic
2+
3+
import org.springframework.stereotype.Service
4+
import ru.ifmo.se.dating.people.logic.PictureService
5+
import ru.ifmo.se.dating.people.model.Picture
6+
import ru.ifmo.se.dating.people.storage.PictureContentStorage
7+
import ru.ifmo.se.dating.people.storage.PictureRecordStorage
8+
9+
@Service
10+
class BasicPictureService(
11+
private val recordStorage: PictureRecordStorage,
12+
private val contentStorage: PictureContentStorage,
13+
) : PictureService {
14+
override suspend fun getById(id: Picture.Id): Picture.Content =
15+
contentStorage.download(id)
16+
17+
override suspend fun save(content: Picture.Content): Picture.Id {
18+
val picture = recordStorage.insert()
19+
contentStorage.upload(picture.id, content)
20+
recordStorage.setIsReferenced(picture.id, isReferenced = true)
21+
return picture.id
22+
}
23+
24+
override suspend fun remove(id: Picture.Id) {
25+
recordStorage.setIsReferenced(id, isReferenced = false)
26+
contentStorage.remove(id)
27+
recordStorage.delete(id)
28+
}
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package ru.ifmo.se.dating.people.model
2+
3+
import ru.ifmo.se.dating.validation.expectId
4+
5+
data class Picture(val id: Id, val isReferenced: Boolean) {
6+
@JvmInline
7+
value class Id(val number: Int) {
8+
init {
9+
expectId(number)
10+
}
11+
12+
override fun toString(): String = number.toString()
13+
}
14+
15+
data class Content(val bytes: ByteArray) {
16+
override fun equals(other: Any?): Boolean =
17+
this === other ||
18+
javaClass == other?.javaClass &&
19+
bytes.contentEquals((other as Content).bytes)
20+
21+
override fun hashCode(): Int =
22+
bytes.contentHashCode()
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
package ru.ifmo.se.dating.people.security
22

3+
import org.springframework.http.HttpMethod
34
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher
45
import org.springframework.stereotype.Component
6+
import ru.ifmo.se.dating.spring.security.auth.And
7+
import ru.ifmo.se.dating.spring.security.auth.Not
58
import ru.ifmo.se.dating.spring.security.auth.Path
69
import ru.ifmo.se.dating.spring.security.auth.SpringSecuredPaths
710

811
@Component
912
class PeopleSecuredPaths : SpringSecuredPaths {
10-
override val matcher: ServerWebExchangeMatcher = Path("")
13+
override val matcher: ServerWebExchangeMatcher = And(
14+
Path("/api/**"),
15+
Not(Path("/api/monitoring/healthcheck", HttpMethod.GET)),
16+
)
1117
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package ru.ifmo.se.dating.people.storage
2+
3+
import ru.ifmo.se.dating.people.model.Picture
4+
5+
interface PictureContentStorage {
6+
suspend fun upload(id: Picture.Id, content: Picture.Content)
7+
suspend fun download(id: Picture.Id): Picture.Content
8+
suspend fun remove(id: Picture.Id)
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package ru.ifmo.se.dating.people.storage
2+
3+
import ru.ifmo.se.dating.people.model.Picture
4+
5+
interface PictureRecordStorage {
6+
suspend fun insert(): Picture
7+
suspend fun setIsReferenced(id: Picture.Id, isReferenced: Boolean)
8+
suspend fun delete(id: Picture.Id)
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package ru.ifmo.se.dating.people.storage.jooq
2+
3+
import org.jooq.generated.tables.references.PICTURE
4+
import org.springframework.stereotype.Repository
5+
import ru.ifmo.se.dating.people.model.Picture
6+
import ru.ifmo.se.dating.people.storage.PictureRecordStorage
7+
import ru.ifmo.se.dating.people.storage.jooq.mapping.toModel
8+
import ru.ifmo.se.dating.storage.jooq.JooqDatabase
9+
10+
@Repository
11+
class JooqPictureRecordStorage(
12+
private val database: JooqDatabase,
13+
) : PictureRecordStorage {
14+
override suspend fun insert(): Picture = database.only {
15+
insertInto(PICTURE)
16+
.set(PICTURE.IS_REFERENCED, false)
17+
.returning()
18+
}.toModel()
19+
20+
override suspend fun setIsReferenced(id: Picture.Id, isReferenced: Boolean) = database.only {
21+
update(PICTURE)
22+
.set(PICTURE.IS_REFERENCED, isReferenced)
23+
.where(PICTURE.ID.eq(id.number))
24+
}.let { }
25+
26+
override suspend fun delete(id: Picture.Id) = database.only {
27+
delete(PICTURE)
28+
.where(PICTURE.ID.eq(id.number))
29+
}.let { }
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package ru.ifmo.se.dating.people.storage.jooq.mapping
2+
3+
import org.jooq.generated.tables.records.PictureRecord
4+
import ru.ifmo.se.dating.people.model.Picture
5+
6+
fun PictureRecord.toModel(): Picture =
7+
Picture(
8+
id = Picture.Id(id!!),
9+
isReferenced = isReferenced,
10+
)

0 commit comments

Comments
 (0)