From dcb2d40362ce8ca408a840ac6f20026dc81683dc Mon Sep 17 00:00:00 2001 From: vityaman Date: Wed, 15 Jan 2025 08:54:17 +0300 Subject: [PATCH 01/12] #60 Add MinIO docker container Signed-off-by: vityaman --- backend/config/env/.env | 3 +++ compose.yml | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/backend/config/env/.env b/backend/config/env/.env index 41cf9053..fc370385 100644 --- a/backend/config/env/.env +++ b/backend/config/env/.env @@ -5,6 +5,9 @@ ITMO_DATING_POSTGRES_DB="postgres" ITMO_DATING_POSTGRES_USER="postgres" ITMO_DATING_POSTGRES_PASSWORD="postgres" +ITMO_DATING_MINIO_ROOT_USER="minioadmin" +ITMO_DATING_MINIO_ROOT_PASSWORD="minioadmin" + ITMO_DATING_AUTHIK_POSTGRES_DB="postgres" ITMO_DATING_AUTHIK_POSTGRES_USER="postgres" ITMO_DATING_AUTHIK_POSTGRES_PASSWORD="postgres" diff --git a/compose.yml b/compose.yml index b98dce0b..dd77ad04 100644 --- a/compose.yml +++ b/compose.yml @@ -61,6 +61,20 @@ services: extends: service: people-0 hostname: people-1.dating.se.ifmo.ru + object-storage: + image: quay.io/minio/minio + command: server --console-address ":9001" "/data" + environment: + MINIO_ROOT_USER: ${ITMO_DATING_MINIO_ROOT_USER?:err} + MINIO_ROOT_PASSWORD: ${ITMO_DATING_MINIO_ROOT_PASSWORD?:err} + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + hostname: object-storage.dating.se.ifmo.ru + ports: + - "127.0.0.1:9001:9001" database: image: postgres environment: From d87c741cc7305b3d4aafa1f8ace902bca121ce9a Mon Sep 17 00:00:00 2001 From: vityaman Date: Wed, 15 Jan 2025 08:57:37 +0300 Subject: [PATCH 02/12] #60 Add Docker Compose profile for a minimal setup Signed-off-by: vityaman --- compose.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/compose.yml b/compose.yml index 3e4fe9ba..bd4a3bf6 100644 --- a/compose.yml +++ b/compose.yml @@ -21,6 +21,8 @@ services: extends: service: authik-0 hostname: authik-1.dating.se.ifmo.ru + profiles: + - reliability matchmaker-0: image: ghcr.io/secs-dev/itmo-dating-matchmaker:latest build: @@ -41,6 +43,8 @@ services: extends: service: matchmaker-0 hostname: matchmaker-1.dating.se.ifmo.ru + profiles: + - reliability people-0: image: ghcr.io/secs-dev/itmo-dating-people:latest build: @@ -61,6 +65,8 @@ services: extends: service: people-0 hostname: people-1.dating.se.ifmo.ru + profiles: + - reliability object-storage: image: quay.io/minio/minio command: server --console-address ":9001" "/data" From 95a57fab7e781b7f73f152026ba0ba5c08e410f1 Mon Sep 17 00:00:00 2001 From: vityaman Date: Wed, 15 Jan 2025 09:07:01 +0300 Subject: [PATCH 03/12] #60 Refactor CorsFilter Signed-off-by: vityaman --- .../ru/ifmo/se/dating/gateway/CorsFilter.kt | 49 ++++++++++++------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/backend/gateway/src/main/kotlin/ru/ifmo/se/dating/gateway/CorsFilter.kt b/backend/gateway/src/main/kotlin/ru/ifmo/se/dating/gateway/CorsFilter.kt index 4ada943c..4a1b6d78 100644 --- a/backend/gateway/src/main/kotlin/ru/ifmo/se/dating/gateway/CorsFilter.kt +++ b/backend/gateway/src/main/kotlin/ru/ifmo/se/dating/gateway/CorsFilter.kt @@ -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 @@ -10,30 +11,42 @@ import reactor.core.publisher.Mono @Component class CorsFilter : WebFilter { - // FIXME override fun filter(ctx: ServerWebExchange, chain: WebFilterChain): Mono { - ctx.response.headers.add("Access-Control-Allow-Origin", "*") + ctx.response.headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*") ctx.response.headers.add( - "Access-Control-Allow-Methods", - "GET, PUT, POST, PATCH, DELETE, OPTIONS" - ) - 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, + listOf( + HttpMethod.GET, + HttpMethod.PUT, + HttpMethod.POST, + HttpMethod.PATCH, + HttpMethod.DELETE, + HttpMethod.OPTIONS, + ).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, 1728000.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, + listOf( + "DNT", + "X-CustomHeader", + "Keep-Alive", + "User-Agent", + "X-Requested-With", + "If-Modified-Since", + "Cache-Control", + "Content-Type", + "Content-Range", + "Range", + ).joinToString(","), + ) + return chain.filter(ctx) ?: Mono.empty() } } From 5b4d9833ffc9be271f9e1d2876e4200a3e97f313 Mon Sep 17 00:00:00 2001 From: vityaman Date: Wed, 15 Jan 2025 10:07:59 +0300 Subject: [PATCH 04/12] #60 Pass MinIO credentials Signed-off-by: vityaman --- backend/gradle/libs.versions.toml | 4 ++++ backend/people/build.gradle.kts | 1 + backend/people/src/main/resources/application.yml | 10 ++++++++-- compose.yml | 2 ++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/gradle/libs.versions.toml b/backend/gradle/libs.versions.toml index fdc2e21f..bde7cc1c 100644 --- a/backend/gradle/libs.versions.toml +++ b/backend/gradle/libs.versions.toml @@ -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" @@ -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" } diff --git a/backend/people/build.gradle.kts b/backend/people/build.gradle.kts index 3eaa3a21..920d44a5 100644 --- a/backend/people/build.gradle.kts +++ b/backend/people/build.gradle.kts @@ -5,6 +5,7 @@ plugins { } dependencies { + implementation(libs.io.minio.minio) implementation(project(":foundation")) testImplementation(project(":foundation-test")) } diff --git a/backend/people/src/main/resources/application.yml b/backend/people/src/main/resources/application.yml index 50aeae22..a4f1faa7 100644 --- a/backend/people/src/main/resources/application.yml +++ b/backend/people/src/main/resources/application.yml @@ -12,5 +12,11 @@ spring: username: ${POSTGRES_USER} password: ${POSTGRES_PASSWORD} service: - matchmaker: - url: https://matchmaker/api + matchmaker: + url: https://matchmaker/api +storage: + s3: + url: object-storage.dating.se.ifmo.ru + port: 9001 + username: ${MINIO_USER} + password: ${MINIO_PASSWORD} diff --git a/compose.yml b/compose.yml index bd4a3bf6..ca5cb105 100644 --- a/compose.yml +++ b/compose.yml @@ -53,6 +53,8 @@ services: POSTGRES_DB: ${ITMO_DATING_PEOPLE_POSTGRES_DB?:err} POSTGRES_USER: ${ITMO_DATING_PEOPLE_POSTGRES_USER?:err} POSTGRES_PASSWORD: ${ITMO_DATING_PEOPLE_POSTGRES_PASSWORD?:err} + MINIO_USER: ${ITMO_DATING_MINIO_ROOT_USER?:err} + MINIO_PASSWORD: ${ITMO_DATING_MINIO_ROOT_PASSWORD?:err} TOKEN_SIGN_KEY_PUBLIC: ${ITMO_DATING_TOKEN_SIGN_KEY_PUBLIC?:err} KEY_STORE_PASSWORD: ${ITMO_DATING_KEY_STORE_PASSWORD?:err} hostname: people-0.dating.se.ifmo.ru From 62b9bbbf2388b920971c208444150ccf72a48007 Mon Sep 17 00:00:00 2001 From: vityaman Date: Wed, 15 Jan 2025 11:05:24 +0300 Subject: [PATCH 05/12] #60 Extend OpenAPI specification Signed-off-by: vityaman --- .../src/main/resources/static/openapi/api.yml | 113 +++++++++++++++++- 1 file changed, 107 insertions(+), 6 deletions(-) diff --git a/backend/people/src/main/resources/static/openapi/api.yml b/backend/people/src/main/resources/static/openapi/api.yml index e35faab2..2243d1ea 100644 --- a/backend/people/src/main/resources/static/openapi/api.yml +++ b/backend/people/src/main/resources/static/openapi/api.yml @@ -14,6 +14,7 @@ security: tags: - name: Monitoring - name: People + - name: Photos - name: Faculties - name: Locations paths: @@ -298,6 +299,98 @@ paths: $ref: "#/components/responses/503" default: $ref: "#/components/responses/Unexpected" + /people/{person_id}/photos: + post: + tags: [ Photos ] + summary: Add a profile photo + description: Adds a profile photo for a person profile with a given id + security: + - bearerAuth: [ USER ] + parameters: + - $ref: "#/components/parameters/PersonIdPath" + requestBody: + required: true + content: + image/jpeg: + schema: + type: string + format: binary + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Picture" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + "503": + $ref: "#/components/responses/503" + default: + $ref: "#/components/responses/Unexpected" + /people/{person_id}/photos/{picture_id}: + get: + tags: [ Photos ] + summary: Get a person profile photo + description: Returns a person profile photo by id + security: + - bearerAuth: [ USER ] + parameters: + - $ref: "#/components/parameters/PersonIdPath" + responses: + "200": + description: "OK" + content: + image/jpeg: + schema: + type: string + format: binary + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + "503": + $ref: "#/components/responses/503" + default: + $ref: "#/components/responses/Unexpected" + delete: + tags: [ Photos ] + summary: Delete a profile photo + description: Removes a person profile photo by id + security: + - bearerAuth: [ USER ] + parameters: + - $ref: "#/components/parameters/PersonIdPath" + - $ref: "#/components/parameters/PictureIdPath" + responses: + "204": + description: Deleted + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + "503": + $ref: "#/components/responses/503" + default: + $ref: "#/components/responses/Unexpected" /faculties: get: tags: [ Faculties ] @@ -346,6 +439,12 @@ components: required: true schema: $ref: "#/components/schemas/PersonId" + PictureIdPath: + name: picture_id + in: path + required: true + schema: + $ref: "#/components/schemas/PictureId" IdempotencyKeyHeader: name: Idempotency-Key in: header @@ -518,6 +617,13 @@ components: minimum: 0 maximum: 180 example: 30.308014 + PictureId: + type: integer + description: A unique key of a picture + format: int64 + minimum: 1 + maximum: 10000000 + example: 1234567 PictureUrl: type: string description: An URL of picture for downloading @@ -528,12 +634,7 @@ components: type: object properties: id: - type: integer - description: A unique key of a picture - format: int64 - minimum: 1 - maximum: 10000000 - example: 1234567 + $ref: "#/components/schemas/PictureId" small: $ref: "#/components/schemas/PictureUrl" medium: From 109b974825f279e2d70fb059c026f9076db09e7b Mon Sep 17 00:00:00 2001 From: vityaman Date: Wed, 15 Jan 2025 11:34:55 +0300 Subject: [PATCH 06/12] #60 Refactor environment variables --- backend/config/env/.env | 11 ----------- compose.yml | 18 +++++++++--------- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/backend/config/env/.env b/backend/config/env/.env index fc370385..6a7c7c8e 100644 --- a/backend/config/env/.env +++ b/backend/config/env/.env @@ -8,16 +8,5 @@ ITMO_DATING_POSTGRES_PASSWORD="postgres" ITMO_DATING_MINIO_ROOT_USER="minioadmin" ITMO_DATING_MINIO_ROOT_PASSWORD="minioadmin" -ITMO_DATING_AUTHIK_POSTGRES_DB="postgres" -ITMO_DATING_AUTHIK_POSTGRES_USER="postgres" -ITMO_DATING_AUTHIK_POSTGRES_PASSWORD="postgres" ITMO_DATING_AUTHIK_TELEGRAM_BOT_TOKEN="bot_token_here" ITMO_DATING_AUTHIK_TOKEN_SIGN_KEY_PRIVATE="RSA:MIIG/QIBADANBgkqhkiG9w0BAQEFAASCBucwggbjAgEAAoIBgQCucrDdhxHdYl4tbrgnqBz7x1P+m2Xg6LWR1+CJvURAEmaI1WbODHqGeZ/rQyFMJs8d4qFsErOjPIWVug7fYCrHk19vRQBTayyX32242LxHZWxhvLrCSiDnwaAWJ198qiF2FaM5lpresE0jUPwJBdySTlZbh4VuicHpWjzg4ux5tbkJf2hvxsKwkFfaJljBL5Y0KW5QwWDFHJou7KG3IqnRhFruiN7uK7cReIudpdMAWEZI/6IrobExQ85G8Zlh8nvP62mPo5hAh3lzBgP/7kRcUzjAKSrzZUNuP32YmoKWkez7UOE8/uw79pIMz1I41eMCwxO2rbuMmB28QAOl3qke3C/+0e/U+xFLoOJBgVBLHl6BzjwkSJUOwGQzLdPM6a9/Z0BTUotYiY1LALJufBCIGGhGvbE+5QHqA3DKtQzyBKJfkd8r2k9fYu55t6sXMFifOLzJHWmGQuWp+8xmf2rq5Gn3Kyu2zDa0Z/5afwGVsCirdbyEPy5+vmlHTfMca+cCAwEAAQKCAYBP2XOXkvHcceBFz34/uLW7kZui2SKi9iHWJghDQ/zvjvyb+YJbIl8bGqTWnR2qq8D2HvxgaZcMSvGifU29dVlfjNeMKPtjM5Vv1vd0OtDDpWscubSKpj+1lW1fdppAh+dVE8Zo38T31Z8ZYUJcJvC1j2H792ZeGHRICeP/1B8F/uY5sLXvI/2NsCRmWFMb6lpIegZitIFE+Dii7fF/0EAHBRxSPxg70Iq1VoYhnPueFsnlNA3ZBuQCdtT+qCvbJ5A9q1EHxlNMLkX2CyLat03G3zit5Pz5frWxGjDNf4Ep0SdL3IBZY2CW8SF0yi2WXWlFPM+Q2ozy2VMx2ESTdKmlNxgIpWWY1p+oJVltKFdTX7Aqq00P6wIhHoOU6+JFtMkyGw0M79tohBjpVV/7DpvTcY7pVSF4jSoTWEaS20LBrHvePTs6YfxnTVDcG7JyfhPSbktF6llN3L8Zi53n6JPtMwXTc3VIJDfs4hLCq8P52eexWnlpWOJN576JopvhLYECgcEAuf91450eMPIgyf8aAx6eUSBvYSPJrF8Xeh1P1CV1AdCYavi2va6rjH01PFcSweyheOfbFxbe+Kto8z3NhRLuLksC+gDWZRLhInTCNwxJ7G5L/mPN2eauZZ0pUGbHrtl3JTMt8FXBB0afK/WPfqepIL9H/3PWBC3jY6xAo64RIX4USjnCdriAQtdCkcGAajX6vEIN/KTifHYOHLzsn8gM2EFXHWAzAmkKcthk1AIU7INY4gldZunkM50TBQRw2vfhAoHBAPAaa+fEoxrIlSm1edG2rJil2DGOIwtKG8ZkjmhxLEq2XQD1obvjs54IfWFg5VviYXvgihujGdbcuviHrZaJOYP+EynOve9D2uaMUq08GYFcZI4jjO8WDSFkUhX361nKqT+O9w+r+KdA1fDoIuHHInvEOTlX/6v9KQADktiNCZjs2KV9TrPTFeoyrbhfLh8smDhRlqVfJ4IK27JcqYEx6mxatucGybQM4KLmDRondd9H77FflMtanktixWjm3G08xwKBwH1B+7tgaR+fP9Oo13S4XvfVdwydFEjf9SiIquT8oLKrLqoDetV81wySmZJcNUahvBB3XAVNorUmglQlD84JdJt6arPAcqG4uCMDLHPz86ikksrrnYqcHmBSGauKu/kVfHZx5AMRTSBAQBtTkOJDuNNT3gG7mapQ2Oyb6SARrnm2taVTBpH7KG1bF/qerINafNPhTBgTVm9o9ZIG7Pehuny8bBVdXpzF7oJvFl/sUvkAb5AxrFQNOWBE7LUZS4M7IQKBwDLNtGVPAyAIrx8rKgKIv45xEQSzSZD69lONNWC+CZwpaBZq4vTpojjfHQB8yysdBHl8slxUr4P6Iomx07YVhRj7qrxe5Wt6FRhROrEzFUZ88T3uIcT5CoA1RPUnByJxskwjiP1E6xEgs+QMikzxoMdFZsJOb2fJ4mIBX5H4jb5Q5yplEEEWef2bCY0Ifq7T9cV85f5J2wc2GvRrjOYsVKjmrOrHUeiKDQIK4VzWWqeLBhmm2soIe5QB6zleF+f5QwKBwQCX68V+DPfc0HgzUFkTlV5LaVxt1oQWZFVNsiABjaaku4XHpe02kaiS1qY9ul4AHhCdCJf9u+7lQdRX2quN76WdvurQRZ8/zxXlLv3kIm6DdUPoz99nAEX02vY8dZSI8saKALmeMT0zgQtmmWyJeyl7kd7T/Xl6ePgWPDM/e3MFhKuT6utaAY2/2jJJx/7ULzNIW9JFcijohVKbTxLA4qoKUPxgZyoWS0In9p4s9mKNkKxrD2MJylpk9ro78T7Qv0g=" - -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" diff --git a/compose.yml b/compose.yml index ca5cb105..15cb52c5 100644 --- a/compose.yml +++ b/compose.yml @@ -4,9 +4,9 @@ services: build: context: ./backend/authik environment: - POSTGRES_DB: ${ITMO_DATING_AUTHIK_POSTGRES_DB?:err} - POSTGRES_USER: ${ITMO_DATING_AUTHIK_POSTGRES_USER?:err} - POSTGRES_PASSWORD: ${ITMO_DATING_AUTHIK_POSTGRES_PASSWORD?:err} + POSTGRES_DB: ${ITMO_DATING_POSTGRES_DB?:err} + POSTGRES_USER: ${ITMO_DATING_POSTGRES_USER?:err} + POSTGRES_PASSWORD: ${ITMO_DATING_POSTGRES_PASSWORD?:err} TOKEN_SIGN_KEY_PUBLIC: ${ITMO_DATING_TOKEN_SIGN_KEY_PUBLIC?:err} TOKEN_SIGN_KEY_PRIVATE: ${ITMO_DATING_AUTHIK_TOKEN_SIGN_KEY_PRIVATE?:err} TELEGRAM_BOT_TOKEN: ${ITMO_DATING_AUTHIK_TELEGRAM_BOT_TOKEN?:err} @@ -28,9 +28,9 @@ services: build: context: ./backend/matchmaker environment: - POSTGRES_DB: ${ITMO_DATING_MATCHMAKER_POSTGRES_DB?:err} - POSTGRES_USER: ${ITMO_DATING_MATCHMAKER_POSTGRES_USER?:err} - POSTGRES_PASSWORD: ${ITMO_DATING_MATCHMAKER_POSTGRES_PASSWORD?:err} + POSTGRES_DB: ${ITMO_DATING_POSTGRES_DB?:err} + POSTGRES_USER: ${ITMO_DATING_POSTGRES_USER?:err} + POSTGRES_PASSWORD: ${ITMO_DATING_POSTGRES_PASSWORD?:err} TOKEN_SIGN_KEY_PUBLIC: ${ITMO_DATING_TOKEN_SIGN_KEY_PUBLIC?:err} KEY_STORE_PASSWORD: ${ITMO_DATING_KEY_STORE_PASSWORD?:err} hostname: matchmaker-0.dating.se.ifmo.ru @@ -50,9 +50,9 @@ services: build: context: ./backend/people environment: - POSTGRES_DB: ${ITMO_DATING_PEOPLE_POSTGRES_DB?:err} - POSTGRES_USER: ${ITMO_DATING_PEOPLE_POSTGRES_USER?:err} - POSTGRES_PASSWORD: ${ITMO_DATING_PEOPLE_POSTGRES_PASSWORD?:err} + POSTGRES_DB: ${ITMO_DATING_POSTGRES_DB?:err} + POSTGRES_USER: ${ITMO_DATING_POSTGRES_USER?:err} + POSTGRES_PASSWORD: ${ITMO_DATING_POSTGRES_PASSWORD?:err} MINIO_USER: ${ITMO_DATING_MINIO_ROOT_USER?:err} MINIO_PASSWORD: ${ITMO_DATING_MINIO_ROOT_PASSWORD?:err} TOKEN_SIGN_KEY_PUBLIC: ${ITMO_DATING_TOKEN_SIGN_KEY_PUBLIC?:err} From 4fca559005751e686eb6e475c57b1503657875b7 Mon Sep 17 00:00:00 2001 From: vityaman Date: Wed, 15 Jan 2025 11:35:35 +0300 Subject: [PATCH 07/12] #60 Receive a picture at API --- .../src/main/resources/application.yml | 6 ++++++ .../se/dating/people/api/HttpPeopleApi.kt | 21 +++++++++++++++++++ .../people/security/PeopleSecuredPaths.kt | 8 ++++++- .../src/main/resources/static/openapi/api.yml | 16 -------------- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/backend/gateway/src/main/resources/application.yml b/backend/gateway/src/main/resources/application.yml index bc15ae80..6204bf76 100644 --- a/backend/gateway/src/main/resources/application.yml +++ b/backend/gateway/src/main/resources/application.yml @@ -79,6 +79,12 @@ 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-faculties uri: lb://people predicates: diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt index d003680d..892fa171 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt @@ -2,10 +2,13 @@ package ru.ifmo.se.dating.people.api import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import org.springframework.core.io.Resource import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller import ru.ifmo.se.dating.exception.AuthorizationException import ru.ifmo.se.dating.exception.orThrowNotFound +import ru.ifmo.se.dating.logging.Log +import ru.ifmo.se.dating.logging.Log.Companion.autoLog import ru.ifmo.se.dating.pagging.Page import ru.ifmo.se.dating.people.api.generated.PeopleApiDelegate import ru.ifmo.se.dating.people.api.mapping.toMessage @@ -20,6 +23,8 @@ import java.time.OffsetDateTime @Controller class HttpPeopleApi(private val service: PersonService) : PeopleApiDelegate { + private val log = Log.autoLog() + override fun peopleGet( offset: Long, limit: Long, @@ -101,4 +106,20 @@ class HttpPeopleApi(private val service: PersonService) : PeopleApiDelegate { return ResponseEntity.ok(Unit) } + + override suspend fun peoplePersonIdPhotosPost( + personId: Long, body: Resource?, + ): ResponseEntity { + 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") + } + + log.info("POST picture with size ${body.contentLength()}") + + return PictureMessage(id = 666).let { ResponseEntity.ok(it) } + } } diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/security/PeopleSecuredPaths.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/security/PeopleSecuredPaths.kt index f907146a..15a45772 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/security/PeopleSecuredPaths.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/security/PeopleSecuredPaths.kt @@ -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)), + ) } diff --git a/backend/people/src/main/resources/static/openapi/api.yml b/backend/people/src/main/resources/static/openapi/api.yml index 2243d1ea..5a15df09 100644 --- a/backend/people/src/main/resources/static/openapi/api.yml +++ b/backend/people/src/main/resources/static/openapi/api.yml @@ -624,29 +624,13 @@ components: minimum: 1 maximum: 10000000 example: 1234567 - PictureUrl: - type: string - description: An URL of picture for downloading - format: uri - maxLength: 256 - example: https://avatars.githubusercontent.com/u/53015676 Picture: type: object properties: id: $ref: "#/components/schemas/PictureId" - small: - $ref: "#/components/schemas/PictureUrl" - medium: - $ref: "#/components/schemas/PictureUrl" - large: - $ref: "#/components/schemas/PictureUrl" required: - id - anyOf: - - required: [ small ] - - required: [ medium ] - - required: [ large ] Interest: type: object properties: From 0cda90089a51b1cef273893849ce6a0883dc229a Mon Sep 17 00:00:00 2001 From: vityaman Date: Wed, 15 Jan 2025 11:58:37 +0300 Subject: [PATCH 08/12] #60 Send a picture at API Signed-off-by: vityaman --- backend/gateway/src/main/resources/application.yml | 6 ++++++ .../ru/ifmo/se/dating/people/api/HttpPeopleApi.kt | 11 +++++++++++ .../people/src/main/resources/static/openapi/api.yml | 1 + 3 files changed, 18 insertions(+) diff --git a/backend/gateway/src/main/resources/application.yml b/backend/gateway/src/main/resources/application.yml index 6204bf76..12e33212 100644 --- a/backend/gateway/src/main/resources/application.yml +++ b/backend/gateway/src/main/resources/application.yml @@ -85,6 +85,12 @@ spring: - 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: get-people-faculties uri: lb://people predicates: diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt index 892fa171..fba911c2 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt @@ -2,6 +2,7 @@ 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 @@ -25,6 +26,8 @@ import java.time.OffsetDateTime class HttpPeopleApi(private val service: PersonService) : PeopleApiDelegate { private val log = Log.autoLog() + private var lastPicture: ByteArray = ByteArray(0) + override fun peopleGet( offset: Long, limit: Long, @@ -119,7 +122,15 @@ class HttpPeopleApi(private val service: PersonService) : PeopleApiDelegate { } log.info("POST picture with size ${body.contentLength()}") + lastPicture = body.contentAsByteArray return PictureMessage(id = 666).let { ResponseEntity.ok(it) } } + + override suspend fun peoplePersonIdPhotosPictureIdGet( + personId: Long, + pictureId: Long, + ): ResponseEntity { + return ByteArrayResource(lastPicture).let { ResponseEntity.ok(it) } + } } diff --git a/backend/people/src/main/resources/static/openapi/api.yml b/backend/people/src/main/resources/static/openapi/api.yml index 5a15df09..c7f51652 100644 --- a/backend/people/src/main/resources/static/openapi/api.yml +++ b/backend/people/src/main/resources/static/openapi/api.yml @@ -345,6 +345,7 @@ paths: - bearerAuth: [ USER ] parameters: - $ref: "#/components/parameters/PersonIdPath" + - $ref: "#/components/parameters/PictureIdPath" responses: "200": description: "OK" From 6c68bf903131e682ebc0cd6ada0430cc258d838b Mon Sep 17 00:00:00 2001 From: vityaman Date: Wed, 15 Jan 2025 17:58:34 +0300 Subject: [PATCH 09/12] #60 Store photos at MinIO --- .../se/dating/people/api/HttpPeopleApi.kt | 31 ++++++----- .../se/dating/people/logic/PictureService.kt | 8 +++ .../people/logic/basic/BasicPictureService.kt | 23 +++++++++ .../ru/ifmo/se/dating/people/model/Picture.kt | 24 +++++++++ .../people/storage/PictureContentStorage.kt | 8 +++ .../people/storage/PictureRecordStorage.kt | 8 +++ .../storage/jooq/JooqPictureRecordStorage.kt | 25 +++++++++ .../storage/jooq/mapping/PictureMapping.kt | 10 ++++ .../storage/minio/MinioClientConfiguration.kt | 35 +++++++++++++ .../minio/MinioPictureContentStorage.kt | 51 +++++++++++++++++++ .../people/src/main/resources/application.yml | 4 +- .../src/main/resources/database/changelog.sql | 7 ++- compose.yml | 9 +++- 13 files changed, 223 insertions(+), 20 deletions(-) create mode 100644 backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/PictureService.kt create mode 100644 backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/basic/BasicPictureService.kt create mode 100644 backend/people/src/main/kotlin/ru/ifmo/se/dating/people/model/Picture.kt create mode 100644 backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureContentStorage.kt create mode 100644 backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureRecordStorage.kt create mode 100644 backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/jooq/JooqPictureRecordStorage.kt create mode 100644 backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/jooq/mapping/PictureMapping.kt create mode 100644 backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioClientConfiguration.kt create mode 100644 backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt index fba911c2..31c9d5cd 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt @@ -8,14 +8,14 @@ import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller import ru.ifmo.se.dating.exception.AuthorizationException import ru.ifmo.se.dating.exception.orThrowNotFound -import ru.ifmo.se.dating.logging.Log -import ru.ifmo.se.dating.logging.Log.Companion.autoLog import ru.ifmo.se.dating.pagging.Page 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 @@ -23,11 +23,10 @@ import java.time.LocalDate import java.time.OffsetDateTime @Controller -class HttpPeopleApi(private val service: PersonService) : PeopleApiDelegate { - private val log = Log.autoLog() - - private var lastPicture: ByteArray = ByteArray(0) - +class HttpPeopleApi( + private val personService: PersonService, + private val pictureService: PictureService, +) : PeopleApiDelegate { override fun peopleGet( offset: Long, limit: Long, @@ -65,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(".*"), @@ -84,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 = - service.getById(User.Id(personId.toInt())) + personService.getById(User.Id(personId.toInt())) ?.toMessage() .orThrowNotFound("person with id $personId not found") .let { ResponseEntity.ok(it) } @@ -103,8 +102,8 @@ 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) @@ -121,16 +120,16 @@ class HttpPeopleApi(private val service: PersonService) : PeopleApiDelegate { throw AuthorizationException("caller $callerId can't post photo to $targetId profile") } - log.info("POST picture with size ${body.contentLength()}") - lastPicture = body.contentAsByteArray + val pictureId = pictureService.save(Picture.Content(body.contentAsByteArray)) - return PictureMessage(id = 666).let { ResponseEntity.ok(it) } + return PictureMessage(id = pictureId.number.toLong()).let { ResponseEntity.ok(it) } } override suspend fun peoplePersonIdPhotosPictureIdGet( personId: Long, pictureId: Long, ): ResponseEntity { - return ByteArrayResource(lastPicture).let { ResponseEntity.ok(it) } + val picture = pictureService.getById(Picture.Id(pictureId.toInt())) + return ByteArrayResource(picture.bytes).let { ResponseEntity.ok(it) } } } diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/PictureService.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/PictureService.kt new file mode 100644 index 00000000..a2015fd9 --- /dev/null +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/PictureService.kt @@ -0,0 +1,8 @@ +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 +} diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/basic/BasicPictureService.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/basic/BasicPictureService.kt new file mode 100644 index 00000000..1857f508 --- /dev/null +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/basic/BasicPictureService.kt @@ -0,0 +1,23 @@ +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 + } +} diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/model/Picture.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/model/Picture.kt new file mode 100644 index 00000000..9bd3100b --- /dev/null +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/model/Picture.kt @@ -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() + } +} diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureContentStorage.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureContentStorage.kt new file mode 100644 index 00000000..dbf72642 --- /dev/null +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureContentStorage.kt @@ -0,0 +1,8 @@ +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 +} diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureRecordStorage.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureRecordStorage.kt new file mode 100644 index 00000000..8ad10df4 --- /dev/null +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureRecordStorage.kt @@ -0,0 +1,8 @@ +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) +} diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/jooq/JooqPictureRecordStorage.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/jooq/JooqPictureRecordStorage.kt new file mode 100644 index 00000000..72230390 --- /dev/null +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/jooq/JooqPictureRecordStorage.kt @@ -0,0 +1,25 @@ +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 { } +} diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/jooq/mapping/PictureMapping.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/jooq/mapping/PictureMapping.kt new file mode 100644 index 00000000..2f7b5a00 --- /dev/null +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/jooq/mapping/PictureMapping.kt @@ -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, + ) diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioClientConfiguration.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioClientConfiguration.kt new file mode 100644 index 00000000..c81b966b --- /dev/null +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioClientConfiguration.kt @@ -0,0 +1,35 @@ +package ru.ifmo.se.dating.people.storage.minio + +import io.minio.MinioClient +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import ru.ifmo.se.dating.logging.Log +import ru.ifmo.se.dating.logging.Log.Companion.autoLog + +@Configuration +class MinioClientConfiguration { + private val log = Log.autoLog() + + @Bean + fun minioClient( + @Value("\${storage.s3.url}") + url: String, + + @Value("\${storage.s3.port}") + port: Int, + + @Value("\${storage.s3.username}") + username: String, + + @Value("\${storage.s3.password}") + password: String, + ): MinioClient = MinioClient.builder() + .endpoint(url, port, /* secure = */ false) + .credentials(username, password) + .build() + .also { client -> + val names = client.listBuckets().joinToString(", ") { "'${it.name()}'" } + log.info("Initialized MinIO client. Found buckets: $names") + } +} diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt new file mode 100644 index 00000000..91a0588e --- /dev/null +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt @@ -0,0 +1,51 @@ +package ru.ifmo.se.dating.people.storage.minio + +import io.minio.GetObjectArgs +import io.minio.MinioClient +import io.minio.PutObjectArgs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.apache.http.entity.ContentType +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Repository +import ru.ifmo.se.dating.people.model.Picture +import ru.ifmo.se.dating.people.storage.PictureContentStorage +import java.io.ByteArrayInputStream + +@Repository +class MinioPictureContentStorage( + private val minio: MinioClient, + + @Value("\${storage.s3.bucket.profile-photos}") + private val bucket: String, +) : PictureContentStorage { + private val contentType = ContentType.IMAGE_JPEG + + override suspend fun upload(id: Picture.Id, content: Picture.Content): Unit = + withContext(Dispatchers.IO) { + PutObjectArgs.builder() + .bucket(bucket) + .`object`(objectName(id)) + .stream( + ByteArrayInputStream(content.bytes), + content.bytes.size.toLong(), + /* partSize = */ -1, + ) + .contentType(contentType.mimeType) + .build() + .let { minio.putObject(it) } + } + + override suspend fun download(id: Picture.Id): Picture.Content = + withContext(Dispatchers.IO) { + GetObjectArgs.builder() + .bucket(bucket) + .`object`(objectName(id)) + .build() + .let { minio.getObject(it) } + .use { it.readBytes() } + .let { Picture.Content(it) } + } + + private fun objectName(id: Picture.Id) = "$id.jpg" +} diff --git a/backend/people/src/main/resources/application.yml b/backend/people/src/main/resources/application.yml index a4f1faa7..9746ae3b 100644 --- a/backend/people/src/main/resources/application.yml +++ b/backend/people/src/main/resources/application.yml @@ -17,6 +17,8 @@ service: storage: s3: url: object-storage.dating.se.ifmo.ru - port: 9001 + port: 9000 username: ${MINIO_USER} password: ${MINIO_PASSWORD} + bucket: + profile-photos: profile-photos diff --git a/backend/people/src/main/resources/database/changelog.sql b/backend/people/src/main/resources/database/changelog.sql index 1a12d874..92a133c1 100644 --- a/backend/people/src/main/resources/database/changelog.sql +++ b/backend/people/src/main/resources/database/changelog.sql @@ -39,7 +39,6 @@ CREATE TABLE people.person ( ); --changeset vityaman:data - INSERT INTO people.faculty (long_name) VALUES ('Control Systems and Robotics'), @@ -56,3 +55,9 @@ VALUES ('ITMO, Kronverkskiy', 59.957427, 30.308053), ('ITMO, Birzhevaya', 59.943970, 30.295717), ('ITMO, Lomonosova', 59.926567, 30.339097); + +--changeset vityaman:picture +CREATE TABLE people.picture ( + id serial PRIMARY KEY, + is_referenced boolean NOT NULL +); diff --git a/compose.yml b/compose.yml index 15cb52c5..215cce96 100644 --- a/compose.yml +++ b/compose.yml @@ -63,6 +63,8 @@ services: condition: service_healthy consul: condition: service_started + object-storage: + condition: service_healthy people-1: extends: service: people-0 @@ -71,12 +73,15 @@ services: - reliability object-storage: image: quay.io/minio/minio - command: server --console-address ":9001" "/data" + entrypoint: sh + command: | + -c 'mkdir -p /export/profile-photos && + minio server /export --console-address ":9001"' environment: MINIO_ROOT_USER: ${ITMO_DATING_MINIO_ROOT_USER?:err} MINIO_ROOT_PASSWORD: ${ITMO_DATING_MINIO_ROOT_PASSWORD?:err} healthcheck: - test: ["CMD", "mc", "ready", "local"] + test: [ "CMD", "mc", "ready", "local" ] interval: 5s timeout: 5s retries: 5 From f91d7f3081d5401194514ba85df0380c48dd211d Mon Sep 17 00:00:00 2001 From: vityaman Date: Wed, 15 Jan 2025 18:23:08 +0300 Subject: [PATCH 10/12] #60 Delete photo happy path --- .../gateway/src/main/resources/application.yml | 6 ++++++ .../ru/ifmo/se/dating/people/api/HttpPeopleApi.kt | 15 +++++++++++++++ .../ifmo/se/dating/people/logic/PictureService.kt | 1 + .../people/logic/basic/BasicPictureService.kt | 6 ++++++ .../people/storage/PictureContentStorage.kt | 1 + .../dating/people/storage/PictureRecordStorage.kt | 1 + .../storage/jooq/JooqPictureRecordStorage.kt | 5 +++++ .../storage/minio/MinioPictureContentStorage.kt | 10 ++++++++++ 8 files changed, 45 insertions(+) diff --git a/backend/gateway/src/main/resources/application.yml b/backend/gateway/src/main/resources/application.yml index 12e33212..9866552f 100644 --- a/backend/gateway/src/main/resources/application.yml +++ b/backend/gateway/src/main/resources/application.yml @@ -91,6 +91,12 @@ spring: - 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: diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt index 31c9d5cd..34e61abf 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt @@ -132,4 +132,19 @@ class HttpPeopleApi( 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 { + 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) + } } diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/PictureService.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/PictureService.kt index a2015fd9..d34c3002 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/PictureService.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/PictureService.kt @@ -5,4 +5,5 @@ 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) } diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/basic/BasicPictureService.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/basic/BasicPictureService.kt index 1857f508..144bf48f 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/basic/BasicPictureService.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/logic/basic/BasicPictureService.kt @@ -20,4 +20,10 @@ class BasicPictureService( 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) + } } diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureContentStorage.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureContentStorage.kt index dbf72642..c2de629f 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureContentStorage.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureContentStorage.kt @@ -5,4 +5,5 @@ 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) } diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureRecordStorage.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureRecordStorage.kt index 8ad10df4..a3f95a47 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureRecordStorage.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/PictureRecordStorage.kt @@ -5,4 +5,5 @@ 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) } diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/jooq/JooqPictureRecordStorage.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/jooq/JooqPictureRecordStorage.kt index 72230390..deedd1b8 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/jooq/JooqPictureRecordStorage.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/jooq/JooqPictureRecordStorage.kt @@ -22,4 +22,9 @@ class JooqPictureRecordStorage( .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 { } } diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt index 91a0588e..c86c4215 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt @@ -3,6 +3,7 @@ package ru.ifmo.se.dating.people.storage.minio import io.minio.GetObjectArgs import io.minio.MinioClient import io.minio.PutObjectArgs +import io.minio.RemoveObjectArgs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.apache.http.entity.ContentType @@ -47,5 +48,14 @@ class MinioPictureContentStorage( .let { Picture.Content(it) } } + override suspend fun remove(id: Picture.Id) = + withContext(Dispatchers.IO) { + RemoveObjectArgs.builder() + .bucket(bucket) + .`object`(objectName(id)) + .build() + .let { minio.removeObject(it) } + } + private fun objectName(id: Picture.Id) = "$id.jpg" } From ce2968d040c92594757173fd21b713637586c8ce Mon Sep 17 00:00:00 2001 From: vityaman Date: Wed, 15 Jan 2025 19:03:07 +0300 Subject: [PATCH 11/12] #60 Catch MinIO NoSuchKey error Signed-off-by: vityaman --- .../minio/MinioPictureContentStorage.kt | 67 ++++++++++++------- .../storage/minio/exception/MinioException.kt | 6 ++ .../minio/exception/NoSuchKeyException.kt | 4 ++ .../minio/exception/UnknownException.kt | 4 ++ 4 files changed, 57 insertions(+), 24 deletions(-) create mode 100644 backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/exception/MinioException.kt create mode 100644 backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/exception/NoSuchKeyException.kt create mode 100644 backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/exception/UnknownException.kt diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt index c86c4215..7995891a 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt @@ -4,13 +4,17 @@ import io.minio.GetObjectArgs import io.minio.MinioClient import io.minio.PutObjectArgs import io.minio.RemoveObjectArgs +import io.minio.errors.ErrorResponseException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.apache.http.entity.ContentType import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository +import ru.ifmo.se.dating.exception.NotFoundException import ru.ifmo.se.dating.people.model.Picture import ru.ifmo.se.dating.people.storage.PictureContentStorage +import ru.ifmo.se.dating.people.storage.minio.exception.NoSuchKeyException +import ru.ifmo.se.dating.people.storage.minio.exception.UnknownException import java.io.ByteArrayInputStream @Repository @@ -22,23 +26,22 @@ class MinioPictureContentStorage( ) : PictureContentStorage { private val contentType = ContentType.IMAGE_JPEG - override suspend fun upload(id: Picture.Id, content: Picture.Content): Unit = - withContext(Dispatchers.IO) { - PutObjectArgs.builder() - .bucket(bucket) - .`object`(objectName(id)) - .stream( - ByteArrayInputStream(content.bytes), - content.bytes.size.toLong(), - /* partSize = */ -1, - ) - .contentType(contentType.mimeType) - .build() - .let { minio.putObject(it) } - } + override suspend fun upload(id: Picture.Id, content: Picture.Content): Unit = wrapped { + PutObjectArgs.builder() + .bucket(bucket) + .`object`(objectName(id)) + .stream( + ByteArrayInputStream(content.bytes), + content.bytes.size.toLong(), + /* partSize = */ -1, + ) + .contentType(contentType.mimeType) + .build() + .let { minio.putObject(it) } + } - override suspend fun download(id: Picture.Id): Picture.Content = - withContext(Dispatchers.IO) { + override suspend fun download(id: Picture.Id): Picture.Content = try { + wrapped { GetObjectArgs.builder() .bucket(bucket) .`object`(objectName(id)) @@ -47,15 +50,31 @@ class MinioPictureContentStorage( .use { it.readBytes() } .let { Picture.Content(it) } } + } catch (e: NoSuchKeyException) { + throw NotFoundException(e.message!!, e) + } - override suspend fun remove(id: Picture.Id) = - withContext(Dispatchers.IO) { - RemoveObjectArgs.builder() - .bucket(bucket) - .`object`(objectName(id)) - .build() - .let { minio.removeObject(it) } - } + override suspend fun remove(id: Picture.Id) = wrapped { + RemoveObjectArgs.builder() + .bucket(bucket) + .`object`(objectName(id)) + .build() + .let { minio.removeObject(it) } + } private fun objectName(id: Picture.Id) = "$id.jpg" + + private suspend fun wrapped(action: () -> T) = try { + withContext(Dispatchers.IO) { action() } + } catch (e: ErrorResponseException) { + val bucketName: String? = e.errorResponse().bucketName() + val objectName: String? = e.errorResponse().objectName() + when (e.errorResponse().code()) { + "NoSuchKey" -> + throw NoSuchKeyException("Key (bucket: $bucketName, object: $objectName) not found") + + else -> + throw UnknownException("Unknown error: ${e.errorResponse().message()}", e) + } + } } diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/exception/MinioException.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/exception/MinioException.kt new file mode 100644 index 00000000..f812a86c --- /dev/null +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/exception/MinioException.kt @@ -0,0 +1,6 @@ +package ru.ifmo.se.dating.people.storage.minio.exception + +import ru.ifmo.se.dating.exception.GenericException + +sealed class MinioException(message: String, cause: Throwable? = null) : + GenericException(message, cause) diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/exception/NoSuchKeyException.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/exception/NoSuchKeyException.kt new file mode 100644 index 00000000..ec797167 --- /dev/null +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/exception/NoSuchKeyException.kt @@ -0,0 +1,4 @@ +package ru.ifmo.se.dating.people.storage.minio.exception + +class NoSuchKeyException(message: String, cause: Throwable? = null) : + MinioException(message, cause) diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/exception/UnknownException.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/exception/UnknownException.kt new file mode 100644 index 00000000..989c57c3 --- /dev/null +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/exception/UnknownException.kt @@ -0,0 +1,4 @@ +package ru.ifmo.se.dating.people.storage.minio.exception + +class UnknownException(message: String, cause: Throwable? = null) : + MinioException(message, cause) From 5ef3ec0173a0ef50ddebc2380bac4a98ab573a2e Mon Sep 17 00:00:00 2001 From: vityaman Date: Wed, 15 Jan 2025 20:44:07 +0300 Subject: [PATCH 12/12] #60 Fix codestyle Signed-off-by: vityaman --- .../ru/ifmo/se/dating/gateway/CorsFilter.kt | 46 ++++++++++--------- .../se/dating/people/api/HttpPeopleApi.kt | 3 +- .../ru/ifmo/se/dating/people/model/Picture.kt | 6 +-- .../storage/minio/MinioClientConfiguration.kt | 5 +- .../minio/MinioPictureContentStorage.kt | 9 ++-- 5 files changed, 40 insertions(+), 29 deletions(-) diff --git a/backend/gateway/src/main/kotlin/ru/ifmo/se/dating/gateway/CorsFilter.kt b/backend/gateway/src/main/kotlin/ru/ifmo/se/dating/gateway/CorsFilter.kt index 4a1b6d78..682cfd99 100644 --- a/backend/gateway/src/main/kotlin/ru/ifmo/se/dating/gateway/CorsFilter.kt +++ b/backend/gateway/src/main/kotlin/ru/ifmo/se/dating/gateway/CorsFilter.kt @@ -11,41 +11,45 @@ import reactor.core.publisher.Mono @Component class CorsFilter : WebFilter { + 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 { ctx.response.headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*") ctx.response.headers.add( HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, - listOf( - HttpMethod.GET, - HttpMethod.PUT, - HttpMethod.POST, - HttpMethod.PATCH, - HttpMethod.DELETE, - HttpMethod.OPTIONS, - ).joinToString(", ") { it.name() }, + allowMethods.joinToString(", ") { it.name() }, ) ctx.response.headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "*") if (ctx.request.method == HttpMethod.OPTIONS) { - ctx.response.headers.add(HttpHeaders.ACCESS_CONTROL_MAX_AGE, 1728000.toString()) + ctx.response.headers.add(HttpHeaders.ACCESS_CONTROL_MAX_AGE, 1_728_000.toString()) ctx.response.statusCode = HttpStatus.NO_CONTENT return Mono.empty() } ctx.response.headers.add( HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, - listOf( - "DNT", - "X-CustomHeader", - "Keep-Alive", - "User-Agent", - "X-Requested-With", - "If-Modified-Since", - "Cache-Control", - "Content-Type", - "Content-Range", - "Range", - ).joinToString(","), + exposeHeaders.joinToString(","), ) return chain.filter(ctx) ?: Mono.empty() } diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt index 34e61abf..9e7bb0ad 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/api/HttpPeopleApi.kt @@ -110,7 +110,8 @@ class HttpPeopleApi( } override suspend fun peoplePersonIdPhotosPost( - personId: Long, body: Resource?, + personId: Long, + body: Resource?, ): ResponseEntity { require(body != null) diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/model/Picture.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/model/Picture.kt index 9bd3100b..2aac5dc0 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/model/Picture.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/model/Picture.kt @@ -14,9 +14,9 @@ data class Picture(val id: Id, val isReferenced: Boolean) { data class Content(val bytes: ByteArray) { override fun equals(other: Any?): Boolean = - (this === other) || - (javaClass == other?.javaClass) && - bytes.contentEquals((other as Content).bytes) + this === other || + javaClass == other?.javaClass && + bytes.contentEquals((other as Content).bytes) override fun hashCode(): Int = bytes.contentHashCode() diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioClientConfiguration.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioClientConfiguration.kt index c81b966b..e496e70c 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioClientConfiguration.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioClientConfiguration.kt @@ -9,9 +9,12 @@ import ru.ifmo.se.dating.logging.Log.Companion.autoLog @Configuration class MinioClientConfiguration { + private val isSecure = false + private val log = Log.autoLog() @Bean + @Suppress("LongParameterList") fun minioClient( @Value("\${storage.s3.url}") url: String, @@ -25,7 +28,7 @@ class MinioClientConfiguration { @Value("\${storage.s3.password}") password: String, ): MinioClient = MinioClient.builder() - .endpoint(url, port, /* secure = */ false) + .endpoint(url, port, isSecure) .credentials(username, password) .build() .also { client -> diff --git a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt index 7995891a..796cbebc 100644 --- a/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt +++ b/backend/people/src/main/kotlin/ru/ifmo/se/dating/people/storage/minio/MinioPictureContentStorage.kt @@ -33,7 +33,7 @@ class MinioPictureContentStorage( .stream( ByteArrayInputStream(content.bytes), content.bytes.size.toLong(), - /* partSize = */ -1, + -1, // partSize ) .contentType(contentType.mimeType) .build() @@ -64,17 +64,20 @@ class MinioPictureContentStorage( private fun objectName(id: Picture.Id) = "$id.jpg" + @Suppress("UseIfInsteadOfWhen") private suspend fun wrapped(action: () -> T) = try { withContext(Dispatchers.IO) { action() } } catch (e: ErrorResponseException) { val bucketName: String? = e.errorResponse().bucketName() val objectName: String? = e.errorResponse().objectName() when (e.errorResponse().code()) { - "NoSuchKey" -> + "NoSuchKey" -> { throw NoSuchKeyException("Key (bucket: $bucketName, object: $objectName) not found") + } - else -> + else -> { throw UnknownException("Unknown error: ${e.errorResponse().message()}", e) + } } } }