├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── deploy.yaml │ ├── identity-service-test.yaml │ ├── identity-service-unit-test.yaml │ ├── messaging-service-unit-test.yaml │ └── shared-lib-unit-test.yaml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── api ├── file-storage-service │ ├── file-storage.openapi.yaml │ └── file-storage.proto ├── identity-service │ ├── identity.openapi.yaml │ ├── identity.proto │ └── internal.identity.openapi.yaml ├── live-connection-service │ ├── kafka-messages.md │ ├── live-connection.openapi.yaml │ └── web-socket-messages.md ├── messaging-service │ └── messaging.openapi.yaml ├── standard.md └── user-service │ ├── user.openapi.yaml │ └── user.proto ├── docker-compose.yml ├── file-storage-service ├── Dockerfile ├── Makefile ├── config.go ├── config.yml ├── go.mod ├── go.sum ├── internal │ ├── handlers │ │ ├── get_file.go │ │ ├── upload.go │ │ ├── upload_abort.go │ │ ├── upload_complete.go │ │ ├── upload_init.go │ │ └── upload_part.go │ ├── proto │ │ ├── filestorage │ │ │ ├── file-storage.pb.go │ │ │ └── file-storage_grpc.pb.go │ │ └── grpc_service.go │ ├── restapi │ │ ├── common.go │ │ └── response.go │ ├── services │ │ ├── get_file.go │ │ ├── upload.go │ │ ├── upload_abort.go │ │ ├── upload_complete.go │ │ ├── upload_init.go │ │ └── upload_part.go │ └── storage │ │ ├── file_meta_storage.go │ │ └── upload_meta_storage.go └── main.go ├── identity-service ├── .dockerignore ├── Dockerfile ├── Makefile ├── config.go ├── config.yml ├── go.mod ├── go.sum ├── internal │ ├── handlers │ │ ├── identity.go │ │ ├── refresh_jwt.go │ │ ├── signin.go │ │ ├── signin_send_code.go │ │ ├── signout.go │ │ ├── signup.go │ │ ├── signup_send_code.go │ │ └── signup_verify_code.go │ ├── proto │ │ ├── grpc_service.go │ │ └── identity │ │ │ ├── identity.pb.go │ │ │ └── identity_grpc.pb.go │ ├── restapi │ │ ├── common.go │ │ └── response.go │ ├── services │ │ ├── identity.go │ │ ├── refresh_jwt.go │ │ ├── signin.go │ │ ├── signin_send_code.go │ │ ├── signin_send_code_test.go │ │ ├── signout.go │ │ ├── signup.go │ │ ├── signup_send_code.go │ │ └── signup_verify_code.go │ ├── sms │ │ ├── sms.go │ │ └── sms_stub.go │ ├── storage │ │ ├── device_storage.go │ │ ├── invalidated_token_storage.go │ │ ├── signin_meta_storage.go │ │ └── signup_meta_storage.go │ └── userservice │ │ ├── user.pb.go │ │ └── user_grpc.pb.go └── main.go ├── img └── architecture.png ├── k8s ├── Chart.yaml ├── README.md ├── templates │ ├── configmap.yaml │ ├── deployment.yaml │ ├── external-secret.yaml │ ├── extra-manifest.yaml │ ├── job.yaml │ ├── local-secret.yaml │ ├── pg.yaml │ ├── redis.yaml │ └── service.yaml ├── values-local.yaml └── values-test.yaml ├── live-connection-service ├── Dockerfile ├── config.yml ├── go.mod ├── go.sum ├── internal │ ├── handler │ │ └── last_online.go │ ├── models │ │ └── message.go │ ├── mq │ │ ├── consumer.go │ │ └── producer.go │ ├── restapi │ │ ├── common.go │ │ └── response.go │ ├── services │ │ ├── kafka.go │ │ └── last_online.go │ ├── storage │ │ └── last-online.go │ └── ws │ │ └── hub.go ├── main.go └── migrations │ └── V001__ping.sql ├── local.env ├── messaging-service ├── .mockery.yaml ├── Dockerfile ├── Makefile ├── README.md ├── cmd │ └── main.go ├── config.yml ├── go.mod ├── go.sum ├── internal │ ├── application │ │ ├── dto │ │ │ ├── convert.go │ │ │ ├── file_message_dto.go │ │ │ ├── group_chat_dto.go │ │ │ ├── personal_chat_dto.go │ │ │ ├── reaction_dto.go │ │ │ ├── secret_group_chat_dto.go │ │ │ ├── secret_personal_chat_dto.go │ │ │ ├── secret_update_dto.go │ │ │ ├── text_message_dto.go │ │ │ ├── text_message_edited_dto.go │ │ │ └── update_deleted_dto.go │ │ ├── external │ │ │ ├── file_storage.go │ │ │ └── mq_publisher.go │ │ ├── generic │ │ │ ├── chat.go │ │ │ ├── chat_test.go │ │ │ ├── update.go │ │ │ └── update_test.go │ │ ├── publish │ │ │ ├── events │ │ │ │ ├── chat.go │ │ │ │ └── events.go │ │ │ └── publisher.go │ │ ├── request │ │ │ ├── chat.go │ │ │ └── update.go │ │ ├── services │ │ │ ├── chat │ │ │ │ ├── generic_chat_service.go │ │ │ │ ├── group_chat_service.go │ │ │ │ ├── group_photo_service.go │ │ │ │ ├── personal_chat_service.go │ │ │ │ ├── secret_group_chat_service.go │ │ │ │ ├── secret_group_photo_service.go │ │ │ │ └── secret_personal_chat_service.go │ │ │ ├── common.go │ │ │ ├── errors.go │ │ │ └── update │ │ │ │ ├── generic_update_service.go │ │ │ │ ├── group_file_service.go │ │ │ │ ├── group_update_service.go │ │ │ │ ├── personal_file_service.go │ │ │ │ ├── personal_update_service.go │ │ │ │ ├── secret_group_update_service.go │ │ │ │ └── secret_personal_update_service.go │ │ └── storage │ │ │ ├── repository │ │ │ ├── chatter_repository.go │ │ │ ├── generic_chat_repository.go │ │ │ ├── generic_update_repository.go │ │ │ ├── group_chat_repository.go │ │ │ ├── mocks │ │ │ │ └── mock_personal_chat_repository.go │ │ │ ├── personal_chat_repository.go │ │ │ ├── secret_group_chat_repository.go │ │ │ ├── secret_personal_chat_repository.go │ │ │ └── update_repository.go │ │ │ └── sql.go │ ├── configuration │ │ ├── config.go │ │ ├── db.go │ │ ├── external.go │ │ ├── gin.go │ │ ├── handler.go │ │ └── service.go │ ├── domain │ │ ├── chat.go │ │ ├── errors.go │ │ ├── file_message.go │ │ ├── file_message_test.go │ │ ├── group │ │ │ └── group_chat.go │ │ ├── message.go │ │ ├── personal │ │ │ └── personal_chat.go │ │ ├── reaction.go │ │ ├── reaction_test.go │ │ ├── secgroup │ │ │ └── secret_group_chat.go │ │ ├── secpersonal │ │ │ └── secret_personal_chat.go │ │ ├── secret_chat.go │ │ ├── secret_update.go │ │ ├── text_message.go │ │ ├── text_message_test.go │ │ └── update.go │ ├── infrastructure │ │ ├── kafkamq │ │ │ └── mq.go │ │ ├── postgres │ │ │ ├── chat │ │ │ │ ├── chatter_repository.go │ │ │ │ ├── generic_chat_repository.go │ │ │ │ ├── group_chat_repository.go │ │ │ │ ├── personal_chat_repository.go │ │ │ │ ├── secret_group_chat_repository.go │ │ │ │ ├── secret_personal_chat_repository.go │ │ │ │ └── utils.go │ │ │ ├── instrumentation │ │ │ │ └── tracing.go │ │ │ └── update │ │ │ │ ├── generic_update_repository.go │ │ │ │ ├── secret_update_repository.go │ │ │ │ └── update_repository.go │ │ └── proto │ │ │ ├── file_storage.go │ │ │ └── filestorage │ │ │ ├── file-storage.pb.go │ │ │ └── file-storage_grpc.pb.go │ └── rest │ │ ├── errmap │ │ └── errmap.go │ │ ├── handlers │ │ ├── chat │ │ │ ├── generic_chat_handler.go │ │ │ ├── group_chat_handler.go │ │ │ ├── group_photo_handler.go │ │ │ ├── personal_chat_handler.go │ │ │ ├── secret_group_handler.go │ │ │ ├── secret_group_photo_handler.go │ │ │ ├── secret_personal_chat_handler.go │ │ │ └── utils.go │ │ └── update │ │ │ ├── generic_update_handler.go │ │ │ ├── group_file_handler.go │ │ │ ├── group_update_handler.go │ │ │ ├── personal_file_handler.go │ │ │ ├── personal_update_handler.go │ │ │ ├── secret_group_update_handler.go │ │ │ ├── secret_personal_update_handler.go │ │ │ └── utils.go │ │ ├── middleware │ │ ├── authorization.go │ │ └── internal_error.go │ │ └── restapi │ │ ├── common.go │ │ └── response.go └── migrations │ ├── V001__init_chats.sql │ ├── V002__chat_type_trigger.sql │ ├── V003__prohibit_subchats_delete.sql │ ├── V004__init_updates.sql │ └── V005__update_id_sequence.sql ├── nginx.conf ├── nginx └── static │ ├── final_icon.ico │ ├── fonts │ ├── Jost-VariableFont_wght.ttf │ └── RammettoOne-Regular.ttf │ ├── images │ ├── faq.png │ ├── github.png │ └── tg.png │ ├── index.html │ └── style.css ├── notification-service ├── config.yml ├── go.mod ├── go.sum ├── internal │ ├── grpc_service │ │ └── grpc_service.go │ ├── identity │ │ ├── identity.pb.go │ │ └── identity_grpc.pb.go │ ├── notifier │ │ ├── apns_service.go │ │ ├── kafka_consumer.go │ │ └── parser.go │ └── user │ │ ├── user.pb.go │ │ └── user_grpc.pb.go └── main.go ├── otel-collector-config.yaml ├── shared └── go │ ├── auth │ ├── auth.go │ └── auth_test.go │ ├── go.mod │ ├── go.sum │ ├── idempotency │ ├── idempotency.go │ ├── idempotency_storage.go │ ├── idempotency_test.go │ └── locker.go │ ├── internal │ └── restapi │ │ ├── common.go │ │ └── response.go │ ├── jwt │ ├── jwt.go │ └── jwt_test.go │ └── postgres │ ├── sql.go │ └── tracing.go ├── stubs ├── sms-service-stub │ ├── Dockerfile │ ├── go.mod │ ├── go.sum │ └── main.go └── user-service-stub │ ├── Dockerfile │ ├── go.mod │ ├── go.sum │ ├── main.go │ └── userservice │ ├── user.pb.go │ └── user_grpc.pb.go ├── tests ├── identity-service │ ├── Makefile │ ├── docker-compose.yml │ ├── identity-service-config.yml │ └── service-test │ │ ├── Dockerfile │ │ ├── common │ │ └── common.go │ │ ├── go.mod │ │ ├── go.sum │ │ ├── identity_test.go │ │ ├── refresh_jwt_test.go │ │ ├── signin_send_code_test.go │ │ ├── signin_test.go │ │ ├── signout_test.go │ │ ├── signup_send_code_test.go │ │ ├── signup_test.go │ │ └── signup_verifiy_test.go └── user-service │ ├── Makefile │ ├── docker-compose.yml │ ├── service-test │ ├── Dockerfile │ ├── go.mod │ ├── go.sum │ ├── grpc_test.go │ ├── http_test.go │ └── userservice │ │ ├── user.pb.go │ │ └── user_grpc.pb.go │ └── user-service-config.yml └── user-service ├── Dockerfile ├── Makefile ├── config.yml ├── go.mod ├── go.sum ├── internal ├── filestorage │ ├── file-storage.pb.go │ └── file-storage_grpc.pb.go ├── grpcservice │ ├── user.pb.go │ └── user_grpc.pb.go ├── handlers │ ├── check_user.go │ ├── get_restrictions.go │ ├── get_user.go │ ├── process_photo.go │ ├── teapot.go │ ├── update_restrictions.go │ ├── update_user.go │ └── user_server.go ├── models │ └── models.go ├── restapi │ ├── common.go │ └── response.go ├── services │ ├── get_restrictions.go │ ├── get_user.go │ ├── process_photo.go │ ├── update_restrictions.go │ ├── update_user.go │ └── user_service.go └── storage │ ├── restriction_storage.go │ └── user_storage.go ├── main.go └── migrations └── V001__user.sql /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | 7 | [*.go] 8 | indent_style = tab 9 | indent_size = 4 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # 2 | # https://help.github.com/articles/dealing-with-line-endings/ 3 | # 4 | # Linux start script should use lf 5 | /gradlew text eol=lf 6 | 7 | # These are Windows script files and should use crlf 8 | *.bat text eol=crlf 9 | 10 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy to VPS 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | name: Deploy to VPS 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Set up SSH key 15 | run: | 16 | mkdir -p ~/.ssh 17 | echo "${{ secrets.SSH_PRIVATE_KEY }}" | tr -d '\r' > ~/.ssh/id_rsa 18 | chmod 600 ~/.ssh/id_rsa 19 | ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 20 | 21 | - name: Test SSH Connection 22 | run: | 23 | ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "echo 'Successfully connected'" 24 | 25 | - name: Pull Latest Code 26 | run: | 27 | ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "cd ${{ secrets.WORK_DIR }} && git switch main && git pull" 28 | 29 | - name: Docker system prune 30 | run: | 31 | ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "docker system prune --force" 32 | 33 | - name: Deploy Application 34 | run: | 35 | ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "cd ${{ secrets.WORK_DIR }} && make down && make run" 36 | 37 | - name: Clean Up 38 | run: rm -rf ~/.ssh 39 | -------------------------------------------------------------------------------- /.github/workflows/identity-service-test.yaml: -------------------------------------------------------------------------------- 1 | name: Run Identity Service Tests in Docker 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | paths: 8 | - identity-service/** 9 | - .github/workflows/identity-service-test.yaml 10 | - tests/identity-service/** 11 | pull_request: 12 | branches: 13 | - main 14 | paths: 15 | - identity-service/** 16 | - .github/workflows/identity-service-test.yaml 17 | - tests/identity-service/** 18 | 19 | jobs: 20 | test: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v3 26 | 27 | - name: Set up Docker Buildx 28 | uses: docker/setup-buildx-action@v2 29 | 30 | - name: Cache Docker Compose 31 | id: docker-compose-cache 32 | uses: actions/cache@v3 33 | with: 34 | path: /usr/local/bin/docker-compose 35 | key: ${{ runner.os }}-docker-compose-v2 # Use Docker Compose version in the key 36 | restore-keys: | 37 | ${{ runner.os }}-docker-compose- 38 | 39 | - name: Install Docker Compose if not cached 40 | run: | 41 | if [ ! -f /usr/local/bin/docker-compose ]; then 42 | sudo apt-get update 43 | sudo apt-get install -y docker-compose 44 | fi 45 | 46 | - name: Verify Docker Compose is available 47 | run: docker-compose --version 48 | 49 | - name: Cache Docker Layers 50 | uses: actions/cache@v3 51 | with: 52 | path: /tmp/.buildx-cache 53 | key: ${{ runner.os }}-docker-layers-${{ github.sha }} 54 | restore-keys: | 55 | ${{ runner.os }}-docker-layers- 56 | 57 | - name: Generate keys 58 | run: | 59 | cd tests/identity-service 60 | make keys-rsa 61 | make keys-sym 62 | cd ../.. 63 | 64 | - name: Build 65 | run: | 66 | docker-compose -f tests/identity-service/docker-compose.yml \ 67 | build --build-arg BUILDKIT_INLINE_CACHE=1 68 | - name: Run tests 69 | run: | 70 | docker-compose -f tests/identity-service/docker-compose.yml \ 71 | up --abort-on-container-exit --exit-code-from test 72 | 73 | - name: Tear down 74 | if: always() 75 | run: docker-compose -f tests/identity-service/docker-compose.yml down 76 | -------------------------------------------------------------------------------- /.github/workflows/identity-service-unit-test.yaml: -------------------------------------------------------------------------------- 1 | name: Identity Service Unit Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | paths: 8 | - identity-service/**.go 9 | - identity-service/**/go.mod 10 | - .github/workflows/identity-service-unit-test.yaml 11 | pull_request: 12 | branches: 13 | - main 14 | paths: 15 | - identity-service/**.go 16 | - identity-service/**/go.mod 17 | - .github/workflows/identity-service-unit-test.yaml 18 | jobs: 19 | unit-test: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Set up Go 24 | uses: actions/setup-go@v2 25 | with: 26 | go-version: 1.23.1 27 | - name: Test 28 | run: | 29 | cd identity-service 30 | go test -v -race ./... -------------------------------------------------------------------------------- /.github/workflows/messaging-service-unit-test.yaml: -------------------------------------------------------------------------------- 1 | name: Messaging Service Unit Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | paths: 8 | - messaging-service/**.go 9 | - messaging-service/**/go.mod 10 | - .github/workflows/messaging-service-unit-test.yaml 11 | pull_request: 12 | branches: 13 | - main 14 | paths: 15 | - messaging-service/**.go 16 | - messaging-service/**/go.mod 17 | - .github/workflows/messaging-service-unit-test.yaml 18 | jobs: 19 | unit-test: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Set up Go 24 | uses: actions/setup-go@v2 25 | with: 26 | go-version: 1.23.1 27 | - name: Test 28 | run: | 29 | cd messaging-service 30 | go test -v -race ./... -------------------------------------------------------------------------------- /.github/workflows/shared-lib-unit-test.yaml: -------------------------------------------------------------------------------- 1 | name: Shared Go Library Unit Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | paths: 8 | - shared/go/**.go 9 | - shared/go/**/go.mod 10 | - .github/workflows/shared-lib-unit-test.yaml 11 | pull_request: 12 | branches: 13 | - main 14 | paths: 15 | - shared/go/**.go 16 | - shared/go/**/go.mod 17 | - .github/workflows/shared-lib-unit-test.yaml 18 | jobs: 19 | unit-test: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Set up Go 24 | uses: actions/setup-go@v2 25 | with: 26 | go-version: 1.23.1 27 | - name: Test 28 | run: | 29 | cd shared/go 30 | go test -v -race ./... -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | # Go workspace file 20 | go.work 21 | go.work.sum 22 | 23 | # env file 24 | .env 25 | 26 | # Keys 27 | keys/ 28 | ssl/ 29 | minio-certs/ 30 | 31 | # Xcode 32 | .DS_Store 33 | build/ 34 | *.pbxuser 35 | !default.pbxuser 36 | *.mode1v3 37 | !default.mode1v3 38 | *.mode2v3 39 | !default.mode2v3 40 | *.perspectivev3 41 | !default.perspectivev3 42 | xcuserdata/ 43 | *.xccheckout 44 | *.moved-aside 45 | DerivedData/ 46 | *.xcuserstate 47 | 48 | # Swift Package Manager 49 | .idea/ 50 | .build/ 51 | .swiftpm/xcode/ 52 | 53 | # CocoaPods 54 | Pods/ 55 | 56 | # Carthage 57 | Carthage/Build/ 58 | 59 | # Fastlane 60 | fastlane/report.xml 61 | fastlane/Preview.html 62 | fastlane/screenshots 63 | fastlane/test_output 64 | 65 | # Code Coverage 66 | *.xccovreport 67 | *.xccovarchive 68 | 69 | # Temporary files 70 | *.temp 71 | *.swp 72 | *.lock 73 | 74 | # Archives 75 | *.xcarchive 76 | 77 | 78 | # Other 79 | *.log 80 | *.xcworkspace 81 | 82 | # Ignore Gradle project-specific cache directory 83 | .gradle 84 | 85 | # Ignore Gradle build output directory 86 | build 87 | 88 | .tmp/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Repository contributing rules 2 | Direct commits in `main` branch are prohibited. \ 3 | Every pull-request should be reviewed by at least one member of the team. 4 | 5 | ## Branch naming 6 | for **Features** create `feature/` branches \ 7 | for **Fixes** create `fix/` branches \ 8 | for **Docs** create `docs/` branches \ 9 | for **Others** create `chore/` branches 10 | 11 | ## Pull-request naming 12 | **Features** should have `🚀 feature:` prefix \ 13 | **Fixes** should have `🩹 fix:` prefix \ 14 | **Docs** should have `📚 docs:` prefix \ 15 | **Others** should have `🧹 chore:` prefix 16 | 17 | ## Commits 18 | Please make sure your commit message is clear and concise. \ 19 | Please separate different logical changes to separate commits. 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2025 chakchat 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | gen-ssl-cert: 2 | mkdir -p ssl 3 | openssl genrsa -out ssl/self-signed.key 2048 4 | openssl req -new -x509 -key ssl/self-signed.key -out ssl/self-signed.crt -days 365 5 | 6 | keys-rsa: 7 | mkdir -p keys 8 | openssl genrsa -out keys/rsa 2048 9 | openssl rsa -in keys/rsa -pubout -out keys/rsa.pub 10 | 11 | keys-sym: 12 | openssl rand -hex 64 | tr -d '\n' > keys/sym 13 | 14 | gen: gen-ssl-cert keys-rsa keys-sym 15 | 16 | run: 17 | docker-compose up -d --build 18 | 19 | down: 20 | docker-compose down 21 | 22 | clean: 23 | docker-compose down --volumes 24 | 25 | unit-test: 26 | cd identity-service && go test ./... 27 | cd file-storage-service && go test ./... 28 | cd shared/go && go test ./... 29 | 30 | identity-service-test: 31 | cd tests/identity-service && make test 32 | 33 | .PHONY: test 34 | test: unit-test identity-service-test 35 | echo "All tests passed" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Deploy to VPS](https://github.com/chakchat/chakchat-backend/actions/workflows/deploy.yaml/badge.svg)](https://github.com/chakchat/chakchat-backend/actions/workflows/deploy.yaml) 2 | 3 | # About 4 | This is a messenger with secret mode where synchoronous e2e encryption is used. 5 | 6 | # Architecture 7 | ![Architecture](./img/architecture.png) 8 | 9 | # Observability 10 | We make our services observable with **OpenTelemetry** **traces** and **metrics**. \ 11 | Logs are replaced with trace events and attributes in order to force using tracing. 12 | 13 | We use OpenTelemetry collector configured in [otel-collector-config.yaml](otel-collector-config.yaml). \ 14 | Traces are visualized with **Jaeger** on `16686` port 15 | 16 | # Deployment 17 | For now all services are hosted on a single machine only for **Development** needs, so single docker-compose.yml file is used. 18 | 19 | **Note**: This setup is intended for development only and is not suitable for production use. For security reasons, access to the development environment is restricted. \ 20 | In the future this all will work in k8s. 21 | 22 | # Run 23 | [Makefile](Makefile) is used for some frequent scenarios. \ 24 | If you want to run the backend on your local machine, you should firstly generate some keys and self-signed SSL certificates by `make gen` \ 25 | After that you can just use `make run` to start and `make down` to stop. \ 26 | (You'll also need a working docker daemon) 27 | 28 | `make test` runs all existing tests including unit, service and intergation tests. 29 | 30 | # Contributing 31 | If you want to contribute to this project, please read [CONTRIBUTING.md](CONTRIBUTING.md) first. -------------------------------------------------------------------------------- /api/file-storage-service/file-storage.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package filestorage; 4 | 5 | option go_package = "./filestorage"; 6 | 7 | message UUID { 8 | string value = 1; 9 | } 10 | 11 | message GetFileRequest { 12 | UUID fileId = 1; 13 | } 14 | 15 | message GetFileResponse { 16 | UUID fileId = 1; 17 | string fileName = 2; 18 | int64 fileSize = 3; 19 | string mimeType = 4; 20 | string fileUrl = 5; 21 | int64 createdAtUNIX = 6; 22 | } 23 | 24 | service FileStorageService { 25 | rpc GetFile(GetFileRequest) returns (GetFileResponse); 26 | } -------------------------------------------------------------------------------- /api/identity-service/identity.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package identity; 4 | 5 | option go_package = "./identity"; 6 | 7 | message UUID { 8 | string value = 1; 9 | } 10 | 11 | enum DeviceTokenResponseStatus { 12 | SUCCESS = 0; 13 | FAILED = 1; 14 | NOT_FOUND = 2; 15 | } 16 | 17 | message DeviceTokenRequest { 18 | string user_id = 1; 19 | } 20 | 21 | message DeviceTokenResponse { 22 | DeviceTokenResponseStatus status = 1; 23 | optional string device_token = 2; 24 | } 25 | 26 | service IdentityService { 27 | rpc GetDeviceTokens(DeviceTokenRequest) returns (DeviceTokenResponse); 28 | } -------------------------------------------------------------------------------- /api/identity-service/internal.identity.openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: Internal Identity Service 4 | description: Identity Service Internal REST API 5 | version: "1.0" 6 | servers: 7 | # internal prefix is used to prevent the client from access this endpoint. 8 | # Other endpoints will have version prefix. 9 | - url: http://localhost:5000/internal/v1.0/identity/ 10 | description: nginx reverse-proxied localhost 11 | paths: 12 | /identity: 13 | get: 14 | summary: Get user's identity 15 | description: Get user's identity 16 | tags: 17 | - internal-identity 18 | parameters: 19 | - name: Authorization 20 | in: header 21 | required: true 22 | schema: 23 | type: string 24 | format: jwt 25 | responses: 26 | '204': 27 | description: Successfylly created internal JWT 28 | headers: 29 | X-Internal-Token: 30 | schema: 31 | type: string 32 | format: jwt 33 | description: Internal JWT 34 | '401': 35 | description: Unauthorized 36 | content: 37 | application/json: 38 | schema: 39 | "$ref": '#/components/schemas/ErrorResponse' 40 | components: 41 | schemas: 42 | ErrorResponse: 43 | type: object 44 | description: Error response specified by standard.md 45 | properties: 46 | error_type: 47 | type: string 48 | nullable: false 49 | error_message: 50 | type: string 51 | nullable: false 52 | error_details: 53 | type: array 54 | items: 55 | type: object 56 | properties: 57 | field: 58 | type: string 59 | nullable: false 60 | message: 61 | type: string 62 | nullable: false 63 | example: 64 | error_type: invalid_input 65 | error_message: Input is invalid 66 | error_details: [] -------------------------------------------------------------------------------- /api/user-service/user.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package user; 4 | 5 | option go_package = "./userservice"; 6 | 7 | message UserRequest { 8 | string phoneNumber = 1; 9 | } 10 | 11 | message UUID { 12 | string value = 1; 13 | } 14 | 15 | enum UserResponseStatus { 16 | SUCCESS = 0; 17 | FAILED = 1; 18 | NOT_FOUND = 2; 19 | } 20 | 21 | message UserResponse { 22 | UserResponseStatus status = 1; 23 | // If password verified then 24 | optional string name = 2; 25 | optional string userName = 3; 26 | optional UUID userId = 4; 27 | } 28 | 29 | message CreateUserRequest { 30 | string phoneNumber = 1; 31 | string name = 2; 32 | string username = 3; 33 | } 34 | 35 | enum CreateUserStatus { 36 | CREATED = 0; 37 | CREATE_FAILED = 1; 38 | ALREADY_EXISTS = 2; 39 | VALIDATION_FAILED = 3; 40 | } 41 | 42 | message CreateUserResponse { 43 | CreateUserStatus status = 1; 44 | optional UUID userId = 2; 45 | optional string name = 3; 46 | optional string userName = 4; 47 | } 48 | 49 | message GetNameRequest { 50 | string user_id = 1; 51 | } 52 | 53 | message GetNameResponse { 54 | UserResponseStatus status = 1; 55 | optional string name = 2; 56 | } 57 | 58 | service UserService { 59 | rpc GetUser(UserRequest) returns (UserResponse); 60 | rpc CreateUser(CreateUserRequest) returns (CreateUserResponse); 61 | rpc GetName(GetNameRequest) returns (GetNameResponse); 62 | } -------------------------------------------------------------------------------- /file-storage-service/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | FROM golang:1.23.1 AS builder 3 | 4 | # Set the working directory inside the container 5 | WORKDIR /app 6 | 7 | # Copy go.mod and go.sum files for dependency installation 8 | COPY go.mod go.sum ./ 9 | 10 | # Download and cache dependencies 11 | RUN go mod download 12 | 13 | # Copy the rest of the application code 14 | COPY . . 15 | 16 | # Build the application binary 17 | RUN go build -o main . 18 | 19 | # Stage 2: Run 20 | FROM ubuntu:latest 21 | 22 | # https://chatgpt.com/share/67851f69-d6e8-8011-b3fb-839ce5bf275c 23 | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* 24 | 25 | # Set the working directory inside the container 26 | WORKDIR /app 27 | 28 | # Copy the compiled binary from the builder stage 29 | COPY --from=builder /app/main . 30 | 31 | # Expose the port your application runs on (optional) 32 | EXPOSE 5004 33 | 34 | # Command to run the application 35 | CMD ["./main"] 36 | -------------------------------------------------------------------------------- /file-storage-service/Makefile: -------------------------------------------------------------------------------- 1 | gen-grpc: 2 | protoc --go_out="./internal/proto" --go-grpc_out="./internal/proto" --proto_path="../api/file-storage-service" file-storage.proto -------------------------------------------------------------------------------- /file-storage-service/config.yml: -------------------------------------------------------------------------------- 1 | jwt: 2 | signing_method: RS256 3 | lifetime: 3m 4 | issuer: identity_service 5 | audience: 6 | - file_storage_service 7 | key_file_path: /app/keys/rsa.pub 8 | redis: 9 | addr: file-storage-redis:6379 10 | password: secret 11 | db: 0 12 | idempotency: 13 | data_exp: 10m 14 | upload: 15 | file_size_limit: 10485760 # 10MB 16 | multipart_upload: 17 | min_file_size: 524288000 # 10MB 18 | max_part_size: 1048576 # 100MB 19 | s3: 20 | bucket: demo-chakchat-yandex-storage 21 | url_prefix: https://storage.yandexcloud.net/demo-chakchat-yandex-storage/ 22 | otlp: 23 | grpc_addr: otel-collector:4317 24 | grpc_service: 25 | port: 9090 -------------------------------------------------------------------------------- /file-storage-service/internal/handlers/get_file.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/chakchat/chakchat-backend/file-storage-service/internal/restapi" 8 | "github.com/chakchat/chakchat-backend/file-storage-service/internal/services" 9 | "github.com/gin-gonic/gin" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | const paramFileId = "fileId" 14 | 15 | type GetFileService interface { 16 | GetFile(context.Context, uuid.UUID) (*services.FileMeta, error) 17 | } 18 | 19 | func GetFile(service GetFileService) gin.HandlerFunc { 20 | return func(c *gin.Context) { 21 | fileId, err := uuid.Parse(c.Param(paramFileId)) 22 | if err != nil { 23 | restapi.SendValidationError(c, []restapi.ErrorDetail{ 24 | { 25 | Field: "fileId", 26 | Message: "Invalid fileId query parameter", 27 | }, 28 | }) 29 | return 30 | } 31 | 32 | file, err := service.GetFile(c.Request.Context(), fileId) 33 | if err != nil { 34 | if err == services.ErrFileNotFound { 35 | c.JSON(http.StatusNotFound, restapi.ErrorResponse{ 36 | ErrorType: restapi.ErrTypeFileNotFound, 37 | ErrorMessage: "File not found", 38 | }) 39 | return 40 | } 41 | c.Error(err) 42 | restapi.SendInternalError(c) 43 | return 44 | } 45 | 46 | restapi.SendSuccess(c, fileResponse{ 47 | FileName: file.FileName, 48 | FileSize: file.FileSize, 49 | MimeType: file.MimeType, 50 | FileId: file.FileId, 51 | FileUrl: file.FileUrl, 52 | CreatedAt: file.CreatedAt, 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /file-storage-service/internal/handlers/upload_abort.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/chakchat/chakchat-backend/file-storage-service/internal/restapi" 8 | "github.com/chakchat/chakchat-backend/file-storage-service/internal/services" 9 | "github.com/gin-gonic/gin" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | type UploadAbortService interface { 14 | Abort(ctx context.Context, uploadId uuid.UUID) error 15 | } 16 | 17 | func UploadAbort(service UploadAbortService) gin.HandlerFunc { 18 | return func(c *gin.Context) { 19 | var req uploadAbortRequest 20 | if err := c.ShouldBindBodyWithJSON(&req); err != nil { 21 | restapi.SendUnprocessableJSON(c) 22 | return 23 | } 24 | 25 | err := service.Abort(c.Request.Context(), req.UploadId) 26 | 27 | if err != nil { 28 | if err == services.ErrUploadNotFound { 29 | c.JSON(http.StatusNotFound, restapi.ErrorResponse{ 30 | ErrorType: restapi.ErrTypeUploadNotFound, 31 | ErrorMessage: "Upload not found", 32 | }) 33 | return 34 | } 35 | c.Error(err) 36 | restapi.SendInternalError(c) 37 | return 38 | } 39 | 40 | restapi.SendSuccess(c, struct{}{}) 41 | } 42 | } 43 | 44 | type uploadAbortRequest struct { 45 | UploadId uuid.UUID `json:"upload_id" binding:"required"` 46 | } 47 | -------------------------------------------------------------------------------- /file-storage-service/internal/handlers/upload_complete.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/chakchat/chakchat-backend/file-storage-service/internal/restapi" 8 | "github.com/chakchat/chakchat-backend/file-storage-service/internal/services" 9 | "github.com/gin-gonic/gin" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | type UploadCompleteService interface { 14 | Complete(context.Context, *services.UploadCompleteRequest) (*services.FileMeta, error) 15 | } 16 | 17 | func UploadComplete(service UploadCompleteService) gin.HandlerFunc { 18 | return func(c *gin.Context) { 19 | var req uploadCompleteRequest 20 | if err := c.ShouldBindBodyWithJSON(&req); err != nil { 21 | restapi.SendUnprocessableJSON(c) 22 | return 23 | } 24 | 25 | parts := make([]services.UploadPart, 0, len(req.Parts)) 26 | for _, part := range req.Parts { 27 | parts = append(parts, services.UploadPart{ 28 | PartNumber: part.PartNumber, 29 | ETag: part.ETag, 30 | }) 31 | } 32 | file, err := service.Complete(c.Request.Context(), &services.UploadCompleteRequest{ 33 | UploadId: req.UploadId, 34 | Parts: parts, 35 | }) 36 | 37 | if err != nil { 38 | switch err { 39 | case services.ErrUploadNotFound: 40 | c.JSON(http.StatusNotFound, restapi.ErrorResponse{ 41 | ErrorType: restapi.ErrTypeUploadNotFound, 42 | ErrorMessage: "Upload not found", 43 | }) 44 | // TODO: handle occured errors 45 | default: 46 | c.Error(err) 47 | restapi.SendInternalError(c) 48 | } 49 | return 50 | } 51 | 52 | restapi.SendSuccess(c, fileResponse{ 53 | FileName: file.FileName, 54 | FileSize: file.FileSize, 55 | MimeType: file.MimeType, 56 | FileId: file.FileId, 57 | FileUrl: file.FileUrl, 58 | CreatedAt: file.CreatedAt, 59 | }) 60 | } 61 | } 62 | 63 | type uploadCompleteRequest struct { 64 | UploadId uuid.UUID `json:"upload_id" binding:"required"` 65 | Parts []struct { 66 | PartNumber int `json:"part_number" binding:"required"` 67 | ETag string `json:"e_tag" binding:"required"` 68 | } `json:"parts" binding:"required"` 69 | } 70 | -------------------------------------------------------------------------------- /file-storage-service/internal/handlers/upload_init.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chakchat/chakchat-backend/file-storage-service/internal/restapi" 7 | "github.com/chakchat/chakchat-backend/file-storage-service/internal/services" 8 | "github.com/gin-gonic/gin" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | type MultipartUploadConfig struct { 13 | MinFileSize int64 14 | MaxPartSize int64 15 | } 16 | 17 | type UploadInitService interface { 18 | Init(context.Context, *services.UploadInitRequest) (uploadId uuid.UUID, err error) 19 | } 20 | 21 | func UploadInit(conf *MultipartUploadConfig, service UploadInitService) gin.HandlerFunc { 22 | return func(c *gin.Context) { 23 | var req uploadInitRequest 24 | if err := c.ShouldBindBodyWithJSON(&req); err != nil { 25 | restapi.SendUnprocessableJSON(c) 26 | return 27 | } 28 | 29 | uploadId, err := service.Init(c.Request.Context(), &services.UploadInitRequest{ 30 | FileName: req.FileName, 31 | MimeType: req.MimeType, 32 | }) 33 | if err != nil { 34 | // TODO: for now I don't know what may occur here 35 | // But please handle errors properly. 36 | c.Error(err) 37 | restapi.SendInternalError(c) 38 | return 39 | } 40 | 41 | restapi.SendSuccess(c, uploadInitResponse{ 42 | UploadId: uploadId, 43 | }) 44 | } 45 | } 46 | 47 | type uploadInitRequest struct { 48 | FileName string `json:"file_name" binding:"required"` 49 | MimeType string `json:"mime_type" binding:"required"` 50 | } 51 | 52 | type uploadInitResponse struct { 53 | UploadId uuid.UUID `json:"upload_id"` 54 | } 55 | -------------------------------------------------------------------------------- /file-storage-service/internal/proto/grpc_service.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/chakchat/chakchat-backend/file-storage-service/internal/proto/filestorage" 8 | "github.com/chakchat/chakchat-backend/file-storage-service/internal/services" 9 | "github.com/google/uuid" 10 | "google.golang.org/grpc/codes" 11 | "google.golang.org/grpc/status" 12 | ) 13 | 14 | type GRPCService struct { 15 | service *services.GetFileService 16 | filestorage.UnimplementedFileStorageServiceServer 17 | } 18 | 19 | func NewGRPCServer(service *services.GetFileService) *GRPCService { 20 | return &GRPCService{ 21 | service: service, 22 | } 23 | } 24 | 25 | func (s *GRPCService) GetFile(ctx context.Context, req *filestorage.GetFileRequest) (*filestorage.GetFileResponse, error) { 26 | fileId, err := uuid.Parse(req.GetFileId().Value) 27 | if err != nil { 28 | return nil, status.New(codes.InvalidArgument, "Cannot parse UUID").Err() 29 | } 30 | 31 | file, err := s.service.GetFile(ctx, fileId) 32 | if err != nil { 33 | if errors.Is(err, services.ErrFileNotFound) { 34 | return nil, status.New(codes.NotFound, "File not found").Err() 35 | } 36 | return nil, err 37 | } 38 | 39 | return &filestorage.GetFileResponse{ 40 | FileId: &filestorage.UUID{Value: file.FileId.String()}, 41 | FileName: file.FileName, 42 | FileSize: file.FileSize, 43 | MimeType: file.MimeType, 44 | FileUrl: file.FileUrl, 45 | CreatedAtUNIX: file.CreatedAt.Unix(), 46 | }, nil 47 | } 48 | -------------------------------------------------------------------------------- /file-storage-service/internal/restapi/common.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func SendSuccess(c *gin.Context, data any) { 10 | c.JSON(http.StatusOK, SuccessResponse{ 11 | Data: data, 12 | }) 13 | } 14 | 15 | func SendUnprocessableJSON(c *gin.Context) { 16 | c.JSON(http.StatusUnprocessableEntity, ErrorResponse{ 17 | ErrorType: ErrTypeInvalidJson, 18 | ErrorMessage: "Body has invalid JSON", 19 | }) 20 | } 21 | 22 | func SendValidationError(c *gin.Context, errors []ErrorDetail) { 23 | c.JSON(http.StatusBadRequest, ErrorResponse{ 24 | ErrorType: ErrTypeValidationFailed, 25 | ErrorMessage: "Validation has failed", 26 | ErrorDetails: errors, 27 | }) 28 | } 29 | 30 | func SendInternalError(c *gin.Context) { 31 | errResp := ErrorResponse{ 32 | ErrorType: ErrTypeInternal, 33 | ErrorMessage: "Internal Server Error", 34 | } 35 | c.JSON(http.StatusInternalServerError, errResp) 36 | } 37 | -------------------------------------------------------------------------------- /file-storage-service/internal/restapi/response.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | // Specified by contract.md in /api folder 4 | 5 | const ( 6 | ErrTypeInternal = "internal" 7 | ErrTypeInvalidJson = "invalid_json" 8 | ErrTypeValidationFailed = "validation_failed" 9 | ErrTypeNotFound = "not_found" 10 | ErrTypeIdempotencyKeyMissing = "idempotency_key_missing" 11 | ErrTypeUnautorized = "unauthorized" 12 | 13 | ErrTypeInvalidHeader = "invalid_header" 14 | ErrTypeContentTooLarge = "content_too_large" 15 | ErrTypeInvalidForm = "invalid_form" 16 | 17 | ErrTypeFileNotFound = "file_not_found" 18 | ErrTypeUploadNotFound = "upload_not_found" 19 | ) 20 | 21 | type ErrorDetail struct { 22 | Field string `json:"field,omitempty"` 23 | Message string `json:"message,omitempty"` 24 | } 25 | 26 | type ErrorResponse struct { 27 | ErrorType string `json:"error_type,omitempty"` 28 | ErrorMessage string `json:"error_message,omitempty"` 29 | ErrorDetails []ErrorDetail `json:"error_details,omitempty"` 30 | } 31 | 32 | type SuccessResponse struct { 33 | Data any `json:"data,omitempty"` 34 | } 35 | -------------------------------------------------------------------------------- /file-storage-service/internal/services/get_file.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | var ErrFileNotFound = errors.New("file not found") 12 | 13 | type FileMetaGetter interface { 14 | GetFileMeta(context.Context, uuid.UUID) (*FileMeta, bool, error) 15 | } 16 | 17 | type GetFileService struct { 18 | getter FileMetaGetter 19 | } 20 | 21 | func NewGetFileService(getter FileMetaGetter) *GetFileService { 22 | return &GetFileService{ 23 | getter: getter, 24 | } 25 | } 26 | 27 | func (s *GetFileService) GetFile(ctx context.Context, fileId uuid.UUID) (*FileMeta, error) { 28 | meta, ok, err := s.getter.GetFileMeta(ctx, fileId) 29 | if err != nil { 30 | return nil, fmt.Errorf("getting file metadata failed: %s", err) 31 | } 32 | if !ok { 33 | return nil, ErrFileNotFound 34 | } 35 | return meta, nil 36 | } 37 | -------------------------------------------------------------------------------- /file-storage-service/internal/services/upload.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/base64" 7 | "fmt" 8 | "io" 9 | "time" 10 | 11 | "github.com/aws/aws-sdk-go-v2/aws" 12 | "github.com/aws/aws-sdk-go-v2/service/s3" 13 | "github.com/google/uuid" 14 | ) 15 | 16 | type UploadFileRequest struct { 17 | FileName string 18 | MimeType string 19 | FileSize int64 20 | 21 | File io.ReadSeeker 22 | } 23 | 24 | type FileMeta struct { 25 | FileName string 26 | MimeType string 27 | FileSize int64 28 | FileId uuid.UUID 29 | FileUrl string 30 | CreatedAt time.Time 31 | } 32 | 33 | type FileMetaStorer interface { 34 | Store(context.Context, *FileMeta) error 35 | } 36 | 37 | type UploadService struct { 38 | storer FileMetaStorer 39 | client *s3.Client 40 | conf *S3Config 41 | } 42 | 43 | type S3Config struct { 44 | Bucket string 45 | UrlPrefix string 46 | } 47 | 48 | func NewUploadService(storer FileMetaStorer, client *s3.Client, conf *S3Config) *UploadService { 49 | return &UploadService{ 50 | storer: storer, 51 | client: client, 52 | conf: conf, 53 | } 54 | } 55 | 56 | func (s *UploadService) Upload(ctx context.Context, req *UploadFileRequest) (*FileMeta, error) { 57 | fileId := uuid.New() 58 | 59 | hasher := sha256.New() 60 | if _, err := io.Copy(hasher, req.File); err != nil { 61 | return nil, fmt.Errorf("failed to compute SHA-256 hash: %s", err) 62 | } 63 | hash := base64.StdEncoding.EncodeToString(hasher.Sum(nil)) 64 | 65 | if _, err := req.File.Seek(0, io.SeekStart); err != nil { 66 | return nil, err 67 | } 68 | 69 | _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ 70 | Bucket: aws.String(s.conf.Bucket), 71 | Key: aws.String(fileId.String()), 72 | Body: req.File, 73 | ContentType: aws.String(req.MimeType), 74 | ChecksumSHA256: aws.String(hash), 75 | }) 76 | if err != nil { 77 | return nil, fmt.Errorf("file uploading failed: %s", err) 78 | } 79 | 80 | file := &FileMeta{ 81 | FileName: req.FileName, 82 | MimeType: req.MimeType, 83 | FileSize: req.FileSize, 84 | FileId: fileId, 85 | FileUrl: s.conf.UrlPrefix + fileId.String(), 86 | CreatedAt: time.Now(), 87 | } 88 | 89 | if err := s.storer.Store(ctx, file); err != nil { 90 | return nil, fmt.Errorf("file meta storing failed: %s", err) 91 | } 92 | return file, nil 93 | } 94 | -------------------------------------------------------------------------------- /file-storage-service/internal/services/upload_abort.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/aws/aws-sdk-go-v2/aws" 8 | "github.com/aws/aws-sdk-go-v2/service/s3" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | type UploadMetaGetRemover interface { 13 | UploadMetaGetter 14 | Remove(context.Context, uuid.UUID) error 15 | } 16 | 17 | type UploadAbortService struct { 18 | metaStorage UploadMetaGetRemover 19 | client *s3.Client 20 | conf *S3Config 21 | } 22 | 23 | func NewUploadAbortService(metaStorage UploadMetaGetRemover, client *s3.Client, conf *S3Config) *UploadAbortService { 24 | return &UploadAbortService{ 25 | metaStorage: metaStorage, 26 | client: client, 27 | conf: conf, 28 | } 29 | } 30 | 31 | func (s *UploadAbortService) Abort(ctx context.Context, uploadId uuid.UUID) error { 32 | meta, ok, err := s.metaStorage.Get(ctx, uploadId) 33 | if err != nil { 34 | return fmt.Errorf("getting upload meta failed: %s", err) 35 | } 36 | if !ok { 37 | return ErrUploadNotFound 38 | } 39 | 40 | _, err = s.client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{ 41 | Bucket: aws.String(s.conf.Bucket), 42 | Key: aws.String(meta.Key), 43 | UploadId: aws.String(meta.S3UploadId), 44 | }) 45 | if err != nil { 46 | return fmt.Errorf("multipart upload aborting failed: %s", err) 47 | } 48 | 49 | if err := s.metaStorage.Remove(ctx, meta.PublicUploadId); err != nil { 50 | return fmt.Errorf("upload meta removing failed: %s", err) 51 | } 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /file-storage-service/internal/services/upload_init.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/aws/aws-sdk-go-v2/aws" 8 | "github.com/aws/aws-sdk-go-v2/service/s3" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | type UploadInitRequest struct { 13 | FileName string 14 | MimeType string 15 | } 16 | 17 | type UploadMeta struct { 18 | PublicUploadId uuid.UUID 19 | Key string 20 | FileName string 21 | MimeType string 22 | S3UploadId string 23 | FileId uuid.UUID 24 | } 25 | 26 | type UploadMetaStorer interface { 27 | Store(context.Context, *UploadMeta) error 28 | } 29 | 30 | type UploadInitService struct { 31 | metaStorer UploadMetaStorer 32 | client *s3.Client 33 | conf *S3Config 34 | } 35 | 36 | func NewUploadInitService(metaStorer UploadMetaStorer, client *s3.Client, conf *S3Config) *UploadInitService { 37 | return &UploadInitService{ 38 | metaStorer: metaStorer, 39 | client: client, 40 | conf: conf, 41 | } 42 | } 43 | 44 | func (s *UploadInitService) Init(ctx context.Context, req *UploadInitRequest) (uploadId uuid.UUID, err error) { 45 | fileId := uuid.New() 46 | 47 | res, err := s.client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{ 48 | Bucket: aws.String(s.conf.Bucket), 49 | Key: aws.String(fileId.String()), 50 | ContentType: aws.String(req.MimeType), 51 | }) 52 | if err != nil { 53 | return uuid.Nil, fmt.Errorf("creating multipart upload failed: %s", err) 54 | } 55 | 56 | meta := &UploadMeta{ 57 | PublicUploadId: uuid.New(), 58 | FileId: fileId, 59 | Key: fileId.String(), 60 | FileName: req.FileName, 61 | MimeType: req.MimeType, 62 | S3UploadId: *res.UploadId, 63 | } 64 | 65 | if err := s.metaStorer.Store(ctx, meta); err != nil { 66 | return uuid.Nil, fmt.Errorf("upload meta storing failed: %s", err) 67 | } 68 | return meta.PublicUploadId, nil 69 | } 70 | -------------------------------------------------------------------------------- /file-storage-service/internal/services/upload_part.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | 9 | "github.com/aws/aws-sdk-go-v2/aws" 10 | "github.com/aws/aws-sdk-go-v2/service/s3" 11 | "github.com/google/uuid" 12 | ) 13 | 14 | var ( 15 | ErrUploadNotFound = errors.New("upload not found") 16 | ) 17 | 18 | type UploadPartRequest struct { 19 | PartNumber int 20 | UploadId uuid.UUID 21 | Part io.Reader 22 | } 23 | 24 | type UploadPartResponse struct { 25 | ETag string 26 | } 27 | 28 | type UploadMetaGetter interface { 29 | Get(context.Context, uuid.UUID) (*UploadMeta, bool, error) 30 | } 31 | 32 | type UploadPartService struct { 33 | metaGetter UploadMetaGetter 34 | client *s3.Client 35 | conf *S3Config 36 | } 37 | 38 | func NewUploadPartService(metaGetter UploadMetaGetter, client *s3.Client, conf *S3Config) *UploadPartService { 39 | return &UploadPartService{ 40 | metaGetter: metaGetter, 41 | client: client, 42 | conf: conf, 43 | } 44 | } 45 | 46 | func (s *UploadPartService) UploadPart(ctx context.Context, req *UploadPartRequest) (*UploadPartResponse, error) { 47 | meta, ok, err := s.metaGetter.Get(ctx, req.UploadId) 48 | if err != nil { 49 | return nil, fmt.Errorf("upload-meta getting failed: %s", err) 50 | } 51 | if !ok { 52 | return nil, ErrUploadNotFound 53 | } 54 | 55 | res, err := s.client.UploadPart(ctx, &s3.UploadPartInput{ 56 | Bucket: aws.String(s.conf.Bucket), 57 | Key: aws.String(meta.Key), 58 | PartNumber: aws.Int32(int32(req.PartNumber)), 59 | UploadId: aws.String(meta.S3UploadId), 60 | Body: req.Part, 61 | }) 62 | if err != nil { 63 | return nil, fmt.Errorf("upload part failed: %s", err) 64 | } 65 | 66 | return &UploadPartResponse{ 67 | ETag: *res.ETag, 68 | }, nil 69 | } 70 | -------------------------------------------------------------------------------- /file-storage-service/internal/storage/file_meta_storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/chakchat/chakchat-backend/file-storage-service/internal/services" 9 | "github.com/google/uuid" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | type FileMeta struct { 14 | FileId uuid.UUID `gorm:"primaryKey"` 15 | FileName string 16 | MimeType string 17 | FileSize int64 18 | FileUrl string 19 | CreatedAt time.Time 20 | } 21 | 22 | type FileMetaStorage struct { 23 | db *gorm.DB 24 | } 25 | 26 | func NewFileMetaStorage(db *gorm.DB) *FileMetaStorage { 27 | return &FileMetaStorage{ 28 | db: db, 29 | } 30 | } 31 | 32 | func (s *FileMetaStorage) GetFileMeta(ctx context.Context, id uuid.UUID) (*services.FileMeta, bool, error) { 33 | var meta FileMeta 34 | if err := s.db.WithContext(ctx).First(&meta, id).Error; err != nil { 35 | if errors.Is(err, gorm.ErrRecordNotFound) { 36 | return nil, false, nil 37 | } 38 | return nil, false, err 39 | } 40 | 41 | return &services.FileMeta{ 42 | FileName: meta.FileName, 43 | MimeType: meta.MimeType, 44 | FileSize: meta.FileSize, 45 | FileId: meta.FileId, 46 | FileUrl: meta.FileUrl, 47 | CreatedAt: meta.CreatedAt, 48 | }, true, nil 49 | } 50 | 51 | func (s *FileMetaStorage) Store(ctx context.Context, m *services.FileMeta) error { 52 | meta := FileMeta{ 53 | FileId: m.FileId, 54 | FileName: m.FileName, 55 | MimeType: m.MimeType, 56 | FileSize: m.FileSize, 57 | FileUrl: m.FileUrl, 58 | CreatedAt: m.CreatedAt, 59 | } 60 | 61 | if err := s.db.WithContext(ctx).Create(meta).Error; err != nil { 62 | return err 63 | } 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /file-storage-service/internal/storage/upload_meta_storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/chakchat/chakchat-backend/file-storage-service/internal/services" 8 | "github.com/google/uuid" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | type UploadMeta struct { 13 | Id uuid.UUID `gorm:"primaryKey"` 14 | Key string 15 | FileName string 16 | MimeType string 17 | S3UploadId string 18 | FileId uuid.UUID 19 | } 20 | 21 | type UploadMetaStorage struct { 22 | db *gorm.DB 23 | } 24 | 25 | func NewUploadMetaStorage(db *gorm.DB) *UploadMetaStorage { 26 | return &UploadMetaStorage{ 27 | db: db, 28 | } 29 | } 30 | 31 | func (s *UploadMetaStorage) Get(ctx context.Context, id uuid.UUID) (*services.UploadMeta, bool, error) { 32 | var meta UploadMeta 33 | if err := s.db.WithContext(ctx).First(&meta, id).Error; err != nil { 34 | if errors.Is(err, gorm.ErrRecordNotFound) { 35 | return nil, false, nil 36 | } 37 | return nil, false, err 38 | } 39 | 40 | return &services.UploadMeta{ 41 | PublicUploadId: meta.Id, 42 | Key: meta.Key, 43 | FileName: meta.FileName, 44 | MimeType: meta.MimeType, 45 | S3UploadId: meta.S3UploadId, 46 | FileId: meta.FileId, 47 | }, true, nil 48 | } 49 | 50 | func (s *UploadMetaStorage) Store(ctx context.Context, m *services.UploadMeta) error { 51 | meta := UploadMeta{ 52 | Id: m.PublicUploadId, 53 | Key: m.Key, 54 | FileName: m.FileName, 55 | MimeType: m.MimeType, 56 | S3UploadId: m.S3UploadId, 57 | FileId: m.FileId, 58 | } 59 | 60 | if err := s.db.WithContext(ctx).Create(meta).Error; err != nil { 61 | return err 62 | } 63 | return nil 64 | } 65 | 66 | func (s *UploadMetaStorage) Remove(ctx context.Context, id uuid.UUID) error { 67 | return s.db.WithContext(ctx).Delete(&UploadMeta{}, id).Error 68 | } 69 | -------------------------------------------------------------------------------- /identity-service/.dockerignore: -------------------------------------------------------------------------------- 1 | keys/ 2 | *.exe 3 | *.dll 4 | .env -------------------------------------------------------------------------------- /identity-service/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | FROM golang:1.23.1 AS builder 3 | 4 | # Set the working directory inside the container 5 | WORKDIR /app 6 | 7 | # Copy go.mod and go.sum files for dependency installation 8 | COPY go.mod go.sum ./ 9 | 10 | # Download and cache dependencies 11 | RUN go mod download 12 | 13 | # Copy the rest of the application code 14 | COPY . . 15 | 16 | # Build the application binary 17 | RUN go build -o main . 18 | 19 | # Stage 2: Run 20 | FROM ubuntu:latest 21 | 22 | RUN apt-get update && apt-get install -y ca-certificates && update-ca-certificates 23 | 24 | # Set the working directory inside the container 25 | WORKDIR /app 26 | 27 | # Copy the compiled binary from the builder stage 28 | COPY --from=builder /app/main . 29 | 30 | # Expose the port your application runs on (optional) 31 | EXPOSE 5000 32 | 33 | # Command to run the application 34 | CMD ["./main"] 35 | -------------------------------------------------------------------------------- /identity-service/Makefile: -------------------------------------------------------------------------------- 1 | gen-grpc: 2 | protoc --go_out="./internal/proto" --go-grpc_out="./internal/proto" --proto_path="../api/identity-service" identity.proto -------------------------------------------------------------------------------- /identity-service/config.yml: -------------------------------------------------------------------------------- 1 | access_token: 2 | signing_method: HS512 3 | lifetime: 5h # Just for demo 4 | issuer: identity_service 5 | audience: 6 | - client 7 | key_file_path: /app/keys/sym 8 | refresh_token: 9 | signing_method: HS512 10 | lifetime: 720h 11 | issuer: identity_service 12 | audience: 13 | - client 14 | key_file_path: /app/keys/sym 15 | internal_token: 16 | signing_method: RS256 17 | lifetime: 1m # Just for demo 18 | issuer: identity_service 19 | audience: 20 | - identity_service 21 | key_file_path: /app/keys/rsa 22 | invalidated_token_storage: 23 | exp: 720h # A little bit longer than refresh_token.lifetime 24 | userservice: 25 | grpc_addr: user-service:50051 26 | redis: 27 | addr: identity-redis:6379 28 | password: secret 29 | db: 0 30 | signin_meta: 31 | lifetime: 5m 32 | signup_meta: 33 | lifetime: 5m 34 | idempotency: 35 | data_exp: 10m 36 | phone_code: 37 | send_frequency: 1m 38 | sms: 39 | type: sms_aero 40 | # stub: 41 | # addr: http://sms-service-stub:5023 42 | otlp: 43 | grpc_addr: otel-collector:4317 44 | grpc_service: 45 | port: 9090 -------------------------------------------------------------------------------- /identity-service/internal/handlers/refresh_jwt.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/chakchat/chakchat-backend/identity-service/internal/restapi" 9 | "github.com/chakchat/chakchat-backend/identity-service/internal/services" 10 | "github.com/chakchat/chakchat-backend/shared/go/jwt" 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | type RefreshJWTService interface { 15 | Refresh(ctx context.Context, refresh jwt.Token) (jwt.Pair, error) 16 | } 17 | 18 | func RefreshJWT(service RefreshJWTService) gin.HandlerFunc { 19 | return func(c *gin.Context) { 20 | var req refreshJWTRequest 21 | if err := c.ShouldBindBodyWithJSON(&req); err != nil { 22 | restapi.SendUnprocessableJSON(c) 23 | return 24 | } 25 | 26 | tokens, err := service.Refresh(c.Request.Context(), jwt.Token(req.RefreshToken)) 27 | 28 | if err != nil { 29 | log.Printf("met error in refresh-jwt: %s", err) 30 | switch err { 31 | case services.ErrRefreshTokenExpired: 32 | c.JSON(http.StatusBadRequest, restapi.ErrorResponse{ 33 | ErrorType: restapi.ErrTypeRefreshTokenExpired, 34 | ErrorMessage: "Refresh token expired", 35 | }) 36 | case services.ErrRefreshTokenInvalidated: 37 | c.JSON(http.StatusBadRequest, restapi.ErrorResponse{ 38 | ErrorType: restapi.ErrTypeRefreshTokenInvalidated, 39 | ErrorMessage: "Refresh token invalidated", 40 | }) 41 | case services.ErrInvalidTokenType: 42 | c.JSON(http.StatusBadRequest, restapi.ErrorResponse{ 43 | ErrorType: restapi.ErrTypeInvalidTokenType, 44 | ErrorMessage: "Invalid token type", 45 | }) 46 | case services.ErrInvalidJWT: 47 | c.JSON(http.StatusBadRequest, restapi.ErrorResponse{ 48 | ErrorType: restapi.ErrTypeInvalidJWT, 49 | ErrorMessage: "Invalid signature of JWT", 50 | }) 51 | default: 52 | c.Error(err) 53 | restapi.SendInternalError(c) 54 | } 55 | return 56 | } 57 | 58 | restapi.SendSuccess(c, refreshJWTResponse{ 59 | AccessToken: string(tokens.Access), 60 | RefreshToken: string(tokens.Refresh), 61 | }) 62 | } 63 | } 64 | 65 | type refreshJWTRequest struct { 66 | RefreshToken string `json:"refresh_token"` 67 | } 68 | 69 | type refreshJWTResponse struct { 70 | AccessToken string `json:"access_token"` 71 | RefreshToken string `json:"refresh_token"` 72 | } 73 | -------------------------------------------------------------------------------- /identity-service/internal/handlers/signin_send_code.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "regexp" 7 | 8 | "github.com/chakchat/chakchat-backend/identity-service/internal/restapi" 9 | "github.com/chakchat/chakchat-backend/identity-service/internal/services" 10 | "github.com/gin-gonic/gin" 11 | "github.com/google/uuid" 12 | ) 13 | 14 | // Only russian phone numbers for now. Just hard-coded 15 | var phoneRegex = regexp.MustCompile(`^[78]9\d{9}$`) 16 | 17 | type SignInSendCodeService interface { 18 | SendCode(ctx context.Context, phone string) (signInKey uuid.UUID, err error) 19 | } 20 | 21 | func SignInSendCode(service SignInSendCodeService) gin.HandlerFunc { 22 | return func(c *gin.Context) { 23 | var req signInSendCodeRequest 24 | if err := c.ShouldBindBodyWithJSON(&req); err != nil { 25 | restapi.SendUnprocessableJSON(c) 26 | return 27 | } 28 | 29 | if errors := validateSignInSendCode(&req); len(errors) != 0 { 30 | restapi.SendValidationError(c, errors) 31 | return 32 | } 33 | 34 | signInKey, err := service.SendCode(c.Request.Context(), req.Phone) 35 | 36 | if err != nil { 37 | switch err { 38 | case services.ErrUserNotFound: 39 | c.JSON(http.StatusNotFound, restapi.ErrorResponse{ 40 | ErrorType: restapi.ErrTypeUserNotFound, 41 | ErrorMessage: "Such user doesn't exist", 42 | }) 43 | case services.ErrSendCodeFreqExceeded: 44 | c.JSON(http.StatusBadRequest, restapi.ErrorResponse{ 45 | ErrorType: restapi.ErrTypeSendCodeFreqExceeded, 46 | ErrorMessage: "Send code operation frequency exceeded", 47 | }) 48 | default: 49 | c.Error(err) 50 | restapi.SendInternalError(c) 51 | } 52 | return 53 | } 54 | 55 | restapi.SendSuccess(c, signInSendCodeResponse{ 56 | SignInKey: signInKey, 57 | }) 58 | } 59 | } 60 | 61 | type signInSendCodeRequest struct { 62 | Phone string `json:"phone" binding:"required"` 63 | } 64 | 65 | type signInSendCodeResponse struct { 66 | SignInKey uuid.UUID `json:"signin_key"` 67 | } 68 | 69 | func validateSignInSendCode(req *signInSendCodeRequest) []restapi.ErrorDetail { 70 | var errors []restapi.ErrorDetail 71 | if !phoneRegex.MatchString(req.Phone) { 72 | errors = append(errors, restapi.ErrorDetail{ 73 | Field: "phone", 74 | Message: "phone number must match a regex " + phoneRegex.String(), 75 | }) 76 | } 77 | return errors 78 | } 79 | -------------------------------------------------------------------------------- /identity-service/internal/handlers/signout.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/chakchat/chakchat-backend/identity-service/internal/restapi" 8 | "github.com/chakchat/chakchat-backend/identity-service/internal/services" 9 | "github.com/chakchat/chakchat-backend/shared/go/jwt" 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | type SignOutService interface { 14 | SignOut(ctx context.Context, refresh jwt.Token) error 15 | } 16 | 17 | func SignOut(service SignOutService) gin.HandlerFunc { 18 | return func(c *gin.Context) { 19 | var req signOutRequest 20 | if err := c.ShouldBindBodyWithJSON(&req); err != nil { 21 | restapi.SendUnprocessableJSON(c) 22 | return 23 | } 24 | 25 | err := service.SignOut(c.Request.Context(), jwt.Token(req.RefreshJWT)) 26 | 27 | // I think that signing out expired token counts as a successful operation 28 | if err != nil && err != services.ErrRefreshTokenExpired { 29 | switch err { 30 | case services.ErrInvalidJWT: 31 | c.JSON(http.StatusBadRequest, restapi.ErrorResponse{ 32 | ErrorType: restapi.ErrTypeInvalidJWT, // Is it appropriate error_type? 33 | ErrorMessage: "Refresh token is invalid", 34 | }) 35 | default: 36 | c.Error(err) 37 | restapi.SendInternalError(c) 38 | } 39 | return 40 | } 41 | 42 | restapi.SendSuccess(c, signOutResponse{}) 43 | } 44 | } 45 | 46 | type signOutRequest struct { 47 | RefreshJWT string `json:"refresh_token" binding:"required"` 48 | } 49 | 50 | type signOutResponse struct{} 51 | -------------------------------------------------------------------------------- /identity-service/internal/handlers/signup_send_code.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/chakchat/chakchat-backend/identity-service/internal/restapi" 8 | "github.com/chakchat/chakchat-backend/identity-service/internal/services" 9 | "github.com/gin-gonic/gin" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | type SignUpSendCodeService interface { 14 | SendCode(ctx context.Context, phone string) (signUpKey uuid.UUID, err error) 15 | } 16 | 17 | func SignUpSendCode(service SignUpSendCodeService) gin.HandlerFunc { 18 | return func(c *gin.Context) { 19 | var req signUpSendCodeRequest 20 | if err := c.ShouldBindBodyWithJSON(&req); err != nil { 21 | restapi.SendUnprocessableJSON(c) 22 | return 23 | } 24 | 25 | if errors := validateSignUpSendCode(&req); len(errors) != 0 { 26 | restapi.SendValidationError(c, errors) 27 | return 28 | } 29 | 30 | signUpKey, err := service.SendCode(c.Request.Context(), req.Phone) 31 | 32 | if err != nil { 33 | switch err { 34 | case services.ErrUserAlreadyExists: 35 | c.JSON(http.StatusBadRequest, restapi.ErrorResponse{ 36 | ErrorType: restapi.ErrTypeUserAlreadyExists, 37 | ErrorMessage: "Such user already exists", 38 | }) 39 | case services.ErrSendCodeFreqExceeded: 40 | c.JSON(http.StatusBadRequest, restapi.ErrorResponse{ 41 | ErrorType: restapi.ErrTypeSendCodeFreqExceeded, 42 | ErrorMessage: "Send code operation frequency exceeded", 43 | }) 44 | default: 45 | c.Error(err) 46 | restapi.SendInternalError(c) 47 | } 48 | return 49 | } 50 | 51 | restapi.SendSuccess(c, signUpSendCodeResponse{ 52 | SignUpKey: signUpKey, 53 | }) 54 | } 55 | } 56 | 57 | type signUpSendCodeRequest struct { 58 | Phone string `json:"phone" binding:"required"` 59 | } 60 | 61 | type signUpSendCodeResponse struct { 62 | SignUpKey uuid.UUID `json:"signup_key"` 63 | } 64 | 65 | func validateSignUpSendCode(req *signUpSendCodeRequest) []restapi.ErrorDetail { 66 | var errors []restapi.ErrorDetail 67 | if !phoneRegex.MatchString(req.Phone) { 68 | errors = append(errors, restapi.ErrorDetail{ 69 | Field: "phone", 70 | Message: "phone number must match a regex " + phoneRegex.String(), 71 | }) 72 | } 73 | return errors 74 | } 75 | -------------------------------------------------------------------------------- /identity-service/internal/handlers/signup_verify_code.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/chakchat/chakchat-backend/identity-service/internal/restapi" 8 | "github.com/chakchat/chakchat-backend/identity-service/internal/services" 9 | "github.com/gin-gonic/gin" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | type SignUpVerifyCodeService interface { 14 | VerifyCode(ctx context.Context, signUpKey uuid.UUID, code string) error 15 | } 16 | 17 | func SignUpVerifyCode(service SignUpVerifyCodeService) gin.HandlerFunc { 18 | return func(c *gin.Context) { 19 | var req signUpVerifyCodeRequest 20 | if err := c.ShouldBindBodyWithJSON(&req); err != nil { 21 | restapi.SendUnprocessableJSON(c) 22 | return 23 | } 24 | 25 | err := service.VerifyCode(c.Request.Context(), req.SignUpKey, req.Code) 26 | if err != nil { 27 | switch err { 28 | case services.ErrSignUpKeyNotFound: 29 | c.JSON(http.StatusBadRequest, restapi.ErrorResponse{ 30 | ErrorType: restapi.ErrTypeSignUpKeyNotFound, 31 | ErrorMessage: "Sign-up key doesn't exist", 32 | }) 33 | case services.ErrWrongCode: 34 | c.JSON(http.StatusBadRequest, restapi.ErrorResponse{ 35 | ErrorType: restapi.ErrTypeWrongCode, 36 | ErrorMessage: "Wrong phone verification code", 37 | }) 38 | default: 39 | c.Error(err) 40 | restapi.SendInternalError(c) 41 | } 42 | return 43 | } 44 | 45 | restapi.SendSuccess(c, signUpVerifyCodeResponse{}) 46 | } 47 | } 48 | 49 | type signUpVerifyCodeRequest struct { 50 | SignUpKey uuid.UUID `json:"signup_key" binding:"required"` 51 | Code string `json:"code" binding:"required"` 52 | } 53 | 54 | type signUpVerifyCodeResponse struct{} 55 | -------------------------------------------------------------------------------- /identity-service/internal/proto/grpc_service.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | 8 | "github.com/chakchat/chakchat-backend/identity-service/internal/proto/identity" 9 | "github.com/chakchat/chakchat-backend/identity-service/internal/storage" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | type GRPCService struct { 14 | deviceStorage *storage.DeviceStorage 15 | identity.UnimplementedIdentityServiceServer 16 | } 17 | 18 | func NewGRPCServer(deviceStorage *storage.DeviceStorage) *GRPCService { 19 | return &GRPCService{ 20 | deviceStorage: deviceStorage, 21 | } 22 | } 23 | 24 | func (s *GRPCService) GetDeviceToken(ctx context.Context, req *identity.DeviceTokenRequest) (*identity.DeviceTokenResponse, error) { 25 | 26 | userId, err := uuid.Parse(req.UserId.Value) 27 | if err != nil { 28 | log.Printf("Can't parse userId") 29 | return &identity.DeviceTokenResponse{ 30 | Status: identity.DeviceTokenResponseStatus_FAILED, 31 | }, nil 32 | } 33 | 34 | token, err := s.deviceStorage.GetDeviceTokenByID(ctx, userId) 35 | if err != nil { 36 | if errors.Is(err, storage.ErrNotFound) { 37 | log.Printf("No device token in data base, %s", err) 38 | return &identity.DeviceTokenResponse{ 39 | Status: identity.DeviceTokenResponseStatus_NOT_FOUND, 40 | }, nil 41 | } 42 | log.Printf("Unknown fail: %s", err) 43 | return &identity.DeviceTokenResponse{ 44 | Status: identity.DeviceTokenResponseStatus_FAILED, 45 | }, nil 46 | } 47 | log.Printf("Successfulle get device token") 48 | return &identity.DeviceTokenResponse{ 49 | Status: identity.DeviceTokenResponseStatus_SUCCESS, 50 | DeviceToken: token, 51 | }, nil 52 | } 53 | -------------------------------------------------------------------------------- /identity-service/internal/restapi/common.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func SendSuccess(c *gin.Context, data any) { 10 | c.JSON(http.StatusOK, SuccessResponse{ 11 | Data: data, 12 | }) 13 | } 14 | 15 | func SendUnprocessableJSON(c *gin.Context) { 16 | c.JSON(http.StatusUnprocessableEntity, ErrorResponse{ 17 | ErrorType: ErrTypeInvalidJson, 18 | ErrorMessage: "Body has invalid JSON", 19 | }) 20 | } 21 | 22 | func SendValidationError(c *gin.Context, errors []ErrorDetail) { 23 | c.JSON(http.StatusBadRequest, ErrorResponse{ 24 | ErrorType: ErrTypeValidationFailed, 25 | ErrorMessage: "Validation has failed", 26 | ErrorDetails: errors, 27 | }) 28 | } 29 | 30 | func SendInternalError(c *gin.Context) { 31 | errResp := ErrorResponse{ 32 | ErrorType: ErrTypeInternal, 33 | ErrorMessage: "Internal Server Error", 34 | } 35 | c.JSON(http.StatusInternalServerError, errResp) 36 | } 37 | -------------------------------------------------------------------------------- /identity-service/internal/restapi/response.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | // Specified by contract.md in /api folder 4 | 5 | const ( 6 | ErrTypeInternal = "internal" 7 | ErrTypeInvalidJson = "invalid_json" 8 | ErrTypeValidationFailed = "validation_failed" 9 | ErrTypeUserNotFound = "user_not_found" 10 | 11 | ErrTypeNotFound = "not_found" 12 | 13 | ErrTypeIdempotencyKeyMissing = "idempotency_key_missing" 14 | 15 | ErrTypeSendCodeFreqExceeded = "send_code_freq_exceeded" 16 | ErrTypeSignInKeyNotFound = "signin_key_not_found" 17 | ErrTypeWrongCode = "wrong_code" 18 | 19 | ErrTypeRefreshTokenExpired = "refresh_token_expired" 20 | ErrTypeRefreshTokenInvalidated = "refresh_token_invalidated" 21 | ErrTypeInvalidJWT = "invalid_token" 22 | ErrTypeInvalidTokenType = "invalid_token_type" 23 | 24 | ErrTypeUnautorized = "unauthorized" 25 | ErrTypeAccessTokenExpired = "access_token_expired" 26 | 27 | ErrTypeUserAlreadyExists = "user_already_exists" 28 | ErrTypeSignUpKeyNotFound = "signup_key_not_found" 29 | ErrTypeUsernameAlreadyExists = "username_already_exists" 30 | ErrTypePhoneNotVerified = "phone_not_verified" 31 | ) 32 | 33 | type ErrorDetail struct { 34 | Field string `json:"field,omitempty"` 35 | Message string `json:"message,omitempty"` 36 | } 37 | 38 | type ErrorResponse struct { 39 | ErrorType string `json:"error_type,omitempty"` 40 | ErrorMessage string `json:"error_message,omitempty"` 41 | ErrorDetails []ErrorDetail `json:"error_details,omitempty"` 42 | } 43 | 44 | type SuccessResponse struct { 45 | Data any `json:"data,omitempty"` 46 | } 47 | -------------------------------------------------------------------------------- /identity-service/internal/services/identity.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | 8 | "github.com/chakchat/chakchat-backend/shared/go/jwt" 9 | ) 10 | 11 | var ErrAccessTokenExpired = errors.New("access token expired") 12 | 13 | type IdentityService struct { 14 | userConf *jwt.Config 15 | internalConf *jwt.Config 16 | } 17 | 18 | func NewIdentityService(userConf, internalConf *jwt.Config) *IdentityService { 19 | return &IdentityService{ 20 | userConf: userConf, 21 | internalConf: internalConf, 22 | } 23 | } 24 | 25 | func (i *IdentityService) Idenitfy(ctx context.Context, token jwt.Token) (jwt.InternalToken, error) { 26 | claims, err := jwt.Parse(i.userConf, token) 27 | if err != nil { 28 | log.Printf("jwt validation failed: %s", err) 29 | if err == jwt.ErrTokenExpired { 30 | return "", ErrAccessTokenExpired 31 | } 32 | if err == jwt.ErrInvalidTokenType { 33 | return "", ErrInvalidTokenType 34 | } 35 | return "", ErrInvalidJWT 36 | } 37 | 38 | internalClaims := extractInternal(claims) 39 | 40 | internalToken, err := jwt.Generate(i.internalConf, internalClaims) 41 | if err != nil { 42 | return "", err 43 | } 44 | return jwt.InternalToken(internalToken), nil 45 | } 46 | 47 | func extractInternal(claims jwt.Claims) jwt.Claims { 48 | return jwt.Claims{ 49 | jwt.ClaimSub: claims[jwt.ClaimSub], 50 | jwt.ClaimName: claims[jwt.ClaimName], 51 | jwt.ClaimUsername: claims[jwt.ClaimUsername], 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /identity-service/internal/services/signout.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/chakchat/chakchat-backend/shared/go/jwt" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | type RefreshTokenInvalidator interface { 12 | Invalidate(context.Context, jwt.Token) error 13 | } 14 | 15 | type SignOutService struct { 16 | invalidator RefreshTokenInvalidator 17 | refreshConfig *jwt.Config 18 | deviceStorage DeviceStorage 19 | } 20 | 21 | func NewSignOutService(invalidator RefreshTokenInvalidator, refreshConf *jwt.Config, deviceStorage DeviceStorage) *SignOutService { 22 | return &SignOutService{ 23 | invalidator: invalidator, 24 | refreshConfig: refreshConf, 25 | deviceStorage: deviceStorage, 26 | } 27 | } 28 | 29 | func (s *SignOutService) SignOut(ctx context.Context, refresh jwt.Token) error { 30 | // idk should I check smth? 31 | if err := s.invalidator.Invalidate(ctx, refresh); err != nil { 32 | return fmt.Errorf("token invalidation failed: %s", err) 33 | } 34 | claims, err := jwt.Parse(s.refreshConfig, refresh) 35 | if err != nil { 36 | return fmt.Errorf("failed to parse refresh token: %s", err) 37 | } 38 | sub := claims[jwt.ClaimSub].(string) 39 | 40 | userID, err := uuid.Parse(sub) 41 | if err != nil { 42 | return fmt.Errorf("failed to parse sub claim") 43 | } 44 | 45 | if err := s.deviceStorage.Remove(ctx, userID); err != nil { 46 | return fmt.Errorf("failed to delete device token: %s", err) 47 | } 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /identity-service/internal/services/signup_verify_code.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | var ( 12 | ErrSignUpKeyNotFound = errors.New("sign up key not found") 13 | ) 14 | 15 | type SignUpMetaFindUpdater interface { 16 | FindMeta(ctx context.Context, signInKey uuid.UUID) (*SignUpMeta, bool, error) 17 | Store(context.Context, *SignUpMeta) error 18 | } 19 | 20 | type SignUpVerifyCodeService struct { 21 | storage SignUpMetaFindUpdater 22 | } 23 | 24 | func NewSignUpVerifyCodeService(storage SignUpMetaFindUpdater) *SignUpVerifyCodeService { 25 | return &SignUpVerifyCodeService{ 26 | storage: storage, 27 | } 28 | } 29 | 30 | func (s *SignUpVerifyCodeService) VerifyCode(ctx context.Context, signUpKey uuid.UUID, code string) error { 31 | meta, ok, err := s.storage.FindMeta(ctx, signUpKey) 32 | if err != nil { 33 | return fmt.Errorf("sign up metadata finding failed: %s", err) 34 | } 35 | if !ok { 36 | return ErrSignUpKeyNotFound 37 | } 38 | if meta.Code != code { 39 | return ErrWrongCode 40 | } 41 | 42 | meta.Verified = true 43 | if err := s.storage.Store(ctx, meta); err != nil { 44 | return fmt.Errorf("sign up meta update failed: %s", err) 45 | } 46 | 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /identity-service/internal/sms/sms.go: -------------------------------------------------------------------------------- 1 | package sms 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | 8 | smsaero_golang "github.com/smsaero/smsaero_golang/smsaero" 9 | ) 10 | 11 | type Client struct { 12 | email string 13 | apiKey string 14 | } 15 | type SmsSender interface { 16 | SendSms(context.Context, string, string) (*smsaero_golang.SendSms, error) 17 | } 18 | 19 | func NewSmsSender(email string, apiKey string) *Client { 20 | return &Client{ 21 | email: email, 22 | apiKey: apiKey, 23 | } 24 | } 25 | 26 | func (c *Client) SendSms(ctx context.Context, phone string, sms string) (*smsaero_golang.SendSms, error) { 27 | client := smsaero_golang.NewSmsAeroClient(c.email, c.apiKey, smsaero_golang.WithContext(ctx)) 28 | phoneInt, err := strconv.Atoi(phone) 29 | if err != nil { 30 | return nil, fmt.Errorf("error converting phone number to integer: %v", err) 31 | } 32 | message, err := client.SendSms(phoneInt, sms) 33 | if err != nil { 34 | return nil, err 35 | } 36 | return &message, nil 37 | } 38 | -------------------------------------------------------------------------------- /identity-service/internal/sms/sms_stub.go: -------------------------------------------------------------------------------- 1 | package sms 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | 10 | smsaero_golang "github.com/smsaero/smsaero_golang/smsaero" 11 | ) 12 | 13 | type SmsServerStubSender struct { 14 | addr string 15 | } 16 | 17 | func NewSmsServerStubSender(addr string) *SmsServerStubSender { 18 | return &SmsServerStubSender{ 19 | addr: addr, 20 | } 21 | } 22 | 23 | func (s *SmsServerStubSender) SendSms(ctx context.Context, phone string, message string) (*smsaero_golang.SendSms, error) { 24 | type Req struct { 25 | Phone string `json:"phone"` 26 | Message string `json:"message"` 27 | } 28 | req, _ := json.Marshal(Req{ 29 | Phone: phone, 30 | Message: message, 31 | }) 32 | resp, err := http.Post(s.addr, "application/json", bytes.NewReader(req)) 33 | if err != nil { 34 | return nil, fmt.Errorf("sending sms to stub server failed: %s", err) 35 | } 36 | if resp.StatusCode != 200 { 37 | return nil, fmt.Errorf("sending sms request failed with status code: %s", resp.Status) 38 | } 39 | return nil, nil 40 | } 41 | 42 | type SmsSenderFake struct{} 43 | 44 | func (s *SmsSenderFake) SendSms(ctx context.Context, phone string, message string) error { 45 | fmt.Printf("Sent sms to %s. Sms message: \"%s\"", phone, message) 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /identity-service/internal/storage/device_storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/chakchat/chakchat-backend/identity-service/internal/services" 11 | "github.com/google/uuid" 12 | "github.com/redis/go-redis/v9" 13 | ) 14 | 15 | const DeviceKeyPrefix = "Device:User:" 16 | 17 | var ErrNotFound = errors.New("not_found") 18 | 19 | type DeviceStorageConfig struct { 20 | DeviceInfoLifetime time.Duration 21 | } 22 | 23 | type DeviceStorage struct { 24 | client *redis.Client 25 | config *DeviceStorageConfig 26 | } 27 | 28 | func NewDeviceStorage(client *redis.Client, config *DeviceStorageConfig) *DeviceStorage { 29 | return &DeviceStorage{ 30 | client: client, 31 | config: config, 32 | } 33 | } 34 | 35 | func (s *DeviceStorage) Store(ctx context.Context, userID uuid.UUID, info *services.DeviceInfo) error { 36 | key := DeviceKeyPrefix + userID.String() 37 | 38 | enc, err := json.Marshal(info) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | status := s.client.Set(ctx, key, enc, s.config.DeviceInfoLifetime) 44 | if err := status.Err(); err != nil { 45 | return err 46 | } 47 | return nil 48 | } 49 | 50 | func (s *DeviceStorage) Refresh(ctx context.Context, userID uuid.UUID) error { 51 | key := DeviceKeyPrefix + userID.String() 52 | exists, err := s.client.Exists(ctx, key).Result() 53 | if err != nil { 54 | return err 55 | } 56 | 57 | if exists == 1 { 58 | err := s.client.Expire(ctx, key, s.config.DeviceInfoLifetime).Err() 59 | return err 60 | } 61 | return nil 62 | } 63 | 64 | func (s *DeviceStorage) Remove(ctx context.Context, userID uuid.UUID) error { 65 | key := DeviceKeyPrefix + userID.String() 66 | res := s.client.Del(ctx, key) 67 | if err := res.Err(); err != nil { 68 | return err 69 | } 70 | return nil 71 | } 72 | 73 | func (s *DeviceStorage) GetDeviceTokenByID(ctx context.Context, userID uuid.UUID) (*string, error) { 74 | key := DeviceKeyPrefix + userID.String() 75 | 76 | token := s.client.Get(ctx, key) 77 | if err := token.Err(); err != nil { 78 | if err == redis.Nil { 79 | return nil, ErrNotFound 80 | } 81 | return nil, fmt.Errorf("redis get key by id failed: %s", err) 82 | } 83 | 84 | deviceToken := token.String() 85 | return &deviceToken, nil 86 | } 87 | -------------------------------------------------------------------------------- /identity-service/internal/storage/invalidated_token_storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/chakchat/chakchat-backend/shared/go/jwt" 9 | "github.com/redis/go-redis/v9" 10 | ) 11 | 12 | const ( 13 | preffixInvalidatedToken = "InvalidatedToken:" 14 | invalidatedVal = "invalidated" 15 | ) 16 | 17 | type InvalidatedTokenConfig struct { 18 | InvalidatedExp time.Duration 19 | } 20 | 21 | type InvalidatedTokenStorage struct { 22 | client *redis.Client 23 | config *InvalidatedTokenConfig 24 | } 25 | 26 | func NewInvalidatedTokenStorage(config *InvalidatedTokenConfig, client *redis.Client) *InvalidatedTokenStorage { 27 | return &InvalidatedTokenStorage{ 28 | client: client, 29 | config: config, 30 | } 31 | } 32 | 33 | func (s *InvalidatedTokenStorage) Invalidate(ctx context.Context, token jwt.Token) error { 34 | key := preffixInvalidatedToken + string(token) 35 | 36 | res := s.client.Set(ctx, key, invalidatedVal, s.config.InvalidatedExp) 37 | if err := res.Err(); err != nil { 38 | return fmt.Errorf("redis set invalidated token failed: %s", err) 39 | } 40 | return nil 41 | } 42 | 43 | func (s *InvalidatedTokenStorage) Invalidated(ctx context.Context, token jwt.Token) (bool, error) { 44 | key := preffixInvalidatedToken + string(token) 45 | 46 | res := s.client.Get(ctx, key) 47 | if err := res.Err(); err != nil { 48 | if err == redis.Nil { 49 | return false, nil 50 | } 51 | return false, fmt.Errorf("redis get invalidated token failed: %s", err) 52 | } 53 | return true, nil 54 | } 55 | -------------------------------------------------------------------------------- /identity-service/internal/userservice/user_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 2 | // versions: 3 | // - protoc-gen-go-grpc v1.2.0 4 | // - protoc v5.29.0--rc2 5 | // source: user.proto 6 | 7 | package userservice 8 | 9 | import ( 10 | context "context" 11 | grpc "google.golang.org/grpc" 12 | ) 13 | 14 | // This is a compile-time assertion to ensure that this generated file 15 | // is compatible with the grpc package it is being compiled against. 16 | // Requires gRPC-Go v1.32.0 or later. 17 | const _ = grpc.SupportPackageIsVersion7 18 | 19 | // UserServiceClient is the client API for UserService service. 20 | // 21 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 22 | type UserServiceClient interface { 23 | GetUser(ctx context.Context, in *UserRequest, opts ...grpc.CallOption) (*UserResponse, error) 24 | CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*CreateUserResponse, error) 25 | } 26 | 27 | type userServiceClient struct { 28 | cc grpc.ClientConnInterface 29 | } 30 | 31 | func NewUserServiceClient(cc grpc.ClientConnInterface) UserServiceClient { 32 | return &userServiceClient{cc} 33 | } 34 | 35 | func (c *userServiceClient) GetUser(ctx context.Context, in *UserRequest, opts ...grpc.CallOption) (*UserResponse, error) { 36 | out := new(UserResponse) 37 | err := c.cc.Invoke(ctx, "/user.UserService/GetUser", in, out, opts...) 38 | if err != nil { 39 | return nil, err 40 | } 41 | return out, nil 42 | } 43 | 44 | func (c *userServiceClient) CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*CreateUserResponse, error) { 45 | out := new(CreateUserResponse) 46 | err := c.cc.Invoke(ctx, "/user.UserService/CreateUser", in, out, opts...) 47 | if err != nil { 48 | return nil, err 49 | } 50 | return out, nil 51 | } 52 | -------------------------------------------------------------------------------- /img/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chakchat/chakchat-backend/eb082a286ca829845da7b25c34b1cb62a7225dbb/img/architecture.png -------------------------------------------------------------------------------- /k8s/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: chakchat 3 | version: v1 4 | -------------------------------------------------------------------------------- /k8s/README.md: -------------------------------------------------------------------------------- 1 | You should call `minikube tunnel` to expose nginx to your local network. -------------------------------------------------------------------------------- /k8s/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | {{- range $key, $value := .Values.configMaps }} 2 | apiVersion: v1 3 | kind: ConfigMap 4 | metadata: 5 | name: {{ $key }} 6 | labels: 7 | app.kubernetes.io/name: {{ $key }} 8 | namespace: {{ $.Release.Namespace }} 9 | data: 10 | {{- with $value.data }} 11 | {{- range $datakey, $datavalue := . }} 12 | {{ $datakey }}: {{- if typeIs "string" $datavalue }} {{$datavalue | quote}} {{- else }} {{"|"}} 13 | {{- $datavalue | toYaml | nindent 4 }} 14 | {{- end }} 15 | {{- end }} 16 | {{- end }} 17 | --- 18 | {{- end }} 19 | -------------------------------------------------------------------------------- /k8s/templates/external-secret.yaml: -------------------------------------------------------------------------------- 1 | {{- $secretId := .Values.secretId -}} 2 | {{- range $key, $value := .Values.secrets }} 3 | apiVersion: external-secrets.io/v1beta1 4 | kind: ExternalSecret 5 | metadata: 6 | name: {{ $key }} 7 | namespace: {{ $.Release.Namespace }} 8 | labels: 9 | app.kubernetes.io/name: {{ $.Release.Name }} 10 | spec: 11 | refreshInterval: 60s 12 | secretStoreRef: 13 | name: secret-store 14 | kind: SecretStore 15 | target: 16 | name: {{ $key }} 17 | template: 18 | type: {{ $value.type | default "kubernetes.io/Opaque" }} 19 | engineVersion: v2 20 | {{- with $value.data }} 21 | data: 22 | {{- range $datakey, $datavalue := . }} 23 | {{ $datakey }}: {{- if typeIs "string" $datavalue }} {{$datavalue | quote}} {{- else }} {{"|"}} 24 | {{- $datavalue | toYaml | nindent 10 }} 25 | {{- end }} 26 | {{- end }} 27 | {{- end }} 28 | dataFrom: 29 | - extract: 30 | key: {{ $secretId }} 31 | --- 32 | {{- end }} 33 | -------------------------------------------------------------------------------- /k8s/templates/extra-manifest.yaml: -------------------------------------------------------------------------------- 1 | {{ range .Values.extraManifests }} 2 | --- 3 | {{ tpl (toYaml .) $ }} 4 | {{ end }} 5 | -------------------------------------------------------------------------------- /k8s/templates/job.yaml: -------------------------------------------------------------------------------- 1 | {{- range $key, $value := .Values.jobs }} 2 | apiVersion: batch/v1 3 | kind: Job 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: {{ $.Release.Name }} 7 | name: {{ $key }} 8 | namespace: {{ $.Release.Namespace }} 9 | {{- with $value.annotations }} 10 | annotations: 11 | {{- toYaml . | nindent 4 }} 12 | {{- end }} 13 | spec: 14 | {{ toYaml $value | nindent 2 }} 15 | --- 16 | {{- end }} 17 | -------------------------------------------------------------------------------- /k8s/templates/local-secret.yaml: -------------------------------------------------------------------------------- 1 | {{- range $key, $value := .Values.localSecrets }} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | namespace: {{ $.Release.Namespace }} 6 | name: {{ $key }} 7 | type: Opaque 8 | stringData: 9 | {{- toYaml $value.stringData | nindent 2}} 10 | --- 11 | {{- end }} -------------------------------------------------------------------------------- /k8s/templates/pg.yaml: -------------------------------------------------------------------------------- 1 | {{- range $key, $value := .Values.pg }} 2 | apiVersion: postgresql.cnpg.io/v1 3 | kind: Cluster 4 | metadata: 5 | namespace: {{ $.Release.Namespace }} 6 | name: {{ $key }} 7 | labels: 8 | app.kubernetes.io/name: {{ $key }} 9 | spec: 10 | instances: {{ $value.instances }} 11 | imageName: ghcr.io/cloudnative-pg/postgresql:17 12 | storage: 13 | size: 1Gi 14 | bootstrap: 15 | initdb: 16 | database: {{ $value.database }} 17 | owner: {{ $value.owner }} 18 | secret: 19 | name: {{ $value.secret }} 20 | --- 21 | {{- end }} 22 | -------------------------------------------------------------------------------- /k8s/templates/redis.yaml: -------------------------------------------------------------------------------- 1 | {{- range $key, $value := .Values.redis }} 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | namespace: {{ $.Release.Namespace }} 6 | name: {{ $key }} 7 | labels: 8 | app.kubernetes.io/name: {{ $key }} 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app: {{ $key }} 14 | template: 15 | metadata: 16 | labels: 17 | app: {{ $key }} 18 | spec: 19 | containers: 20 | - name: redis 21 | image: redis:alpine 22 | resources: 23 | requests: 24 | memory: "100Mi" 25 | cpu: "50m" 26 | limits: 27 | memory: "300Mi" 28 | cpu: "100m" 29 | ports: 30 | - containerPort: 6379 31 | envFrom: 32 | - secretRef: 33 | name: {{ $value.secret }} 34 | --- 35 | apiVersion: v1 36 | kind: Service 37 | metadata: 38 | namespace: {{ $.Release.Namespace }} 39 | name: {{ $key }} 40 | spec: 41 | selector: 42 | app: {{ $key }} 43 | ports: 44 | - port: 6379 45 | targetPort: 6379 46 | protocol: TCP 47 | --- 48 | {{- end }} -------------------------------------------------------------------------------- /k8s/templates/service.yaml: -------------------------------------------------------------------------------- 1 | {{- range $key, $value := $.Values.services }} 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | namespace: {{ $.Release.Namespace }} 6 | name: {{ $key }} 7 | spec: 8 | {{- toYaml $value | nindent 2 }} 9 | --- 10 | {{- end }} -------------------------------------------------------------------------------- /live-connection-service/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | FROM golang:1.23.1 AS builder 3 | 4 | # Set the working directory inside the container 5 | WORKDIR /app 6 | 7 | # Copy go.mod and go.sum files for dependency installation 8 | COPY go.mod go.sum ./ 9 | 10 | # Download and cache dependencies 11 | RUN go mod download 12 | 13 | # Copy the rest of the application code 14 | COPY . . 15 | 16 | # Build the application binary 17 | RUN go build -o main . 18 | 19 | # Stage 2: Run 20 | FROM ubuntu:latest 21 | 22 | # Set the working directory inside the container 23 | WORKDIR /app 24 | 25 | # Copy the compiled binary from the builder stage 26 | COPY --from=builder /app/main . 27 | 28 | # Expose the port your application runs on (optional) 29 | EXPOSE 5004 30 | 31 | # Command to run the application 32 | CMD ["./main"] 33 | -------------------------------------------------------------------------------- /live-connection-service/config.yml: -------------------------------------------------------------------------------- 1 | jwt: 2 | signing_method: RS256 3 | lifetime: 3m 4 | issuer: identity_service 5 | audience: 6 | - user_service 7 | key_file_path: /app/keys/rsa.pub 8 | 9 | consume_kafka: 10 | brokers: 11 | - ml-kafka:9092 12 | topic: updates 13 | produce_kafka: 14 | brokers: 15 | - ln-kafka:9092 16 | topic: updates 17 | -------------------------------------------------------------------------------- /live-connection-service/internal/handler/last_online.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/chakchat/chakchat-backend/live-connection-service/internal/restapi" 8 | "github.com/chakchat/chakchat-backend/live-connection-service/internal/services" 9 | "github.com/chakchat/chakchat-backend/live-connection-service/internal/storage" 10 | "github.com/gin-gonic/gin" 11 | "github.com/google/uuid" 12 | ) 13 | 14 | type OnlineStatusServer struct { 15 | service *services.StatusService 16 | } 17 | 18 | func NewOnlineStatusServer(service *services.StatusService) *OnlineStatusServer { 19 | return &OnlineStatusServer{ 20 | service: service, 21 | } 22 | } 23 | 24 | func (s *OnlineStatusServer) GetStatus() gin.HandlerFunc { 25 | return func(c *gin.Context) { 26 | ids := c.QueryArray("users") 27 | if len(ids) == 0 { 28 | c.JSON(http.StatusBadRequest, restapi.ErrTypeBadRequest) 29 | return 30 | } 31 | var userIds []uuid.UUID 32 | for _, id := range ids { 33 | userId, err := uuid.Parse(id) 34 | if err != nil { 35 | c.JSON(http.StatusBadRequest, restapi.ErrTypeBadRequest) 36 | return 37 | } 38 | userIds = append(userIds, userId) 39 | } 40 | 41 | status, err := s.service.GetStatus(c.Request.Context(), userIds) 42 | if err != nil { 43 | if errors.Is(err, storage.ErrNotFound) { 44 | c.JSON(http.StatusNotFound, restapi.ErrTypeNotFound) 45 | return 46 | } 47 | } 48 | 49 | restapi.SendSuccess(c, status) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /live-connection-service/internal/models/message.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "github.com/google/uuid" 4 | 5 | type KafkaMessage struct { 6 | Receivers []uuid.UUID `json:"receivers"` 7 | Type string `json:"type"` 8 | Data any `json:"data"` 9 | } 10 | 11 | type WSMessage struct { 12 | Type string `json:"type"` 13 | Data any `json:"data"` 14 | } 15 | -------------------------------------------------------------------------------- /live-connection-service/internal/mq/consumer.go: -------------------------------------------------------------------------------- 1 | package mq 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | 8 | "github.com/segmentio/kafka-go" 9 | ) 10 | 11 | type ConsumerConf struct { 12 | Brokers []string 13 | Topic string 14 | } 15 | 16 | type Consumer struct { 17 | reader *kafka.Reader 18 | handler func(ctx context.Context, msg kafka.Message) error 19 | shutdown chan struct{} 20 | } 21 | 22 | func NewConsumer(reader *kafka.Reader) *Consumer { 23 | return &Consumer{ 24 | reader: reader, 25 | shutdown: make(chan struct{}), 26 | } 27 | } 28 | 29 | func (c *Consumer) Start(ctx context.Context, handler func(ctx context.Context, msg kafka.Message) error) { 30 | c.handler = handler 31 | go func() { 32 | for { 33 | select { 34 | case <-c.shutdown: 35 | return 36 | case <-ctx.Done(): 37 | return 38 | default: 39 | msg, err := c.reader.ReadMessage(ctx) 40 | if err != nil { 41 | if errors.Is(err, context.Canceled) { 42 | log.Printf("kafka message reading: %v", err) 43 | return 44 | } 45 | log.Printf("Can't read message from kafka: %v", err) 46 | continue 47 | } 48 | processErr := c.handler(ctx, msg) 49 | if processErr != nil { 50 | log.Printf("Error to handle kafka message: %s", err) 51 | continue 52 | } 53 | log.Printf("Successfully handle kafka message. Ready to commit") 54 | } 55 | } 56 | }() 57 | } 58 | 59 | func (c *Consumer) Stop() { 60 | close(c.shutdown) 61 | _ = c.reader.Close() 62 | } 63 | -------------------------------------------------------------------------------- /live-connection-service/internal/mq/producer.go: -------------------------------------------------------------------------------- 1 | package mq 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/segmentio/kafka-go" 8 | ) 9 | 10 | type ProducerConfig struct { 11 | Brokers []string 12 | Topic string 13 | } 14 | type Producer struct { 15 | writer *kafka.Writer 16 | } 17 | 18 | func NewProducer(writer *kafka.Writer) *Producer { 19 | return &Producer{ 20 | writer: writer, 21 | } 22 | } 23 | 24 | func (p *Producer) Send(ctx context.Context, msg any) error { 25 | jsonMsg, err := json.Marshal(msg) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | err = p.writer.WriteMessages(ctx, kafka.Message{ 31 | Value: jsonMsg, 32 | }) 33 | 34 | if err != nil { 35 | return err 36 | } 37 | return nil 38 | } 39 | 40 | func (p *Producer) Close() error { 41 | return p.writer.Close() 42 | } 43 | -------------------------------------------------------------------------------- /live-connection-service/internal/restapi/common.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func SendSuccess(c *gin.Context, data any) { 10 | c.JSON(http.StatusOK, SuccessResponse{ 11 | Data: data, 12 | }) 13 | } 14 | 15 | func SendUnprocessableJSON(c *gin.Context) { 16 | c.JSON(http.StatusUnprocessableEntity, ErrorResponse{ 17 | ErrorType: ErrTypeInvalidJson, 18 | ErrorMessage: "Body has invalid JSON", 19 | }) 20 | } 21 | 22 | func SendValidationError(c *gin.Context, errors []ErrorDetail) { 23 | c.JSON(http.StatusBadRequest, ErrorResponse{ 24 | ErrorType: ErrTypeValidationFailed, 25 | ErrorMessage: "Validation has failed", 26 | ErrorDetails: errors, 27 | }) 28 | } 29 | 30 | func SendUnauthorizedError(c *gin.Context, errors []ErrorDetail) { 31 | c.JSON(http.StatusUnauthorized, ErrorResponse{ 32 | ErrorType: ErrTypeUnautorized, 33 | ErrorMessage: "Failed JWT token authentication", 34 | ErrorDetails: errors, 35 | }) 36 | } 37 | 38 | func SendInternalError(c *gin.Context) { 39 | c.JSON(http.StatusInternalServerError, ErrorResponse{ 40 | ErrorType: ErrTypeInternal, 41 | ErrorMessage: "Internal Server Error", 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /live-connection-service/internal/restapi/response.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | const ( 4 | ErrTypeNotFound = "not_found" 5 | ErrTypeUnautorized = "unauthorized" 6 | ErrTypeInternal = "internal" 7 | ErrTypeInvalidJson = "invalid_json" 8 | ErrTypeValidationFailed = "validation_failed" 9 | ErrTypeBadRequest = "invalid_input" 10 | ) 11 | 12 | type ErrorDetail struct { 13 | Field string `json:"field,omitempty"` 14 | Message string `json:"message,omitempty"` 15 | } 16 | 17 | type ErrorResponse struct { 18 | ErrorType string `json:"error_type,omitempty"` 19 | ErrorMessage string `json:"error_message,omitempty"` 20 | ErrorDetails []ErrorDetail `json:"error_details,omitempty"` 21 | } 22 | 23 | type SuccessResponse struct { 24 | Data any `json:"data,omitempty"` 25 | } 26 | -------------------------------------------------------------------------------- /live-connection-service/internal/services/kafka.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/google/uuid" 8 | "github.com/segmentio/kafka-go" 9 | 10 | "github.com/chakchat/chakchat-backend/live-connection-service/internal/models" 11 | "github.com/chakchat/chakchat-backend/live-connection-service/internal/mq" 12 | "github.com/chakchat/chakchat-backend/live-connection-service/internal/ws" 13 | ) 14 | 15 | type KafkaProcessor struct { 16 | hub *ws.Hub 17 | notifq *mq.Producer //queue to send message in notification service 18 | } 19 | 20 | func NewKafkaProcessor(hub *ws.Hub, dlq *mq.Producer) *KafkaProcessor { 21 | return &KafkaProcessor{ 22 | hub: hub, 23 | notifq: dlq, 24 | } 25 | } 26 | 27 | func (p *KafkaProcessor) MessageHandler(ctx context.Context, msg kafka.Message) error { 28 | var message models.KafkaMessage 29 | 30 | if err := json.Unmarshal(msg.Value, &message); err != nil { 31 | return err 32 | } 33 | 34 | response := models.WSMessage{ 35 | Type: message.Type, 36 | Data: message.Data, 37 | } 38 | 39 | var notificReceivers []uuid.UUID 40 | for _, userId := range message.Receivers { 41 | if !p.hub.Send(userId, response) { 42 | notificReceivers = append(notificReceivers, userId) 43 | } 44 | } 45 | if len(notificReceivers) != 0 { 46 | notificMessage := models.KafkaMessage{ 47 | Receivers: notificReceivers, 48 | Type: message.Type, 49 | Data: message.Data, 50 | } 51 | if err := p.notifq.Send(ctx, notificMessage); err != nil { 52 | return err 53 | } 54 | } 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /live-connection-service/internal/services/last_online.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/chakchat/chakchat-backend/live-connection-service/internal/storage" 8 | "github.com/chakchat/chakchat-backend/live-connection-service/internal/ws" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | type StatusService struct { 13 | storage *storage.OnlineStorage 14 | hub *ws.Hub 15 | } 16 | 17 | type StatusResponse struct { 18 | UserId uuid.UUID 19 | Status bool 20 | LastOnline string 21 | } 22 | 23 | func NewStatusService(storage *storage.OnlineStorage, hub *ws.Hub) *StatusService { 24 | return &StatusService{ 25 | storage: storage, 26 | hub: hub, 27 | } 28 | } 29 | 30 | func (s *StatusService) GetStatus(ctx context.Context, userIds []uuid.UUID) (map[uuid.UUID]StatusResponse, error) { 31 | dbStatus, err := s.storage.GetOnlineStatus(ctx, userIds) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | wsStatus := s.hub.GetOnlineStatus(userIds) 37 | 38 | result := make(map[uuid.UUID]StatusResponse) 39 | for _, id := range userIds { 40 | result[id] = StatusResponse{ 41 | UserId: id, 42 | Status: wsStatus[id] || time.Since(dbStatus[id].LastOnline) < 10*time.Second, 43 | LastOnline: dbStatus[id].LastOnline.Format(time.RFC3339), 44 | } 45 | } 46 | 47 | return result, nil 48 | } 49 | -------------------------------------------------------------------------------- /live-connection-service/internal/storage/last-online.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/chakchat/chakchat-backend/shared/go/postgres" 9 | "github.com/google/uuid" 10 | "github.com/lib/pq" 11 | ) 12 | 13 | var ErrNotFound = errors.New("not found") 14 | 15 | type OnlineResponse struct { 16 | Status string 17 | LastOnline time.Time 18 | } 19 | 20 | type OnlineStorage struct { 21 | db postgres.SQLer 22 | } 23 | 24 | func NewOnlineStorage(db postgres.SQLer) *OnlineStorage { 25 | return &OnlineStorage{db: db} 26 | } 27 | 28 | func (s *OnlineStorage) UpdateLastPing(ctx context.Context, userId string) error { 29 | query := ` 30 | INSERT INTO user_online_status (user_id, last_ping) 31 | VALUES ($1, $2) 32 | ON CONFLICT (user_id) DO UPDATE 33 | SET last_ping = $2 34 | ` 35 | _, err := s.db.Exec(ctx, query, userId, time.Now()) 36 | return err 37 | } 38 | 39 | func (s *OnlineStorage) GetOnlineStatus(ctx context.Context, userIds []uuid.UUID) (map[uuid.UUID]OnlineResponse, error) { 40 | query := ` 41 | SELECT user_id, last_ping 42 | FROM user_online_status 43 | WHERE user_id = ANY($1) 44 | ` 45 | 46 | rows, err := s.db.Query(ctx, query, pq.Array(userIds)) 47 | if err != nil { 48 | return nil, err 49 | } 50 | defer rows.Close() 51 | 52 | result := make(map[uuid.UUID]OnlineResponse) 53 | now := time.Now() 54 | 55 | for rows.Next() { 56 | var userID uuid.UUID 57 | var lastPing time.Time 58 | if err := rows.Scan(&userID, &lastPing); err != nil { 59 | continue 60 | } 61 | 62 | var status string 63 | if now.Sub(lastPing) < 10*time.Second { 64 | status = "online" 65 | } else { 66 | status = "offline" 67 | } 68 | result[userID] = OnlineResponse{ 69 | Status: status, 70 | LastOnline: lastPing, 71 | } 72 | } 73 | 74 | for _, id := range userIds { 75 | if _, exists := result[id]; !exists { 76 | result[id] = OnlineResponse{Status: "offline"} 77 | } 78 | } 79 | 80 | return result, nil 81 | } 82 | -------------------------------------------------------------------------------- /live-connection-service/migrations/V001__ping.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE user_online_status ( 2 | user_id VARCHAR(255) PRIMARY KEY, 3 | last_ping TIMESTAMP WITH TIME ZONE NOT NULL, 4 | updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() 5 | ); -------------------------------------------------------------------------------- /local.env: -------------------------------------------------------------------------------- 1 | FILE_STORAGE_AWS_ACCESS_KEY_ID= 2 | FILE_STORAGE_AWS_REGION= 3 | FILE_STORAGE_AWS_ENDPOINT_URL= 4 | FILE_STORAGE_AWS_SECRET_ACCESS_KEY= 5 | FILE_STORAGE_S3_BUCKET= 6 | FILE_STORAGE_S3_URL_PREFIX= -------------------------------------------------------------------------------- /messaging-service/.mockery.yaml: -------------------------------------------------------------------------------- 1 | with-expecter: True # Generate Expecter methods for better type safety 2 | inpackage: False # Generate mocks in a separate package 3 | testonly: False # Generate mocks for non-test code 4 | force-file-write: True # Overwrites existing files 5 | packages: 6 | github.com/chakchat/chakchat-backend/messaging-service/internal/application/storage/repository: 7 | config: 8 | # Don't use `mockery` directly, use `go generate ./...` instead (or `make gen`) 9 | dir: ./mocks 10 | filename: mock_personal_chat_repository.go 11 | outpkg: mocks 12 | pkgname: mocks 13 | mockname: "Mock{{.InterfaceName}}" 14 | template: testify 15 | interfaces: 16 | PersonalChatRepository: -------------------------------------------------------------------------------- /messaging-service/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | FROM golang:1.23.1 AS builder 3 | 4 | # Set the working directory inside the container 5 | WORKDIR /app 6 | 7 | # Copy go.mod and go.sum files for dependency installation 8 | COPY go.mod go.sum ./ 9 | 10 | # Download and cache dependencies 11 | RUN go mod download 12 | 13 | # Copy the rest of the application code 14 | COPY . . 15 | 16 | # Build the application binary 17 | RUN go build -o main ./cmd/main.go 18 | 19 | # Stage 2: Run 20 | FROM ubuntu:latest 21 | 22 | # Set the working directory inside the container 23 | WORKDIR /app 24 | 25 | # Copy the compiled binary from the builder stage 26 | COPY --from=builder /app/main . 27 | 28 | # Expose the port your application runs on (optional) 29 | EXPOSE 5000 30 | 31 | # Command to run the application 32 | CMD ["./main"] 33 | -------------------------------------------------------------------------------- /messaging-service/Makefile: -------------------------------------------------------------------------------- 1 | gen: 2 | go generate ./... 3 | 4 | test: 5 | go test -v -race ./... 6 | 7 | gen-file-grpc: 8 | protoc --go_out="./internal/infrastructure/proto" \ 9 | --go-grpc_out="./internal/inrastructure/proto" \ 10 | --proto_path="../api/file-storage-service" \ 11 | file-storage.proto -------------------------------------------------------------------------------- /messaging-service/README.md: -------------------------------------------------------------------------------- 1 | Notes for developers (only me): 2 | 1. Don't use ORM for this project. 3 | 2. 4 | After all delete the upper lines including this one. 5 | 6 | # Messaging Service 7 | This service is responsible for chats, messages and everything related to them. 8 | 9 | Clean architecture is used. 10 | 11 | TODO: describe architecture and project structure. 12 | 13 | # Random facts: 14 | 1. If you want to execute `DELETE` on update, you **are wrong** 15 | -------------------------------------------------------------------------------- /messaging-service/config.yml: -------------------------------------------------------------------------------- 1 | jwt: 2 | signing_method: RS256 3 | issuer: identity_service 4 | audience: 5 | - messaging_service 6 | key_file_path: /app/keys/rsa.pub 7 | 8 | redis: 9 | addr: messaging-redis:6379 10 | password: secret 11 | db: 0 12 | 13 | file_storage: 14 | grpc_addr: file-storage-service:9090 15 | 16 | otlp: 17 | grpc_addr: otel-collector:4317 18 | 19 | kafka: 20 | brokers: 21 | - ml-kafka:9092 22 | topic: updates -------------------------------------------------------------------------------- /messaging-service/internal/application/dto/convert.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | func UserIDs(users []uuid.UUID) []domain.UserID { 9 | res := make([]domain.UserID, len(users)) 10 | for i, u := range users { 11 | res[i] = domain.UserID(u) 12 | } 13 | return res 14 | } 15 | 16 | func UUIDs(users []domain.UserID) []uuid.UUID { 17 | res := make([]uuid.UUID, len(users)) 18 | for i, u := range users { 19 | res[i] = uuid.UUID(u) 20 | } 21 | return res 22 | } 23 | -------------------------------------------------------------------------------- /messaging-service/internal/application/dto/file_message_dto.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | type FileMetaDTO struct { 9 | FileId uuid.UUID 10 | FileName string 11 | MimeType string 12 | FileSize int64 13 | FileURL string 14 | CreatedAt int64 15 | } 16 | 17 | func NewFileMetaDTO(f *domain.FileMeta) FileMetaDTO { 18 | return FileMetaDTO{ 19 | FileId: f.FileId, 20 | FileName: f.FileName, 21 | MimeType: f.MimeType, 22 | FileSize: f.FileSize, 23 | FileURL: string(f.FileURL), 24 | CreatedAt: int64(f.CreatedAt), 25 | } 26 | } 27 | 28 | type FileMessageDTO struct { 29 | ChatID uuid.UUID 30 | UpdateID int64 31 | SenderID uuid.UUID 32 | 33 | File FileMetaDTO 34 | ReplyTo *int64 35 | 36 | CreatedAt int64 37 | } 38 | 39 | func NewFileMessageDTO(m *domain.FileMessage) FileMessageDTO { 40 | var replyTo *int64 41 | if m.ReplyTo != nil { 42 | cp := int64(*m.ReplyTo) 43 | replyTo = &cp 44 | } 45 | 46 | return FileMessageDTO{ 47 | ChatID: uuid.UUID(m.ChatID), 48 | UpdateID: int64(m.UpdateID), 49 | SenderID: uuid.UUID(m.SenderID), 50 | File: NewFileMetaDTO(&m.File), 51 | ReplyTo: replyTo, 52 | CreatedAt: int64(m.CreatedAt), 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /messaging-service/internal/application/dto/group_chat_dto.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain/group" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | type GroupChatDTO struct { 9 | ID uuid.UUID 10 | Admin uuid.UUID 11 | Members []uuid.UUID 12 | 13 | Name string 14 | Description string 15 | GroupPhoto string 16 | CreatedAt int64 17 | } 18 | 19 | func NewGroupChatDTO(g *group.GroupChat) GroupChatDTO { 20 | return GroupChatDTO{ 21 | ID: uuid.UUID(g.ID), 22 | Admin: uuid.UUID(g.Admin), 23 | Members: UUIDs(g.Members), 24 | Name: g.Name, 25 | Description: g.Description, 26 | GroupPhoto: string(g.GroupPhoto), 27 | CreatedAt: int64(g.CreatedAt), 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /messaging-service/internal/application/dto/personal_chat_dto.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain/personal" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | type PersonalChatDTO struct { 9 | ID uuid.UUID 10 | Members [2]uuid.UUID 11 | 12 | Blocked bool 13 | BlockedBy []uuid.UUID 14 | CreatedAt int64 15 | } 16 | 17 | func NewPersonalChatDTO(chat *personal.PersonalChat) PersonalChatDTO { 18 | return PersonalChatDTO{ 19 | ID: uuid.UUID(chat.ID), 20 | Members: [2]uuid.UUID{ 21 | uuid.UUID(chat.Members[0]), 22 | uuid.UUID(chat.Members[1]), 23 | }, 24 | BlockedBy: UUIDs(chat.BlockedBy), 25 | CreatedAt: int64(chat.CreatedAt), 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /messaging-service/internal/application/dto/reaction_dto.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | type ReactionDTO struct { 9 | UpdateID int64 10 | ChatID uuid.UUID 11 | SenderID uuid.UUID 12 | 13 | CreatedAt int64 14 | MessageID int64 15 | ReactionType string 16 | } 17 | 18 | func NewReactionDTO(r *domain.Reaction) ReactionDTO { 19 | return ReactionDTO{ 20 | UpdateID: int64(r.UpdateID), 21 | ChatID: uuid.UUID(r.ChatID), 22 | SenderID: uuid.UUID(r.SenderID), 23 | CreatedAt: int64(r.CreatedAt), 24 | MessageID: int64(r.MessageID), 25 | ReactionType: string(r.Type), 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /messaging-service/internal/application/dto/secret_group_chat_dto.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain/secgroup" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | type SecretGroupChatDTO struct { 11 | ID uuid.UUID 12 | CreatedAt int64 13 | 14 | Admin uuid.UUID 15 | Members []uuid.UUID 16 | 17 | Name string 18 | Description string 19 | GroupPhotoURL string 20 | 21 | Expiration *time.Duration 22 | } 23 | 24 | func NewSecretGroupChatDTO(g *secgroup.SecretGroupChat) SecretGroupChatDTO { 25 | return SecretGroupChatDTO{ 26 | ID: uuid.UUID(g.ID), 27 | CreatedAt: int64(g.CreatedAt), 28 | Admin: uuid.UUID(g.Admin), 29 | Members: UUIDs(g.Members), 30 | Name: g.Name, 31 | Description: g.Description, 32 | GroupPhotoURL: string(g.GroupPhoto), 33 | Expiration: g.Exp, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /messaging-service/internal/application/dto/secret_personal_chat_dto.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain/secpersonal" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | type SecretPersonalChatDTO struct { 11 | ID uuid.UUID 12 | CreatedAt int64 13 | Expiration *time.Duration 14 | Members [2]uuid.UUID 15 | } 16 | 17 | func NewSecretPersonalChatDTO(c *secpersonal.SecretPersonalChat) SecretPersonalChatDTO { 18 | return SecretPersonalChatDTO{ 19 | ID: uuid.UUID(c.ID), 20 | CreatedAt: int64(c.CreatedAt), 21 | Expiration: c.Exp, 22 | Members: [2]uuid.UUID{ 23 | uuid.UUID(c.Members[0]), 24 | uuid.UUID(c.Members[1]), 25 | }, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /messaging-service/internal/application/dto/secret_update_dto.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | type SecretUpdateDTO struct { 9 | ChatID uuid.UUID 10 | UpdateID int64 11 | SenderID uuid.UUID 12 | 13 | Payload []byte 14 | InitializationVector []byte 15 | KeyHash []byte 16 | 17 | CreatedAt int64 18 | } 19 | 20 | func NewSecretUpdateDTO(dom *domain.SecretUpdate) SecretUpdateDTO { 21 | return SecretUpdateDTO{ 22 | ChatID: uuid.UUID(dom.ChatID), 23 | UpdateID: int64(dom.UpdateID), 24 | SenderID: uuid.UUID(dom.SenderID), 25 | Payload: dom.Data.Payload, 26 | InitializationVector: dom.Data.IV, 27 | KeyHash: dom.Data.KeyHash, 28 | CreatedAt: int64(dom.CreatedAt), 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /messaging-service/internal/application/dto/text_message_dto.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | type TextMessageDTO struct { 9 | ChatID uuid.UUID 10 | UpdateID int64 11 | SenderID uuid.UUID 12 | 13 | Text string 14 | Edited *TextMessageEditedDTO 15 | ReplyTo *int64 16 | 17 | CreatedAt int64 18 | } 19 | 20 | func NewTextMessageDTO(m *domain.TextMessage) TextMessageDTO { 21 | var edited *TextMessageEditedDTO 22 | if m.Edited != nil { 23 | editedDto := NewTextMessageEditedDTO(m.Edited) 24 | edited = &editedDto 25 | } 26 | 27 | var replyTo *int64 28 | if m.ReplyTo != nil { 29 | cp := int64(*m.ReplyTo) 30 | replyTo = &cp 31 | } 32 | 33 | return TextMessageDTO{ 34 | ChatID: uuid.UUID(m.ChatID), 35 | UpdateID: int64(m.UpdateID), 36 | SenderID: uuid.UUID(m.SenderID), 37 | Text: m.Text, 38 | Edited: edited, 39 | ReplyTo: replyTo, 40 | CreatedAt: int64(m.CreatedAt), 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /messaging-service/internal/application/dto/text_message_edited_dto.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | type TextMessageEditedDTO struct { 9 | ChatID uuid.UUID 10 | UpdateID int64 11 | SenderID uuid.UUID 12 | 13 | MessageID int64 14 | NewText string 15 | 16 | CreatedAt int64 17 | } 18 | 19 | func NewTextMessageEditedDTO(dom *domain.TextMessageEdited) TextMessageEditedDTO { 20 | return TextMessageEditedDTO{ 21 | ChatID: uuid.UUID(dom.ChatID), 22 | UpdateID: int64(dom.UpdateID), 23 | SenderID: uuid.UUID(dom.SenderID), 24 | MessageID: int64(dom.MessageID), 25 | NewText: dom.NewText, 26 | CreatedAt: int64(dom.CreatedAt), 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /messaging-service/internal/application/dto/update_deleted_dto.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | type UpdateDeletedDTO struct { 9 | ChatID uuid.UUID 10 | UpdateID int64 11 | SenderID uuid.UUID 12 | 13 | DeletedID int64 14 | DeleteMode string 15 | 16 | CreatedAt int64 17 | } 18 | 19 | func NewUpdateDeletedDTO(dom *domain.UpdateDeleted) UpdateDeletedDTO { 20 | return UpdateDeletedDTO{ 21 | ChatID: uuid.UUID(dom.ChatID), 22 | UpdateID: int64(dom.UpdateID), 23 | SenderID: uuid.UUID(dom.SenderID), 24 | DeletedID: int64(dom.DeletedID), 25 | DeleteMode: string(dom.Mode), 26 | CreatedAt: int64(dom.CreatedAt), 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /messaging-service/internal/application/external/file_storage.go: -------------------------------------------------------------------------------- 1 | package external 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | var ( 11 | ErrFileNotFound = errors.New("file not found") 12 | ) 13 | 14 | type FileMeta struct { 15 | FileId uuid.UUID 16 | FileName string 17 | MimeType string 18 | FileSize int64 19 | FileUrl string 20 | CreatedAt int64 21 | } 22 | 23 | type FileStorage interface { 24 | // Should return ErrFileNotFound if there are no such file 25 | GetById(context.Context, uuid.UUID) (*FileMeta, error) 26 | } 27 | -------------------------------------------------------------------------------- /messaging-service/internal/application/external/mq_publisher.go: -------------------------------------------------------------------------------- 1 | package external 2 | 3 | import "context" 4 | 5 | type Event any 6 | 7 | type MqPublisher interface { 8 | // It doesn't return a value to guarantee that event will be published. 9 | // Eventual consistency is possible. 10 | Publish(context.Context, []byte) error 11 | } 12 | -------------------------------------------------------------------------------- /messaging-service/internal/application/generic/chat_test.go: -------------------------------------------------------------------------------- 1 | package generic 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | func TestChatMarshalJSON(t *testing.T) { 9 | chatInfo := ChatInfo{ 10 | Personal: &PersonalInfo{ 11 | BlockedBy: nil, 12 | }, 13 | } 14 | 15 | m, err := json.Marshal(chatInfo) 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | 20 | if string(m) != `{"blocked_by":null}` { 21 | t.Fatalf("Got %s", string(m)) 22 | } 23 | } -------------------------------------------------------------------------------- /messaging-service/internal/application/generic/update_test.go: -------------------------------------------------------------------------------- 1 | package generic 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | func TestUpdateContentMarshalJSON(t *testing.T) { 9 | updateContent := UpdateContent{ 10 | TextMessage: &TextMessageContent{ 11 | Text: "test", 12 | }, 13 | } 14 | 15 | enc, err := json.Marshal(updateContent) 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | 20 | if string(enc) != `{"text":"test"}` { 21 | t.Fatalf("got: %s", enc) 22 | } 23 | } -------------------------------------------------------------------------------- /messaging-service/internal/application/publish/events/chat.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/generic" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | type ChatCreated struct { 11 | SenderID uuid.UUID `json:"sender_id"` 12 | Chat generic.Chat `json:"chat"` 13 | } 14 | 15 | type ChatDeleted struct { 16 | SenderID uuid.UUID `json:"sender_id"` 17 | ChatID uuid.UUID `json:"chat_id"` 18 | } 19 | 20 | type ChatBlocked struct { 21 | SenderID uuid.UUID `json:"sender_id"` 22 | ChatID uuid.UUID `json:"chat_id"` 23 | } 24 | 25 | type ChatUnblocked struct { 26 | SenderID uuid.UUID `json:"sender_id"` 27 | ChatID uuid.UUID `json:"chat_id"` 28 | } 29 | 30 | type ExpirationSet struct { 31 | ChatID uuid.UUID `json:"chat_id"` 32 | SenderID uuid.UUID `json:"sender_id"` 33 | Expiration *time.Duration `json:"expiration"` 34 | } 35 | 36 | type GroupInfoUpdated struct { 37 | SenderID uuid.UUID `json:"sender_id"` 38 | ChatID uuid.UUID `json:"chat_id"` 39 | Name string `json:"name"` 40 | Description string `json:"description"` 41 | GroupPhoto string `json:"group_photo"` 42 | } 43 | 44 | type GroupMemberAdded struct { 45 | SenderID uuid.UUID `json:"sender_id"` 46 | ChatID uuid.UUID `json:"chat_id"` 47 | Members []uuid.UUID `json:"members"` 48 | } 49 | 50 | type GroupMembersRemoved struct { 51 | SenderID uuid.UUID `json:"sender_id"` 52 | ChatID uuid.UUID `json:"chat_id"` 53 | Members []uuid.UUID `json:"members"` 54 | } 55 | -------------------------------------------------------------------------------- /messaging-service/internal/application/publish/events/events.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | const ( 4 | TypeUpdate = "update" 5 | TypeChatCreated = "chat_created" 6 | TypeChatDeleted = "chat_deleted" 7 | TypeChatBlocked = "chat_blocked" 8 | TypeChatUnblocked = "chat_unblocked" 9 | TypeChatExpirationSet = "chat_expiration_set" 10 | TypeGroupInfoUpdated = "group_info_updated" 11 | TypeGroupMembersAdded = "group_members_added" 12 | TypeGroupMembersRemoved = "group_members_removed" 13 | ) 14 | -------------------------------------------------------------------------------- /messaging-service/internal/application/publish/publisher.go: -------------------------------------------------------------------------------- 1 | package publish 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/external" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | type UserEvent struct { 12 | Receivers []uuid.UUID `json:"receivers"` 13 | Type string `json:"type"` 14 | Data any `json:"data"` 15 | } 16 | 17 | type Publisher interface { 18 | PublishForReceivers(ctx context.Context, users []uuid.UUID, typ string, data any) error 19 | } 20 | 21 | type UserEventPublisher struct { 22 | mq external.MqPublisher 23 | } 24 | 25 | func NewUserEventPublisher(mq external.MqPublisher) UserEventPublisher { 26 | return UserEventPublisher{ 27 | mq: mq, 28 | } 29 | } 30 | 31 | func (p UserEventPublisher) PublishForReceivers(ctx context.Context, users []uuid.UUID, typ string, data any) error { 32 | if len(users) == 0 { 33 | return nil 34 | } 35 | 36 | e := UserEvent{ 37 | Receivers: users, 38 | Type: typ, 39 | Data: data, 40 | } 41 | 42 | binE, err := json.Marshal(e) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | return p.mq.Publish(ctx, binE) 48 | } 49 | 50 | type PublisherStub struct{} 51 | 52 | func (PublisherStub) PublishForReceivers(ctx context.Context, users []uuid.UUID, typ string, data any) error { 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /messaging-service/internal/application/request/update.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import "github.com/google/uuid" 4 | 5 | type SendTextMessage struct { 6 | ChatID uuid.UUID 7 | SenderID uuid.UUID 8 | Text string 9 | ReplyToMessage *int64 10 | } 11 | 12 | type EditTextMessage struct { 13 | ChatID uuid.UUID 14 | SenderID uuid.UUID 15 | 16 | MessageID int64 17 | NewText string 18 | } 19 | 20 | type DeleteMessage struct { 21 | ChatID uuid.UUID 22 | SenderID uuid.UUID 23 | 24 | MessageID int64 25 | DeleteMode string 26 | } 27 | 28 | type SendReaction struct { 29 | ChatID uuid.UUID 30 | SenderID uuid.UUID 31 | 32 | MessageID int64 33 | ReactionType string 34 | } 35 | 36 | type DeleteReaction struct { 37 | ChatID uuid.UUID 38 | SenderID uuid.UUID 39 | 40 | ReactionID int64 41 | } 42 | 43 | type ForwardMessage struct { 44 | ToChatID uuid.UUID 45 | SenderID uuid.UUID 46 | 47 | MessageID int64 48 | FromChatID uuid.UUID 49 | } 50 | 51 | type SendFileMessage struct { 52 | ChatID uuid.UUID 53 | SenderID uuid.UUID 54 | FileID uuid.UUID 55 | ReplyToMessage *int64 56 | } 57 | 58 | type SendSecretUpdate struct { 59 | ChatID uuid.UUID 60 | SenderID uuid.UUID 61 | 62 | Payload []byte 63 | InitializationVector []byte 64 | KeyHash []byte 65 | } 66 | 67 | type DeleteSecretUpdate struct { 68 | ChatID uuid.UUID 69 | SenderID uuid.UUID 70 | 71 | SecretUpdateID int64 72 | } 73 | 74 | type GetUpdatesRange struct { 75 | ChatID uuid.UUID 76 | SenderID uuid.UUID 77 | 78 | From, To int64 79 | } 80 | 81 | type GetUpdate struct { 82 | ChatID uuid.UUID 83 | SenderID uuid.UUID 84 | 85 | UpdateID int64 86 | } 87 | -------------------------------------------------------------------------------- /messaging-service/internal/application/services/common.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/external" 5 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 6 | "github.com/google/uuid" 7 | ) 8 | 9 | func GetReceivingMembers(members []domain.UserID, sender domain.UserID) []uuid.UUID { 10 | res := make([]uuid.UUID, 0, len(members)-1) 11 | for _, user := range members { 12 | if user != sender { 13 | res = append(res, uuid.UUID(user)) 14 | } 15 | } 16 | return res 17 | } 18 | 19 | func GetReceivingUpdateMembers(members []domain.UserID, sender domain.UserID, update *domain.Update) []uuid.UUID { 20 | res := make([]uuid.UUID, 0, len(members)-1) 21 | for _, user := range members { 22 | if user != sender && !update.DeletedFor(user) { 23 | res = append(res, uuid.UUID(user)) 24 | } 25 | } 26 | return res 27 | } 28 | 29 | func NewDomainFileMeta(f *external.FileMeta) domain.FileMeta { 30 | return domain.FileMeta{ 31 | FileId: f.FileId, 32 | FileName: f.FileName, 33 | MimeType: f.MimeType, 34 | FileSize: f.FileSize, 35 | FileURL: domain.URL(f.FileUrl), 36 | CreatedAt: domain.Timestamp(f.CreatedAt), 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /messaging-service/internal/application/services/errors.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | type Error struct { 4 | text string 5 | } 6 | 7 | func (e Error) Error() string { 8 | return e.text 9 | } 10 | 11 | // NOTE: 12 | // If you add an error here 13 | // You alse should add it to `errmap` package 14 | var ( 15 | ErrFileNotFound = Error{"service: file not found"} 16 | ErrChatNotFound = Error{"service: chat not found"} 17 | ErrInvalidChatType = Error{"service: invalid chat type"} 18 | ErrInvalidPhoto = Error{"service: invalid photo"} 19 | ErrChatAlreadyExists = Error{"service: chat already exists"} 20 | ErrMessageNotFound = Error{"service: message not found"} 21 | ErrReactionNotFound = Error{"service: reaction not found"} 22 | ErrSecretUpdateNotFound = Error{"service: secret update is not found"} 23 | ) 24 | -------------------------------------------------------------------------------- /messaging-service/internal/application/storage/repository/chatter_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/storage" 7 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 8 | ) 9 | 10 | // TODO: move it to GenericChatRepository 11 | type ChatterRepository interface { 12 | FindChatter(context.Context, storage.ExecQuerier, domain.ChatID) (domain.Chatter, error) 13 | } 14 | -------------------------------------------------------------------------------- /messaging-service/internal/application/storage/repository/generic_chat_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/generic" 7 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/storage" 8 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 9 | ) 10 | 11 | type GenericChatRepository interface { 12 | // Should return empty slice if not found 13 | GetByMemberID(context.Context, storage.ExecQuerier, domain.UserID) ([]generic.Chat, error) 14 | // Should return ErrNotFound if not found 15 | GetByChatID(context.Context, storage.ExecQuerier, domain.ChatID) (*generic.Chat, error) 16 | // Should return ErrNotFound if not found 17 | GetChatType(context.Context, storage.ExecQuerier, domain.ChatID) (string, error) 18 | } 19 | -------------------------------------------------------------------------------- /messaging-service/internal/application/storage/repository/group_chat_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/storage" 7 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 8 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain/group" 9 | ) 10 | 11 | type GroupChatRepository interface { 12 | // Should return ErrNotFound if entity is not found 13 | FindById(context.Context, storage.ExecQuerier, domain.ChatID) (*group.GroupChat, error) 14 | Update(context.Context, storage.ExecQuerier, *group.GroupChat) (*group.GroupChat, error) 15 | Create(context.Context, storage.ExecQuerier, *group.GroupChat) (*group.GroupChat, error) 16 | Delete(context.Context, storage.ExecQuerier, domain.ChatID) error 17 | } 18 | -------------------------------------------------------------------------------- /messaging-service/internal/application/storage/repository/personal_chat_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/storage" 8 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 9 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain/personal" 10 | ) 11 | 12 | var ( 13 | ErrNotFound = errors.New("not found") 14 | ) 15 | 16 | //go:generate mockery 17 | type PersonalChatRepository interface { 18 | // Should return ErrNotFound if entity is not found 19 | FindById(context.Context, storage.ExecQuerier, domain.ChatID) (*personal.PersonalChat, error) 20 | // Should return ErrNotFound if entity is not found. 21 | // Members order should NOT affect the result 22 | FindByMembers(context.Context, storage.ExecQuerier, [2]domain.UserID) (*personal.PersonalChat, error) 23 | Update(context.Context, storage.ExecQuerier, *personal.PersonalChat) (*personal.PersonalChat, error) 24 | Create(context.Context, storage.ExecQuerier, *personal.PersonalChat) (*personal.PersonalChat, error) 25 | Delete(context.Context, storage.ExecQuerier, domain.ChatID) error 26 | } 27 | -------------------------------------------------------------------------------- /messaging-service/internal/application/storage/repository/secret_group_chat_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/storage" 7 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 8 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain/secgroup" 9 | ) 10 | 11 | type SecretGroupChatRepository interface { 12 | // Should return ErrNotFound if entity is not found 13 | FindById(context.Context, storage.ExecQuerier, domain.ChatID) (*secgroup.SecretGroupChat, error) 14 | Update(context.Context, storage.ExecQuerier, *secgroup.SecretGroupChat) (*secgroup.SecretGroupChat, error) 15 | Create(context.Context, storage.ExecQuerier, *secgroup.SecretGroupChat) (*secgroup.SecretGroupChat, error) 16 | Delete(context.Context, storage.ExecQuerier, domain.ChatID) error 17 | } 18 | -------------------------------------------------------------------------------- /messaging-service/internal/application/storage/repository/secret_personal_chat_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/storage" 7 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 8 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain/secpersonal" 9 | ) 10 | 11 | type SecretPersonalChatRepository interface { 12 | // Should return ErrNotFound if entity is not found 13 | FindById(context.Context, storage.ExecQuerier, domain.ChatID) (*secpersonal.SecretPersonalChat, error) 14 | // Should return ErrNotFound if entity is not found. 15 | // Members order should NOT affect the result 16 | FindByMembers(context.Context, storage.ExecQuerier, [2]domain.UserID) (*secpersonal.SecretPersonalChat, error) 17 | Update(context.Context, storage.ExecQuerier, *secpersonal.SecretPersonalChat) (*secpersonal.SecretPersonalChat, error) 18 | Create(context.Context, storage.ExecQuerier, *secpersonal.SecretPersonalChat) (*secpersonal.SecretPersonalChat, error) 19 | Delete(context.Context, storage.ExecQuerier, domain.ChatID) error 20 | } 21 | -------------------------------------------------------------------------------- /messaging-service/internal/application/storage/repository/update_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/storage" 7 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 8 | ) 9 | 10 | type UpdateRepository interface { 11 | FindGenericMessage(context.Context, storage.ExecQuerier, domain.ChatID, domain.UpdateID) (*domain.Message, error) 12 | DeleteUpdate(context.Context, storage.ExecQuerier, domain.ChatID, domain.UpdateID) error 13 | CreateUpdateDeleted(context.Context, storage.ExecQuerier, *domain.UpdateDeleted) (*domain.UpdateDeleted, error) 14 | 15 | CreateTextMessage(context.Context, storage.ExecQuerier, *domain.TextMessage) (*domain.TextMessage, error) 16 | CreateTextMessageEdited(context.Context, storage.ExecQuerier, *domain.TextMessageEdited) (*domain.TextMessageEdited, error) 17 | FindTextMessage(context.Context, storage.ExecQuerier, domain.ChatID, domain.UpdateID) (*domain.TextMessage, error) 18 | UpdateTextMessage(context.Context, storage.ExecQuerier, *domain.TextMessage) (*domain.TextMessage, error) 19 | 20 | CreateReaction(context.Context, storage.ExecQuerier, *domain.Reaction) (*domain.Reaction, error) 21 | FindReaction(context.Context, storage.ExecQuerier, domain.ChatID, domain.UpdateID) (*domain.Reaction, error) 22 | 23 | FindFileMessage(context.Context, storage.ExecQuerier, domain.ChatID, domain.UpdateID) (*domain.FileMessage, error) 24 | CreateFileMessage(context.Context, storage.ExecQuerier, *domain.FileMessage) (*domain.FileMessage, error) 25 | } 26 | 27 | type SecretUpdateRepository interface { 28 | CreateSecretUpdate(context.Context, storage.ExecQuerier, *domain.SecretUpdate) (*domain.SecretUpdate, error) 29 | FindSecretUpdate(context.Context, storage.ExecQuerier, domain.ChatID, domain.UpdateID) (*domain.SecretUpdate, error) 30 | DeleteSecretUpdate(context.Context, storage.ExecQuerier, domain.ChatID, domain.UpdateID) error 31 | CreateUpdateDeleted(context.Context, storage.ExecQuerier, *domain.UpdateDeleted) (*domain.UpdateDeleted, error) 32 | } 33 | -------------------------------------------------------------------------------- /messaging-service/internal/application/storage/sql.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/jackc/pgx/v5" 8 | "github.com/jackc/pgx/v5/pgconn" 9 | ) 10 | 11 | type SQLer interface { 12 | TxProvider 13 | ExecQuerier 14 | } 15 | 16 | type TxProvider interface { 17 | BeginTx(context.Context, pgx.TxOptions) (pgx.Tx, error) 18 | Begin(context.Context) (pgx.Tx, error) 19 | } 20 | 21 | type ExecQuerier interface { 22 | Exec(_ context.Context, query string, args ...any) (pgconn.CommandTag, error) 23 | Query(_ context.Context, query string, args ...any) (pgx.Rows, error) 24 | QueryRow(_ context.Context, query string, args ...any) pgx.Row 25 | } 26 | 27 | // Should be used only in `defer` block because it has recover() call 28 | func FinishTx(ctx context.Context, tx pgx.Tx, err *error) { 29 | if p := recover(); p != nil { 30 | if *err != nil { 31 | *err = fmt.Errorf(`[PANIC] panic recovered: %+v. (error overwritten: "%w")`, p, *err) 32 | } else { 33 | *err = fmt.Errorf(`[PANIC] panic recovered: %+v`, p) 34 | } 35 | } 36 | 37 | if *err != nil { 38 | tx.Rollback(ctx) 39 | } else { 40 | tx.Commit(ctx) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /messaging-service/internal/configuration/config.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/viper" 7 | ) 8 | 9 | type Config struct { 10 | JWT struct { 11 | SigningMethod string `mapstructure:"signing_method"` 12 | Issuer string `mapstructure:"issuer"` 13 | Audience []string `mapstructure:"audience"` 14 | KeyFilePath string `mapstructure:"key_file_path"` 15 | } `mapstructure:"jwt"` 16 | 17 | DB struct { 18 | ConnString string `mapstructure:"conn_string"` 19 | } `mapstructure:"db"` 20 | 21 | Redis struct { 22 | Addr string `mapstructure:"addr"` 23 | Password string `mapstructure:"password"` 24 | DB int `mapstructure:"db"` 25 | } `mapstructure:"redis"` 26 | 27 | Otlp struct { 28 | GrpcAddr string `mapstructure:"grpc_addr"` 29 | } `mapstructure:"otlp"` 30 | 31 | FileStorage struct { 32 | GrpcAddr string `mapstructure:"grpc_addr"` 33 | } `mapstructure:"file_storage"` 34 | 35 | Kafka struct { 36 | Brokers []string `mapstructure:"brokers"` 37 | Topic string `mapstructure:"topic"` 38 | } `mapstructure:"kafka"` 39 | } 40 | 41 | func LoadConfig(file string) (*Config, error) { 42 | viper.AutomaticEnv() 43 | 44 | viper.MustBindEnv("db.conn_string", "DB_CONN_STRING") 45 | 46 | viper.SetConfigFile(file) 47 | 48 | if err := viper.ReadInConfig(); err != nil { 49 | return nil, fmt.Errorf("viper reading config failed: %s", err) 50 | } 51 | 52 | conf := new(Config) 53 | if err := viper.UnmarshalExact(&conf); err != nil { 54 | return nil, fmt.Errorf("viper config unmarshalling failed: %s", err) 55 | } 56 | 57 | return conf, nil 58 | } 59 | -------------------------------------------------------------------------------- /messaging-service/internal/configuration/db.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/storage" 5 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/storage/repository" 6 | "github.com/chakchat/chakchat-backend/messaging-service/internal/infrastructure/postgres/chat" 7 | "github.com/chakchat/chakchat-backend/messaging-service/internal/infrastructure/postgres/update" 8 | "github.com/redis/go-redis/v9" 9 | ) 10 | 11 | type DB struct { 12 | PersonalChat repository.PersonalChatRepository 13 | GroupChat repository.GroupChatRepository 14 | SecretPersonalChat repository.SecretPersonalChatRepository 15 | SecretGroupChat repository.SecretGroupChatRepository 16 | Chatter repository.ChatterRepository 17 | GenericChat repository.GenericChatRepository 18 | 19 | Update repository.UpdateRepository 20 | SecretUpdate repository.SecretUpdateRepository 21 | GenericUpdate repository.GenericUpdateRepository 22 | 23 | SQLer storage.SQLer 24 | 25 | Redis *redis.Client 26 | } 27 | 28 | func NewDB(db storage.SQLer, redis *redis.Client) *DB { 29 | return &DB{ 30 | PersonalChat: chat.NewPersonalChatRepository(), 31 | GroupChat: chat.NewGroupChatRepository(), 32 | SecretPersonalChat: chat.NewSecretPersonalChatRepository(), 33 | SecretGroupChat: chat.NewSecretGroupChatRepository(), 34 | Chatter: chat.NewChatterRepository(), 35 | GenericChat: chat.NewGenericChatRepository(), 36 | Update: update.NewUpdateRepository(), 37 | SecretUpdate: update.NewSecretUpdateRepository(), 38 | GenericUpdate: update.NewGenericUpdateRepository(), 39 | SQLer: db, 40 | Redis: redis, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /messaging-service/internal/configuration/external.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/external" 5 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/publish" 6 | "github.com/chakchat/chakchat-backend/messaging-service/internal/infrastructure/proto" 7 | "google.golang.org/grpc" 8 | ) 9 | 10 | type External struct { 11 | Publisher publish.Publisher 12 | FileStorage external.FileStorage 13 | } 14 | 15 | func NewExternal(fileStConn *grpc.ClientConn, mq external.MqPublisher) *External { 16 | return &External{ 17 | Publisher: publish.NewUserEventPublisher(mq), 18 | FileStorage: proto.NewFileStorage(fileStConn), 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /messaging-service/internal/configuration/handler.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "github.com/chakchat/chakchat-backend/messaging-service/internal/rest/handlers/chat" 5 | "github.com/chakchat/chakchat-backend/messaging-service/internal/rest/handlers/update" 6 | ) 7 | 8 | type Handlers struct { 9 | PersonalChat *chat.PersonalChatHandler 10 | GroupChat *chat.GroupChatHandler 11 | GroupPhoto *chat.GroupPhotoHandler 12 | SecretPersonalChat *chat.SecretPersonalChatHandler 13 | SecretGroup *chat.SecretGroupHandler 14 | SecretGroupPhoto *chat.SecretGroupPhotoHandler 15 | GenericChat *chat.GenericChatHandler 16 | 17 | PersonalUpdate *update.PersonalUpdateHandler 18 | PersonalFile *update.PersonalFileHandler 19 | GroupUpdate *update.GroupUpdateHandler 20 | GroupFile *update.GroupFileHandler 21 | SecretPersonalUpdate *update.SecretPersonalUpdateHandler 22 | SecretGroupUpdate *update.SecretGroupUpdateHandler 23 | GenericUpdate *update.GenericUpdateHandler 24 | } 25 | 26 | func NewHandlers(services *Services) *Handlers { 27 | return &Handlers{ 28 | PersonalChat: chat.NewPersonalChatHandler(services.PersonalChat), 29 | GroupChat: chat.NewGroupChatHandler(services.GroupChat), 30 | GroupPhoto: chat.NewGroupPhotoHandler(services.GroupPhoto), 31 | SecretPersonalChat: chat.NewSecretPersonalChatHandler(services.SecretPersonalChat), 32 | SecretGroup: chat.NewSecretGroupHandler(services.SecretGroup), 33 | SecretGroupPhoto: chat.NewSecretGroupPhotoHandler(services.SecretGroupPhoto), 34 | GenericChat: chat.NewGenericChatHandler(services.GenericChat), 35 | PersonalUpdate: update.NewPersonalUpdateHandler(services.PersonalUpdate), 36 | PersonalFile: update.NewFileHandler(services.PersonalFile), 37 | GroupUpdate: update.NewGroupUpdateHandler(services.GroupUpdate), 38 | GroupFile: update.NewGroupFileHandler(services.GroupFile), 39 | SecretPersonalUpdate: update.NewSecretPersonalUpdateHandler(services.SecretPersonalUpdate), 40 | SecretGroupUpdate: update.NewSecretGroupUpdateHandler(services.SecretGroupUpdate), 41 | GenericUpdate: update.NewGenericUpdateHandler(services.GenericUpdate), 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /messaging-service/internal/domain/chat.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | const ( 10 | ChatTypePersonal = "personal" 11 | ChatTypeGroup = "group" 12 | ChatTypeSecretPersonal = "secret_personal" 13 | ChatTypeSecretGroup = "secret_group" 14 | ) 15 | 16 | const ( 17 | maxGroupNameLen = 50 18 | maxDescriptionLen = 300 19 | ) 20 | 21 | type ( 22 | URL string 23 | ChatID uuid.UUID 24 | UserID uuid.UUID 25 | ) 26 | 27 | func NewUserID(id string) (UserID, error) { 28 | userId, err := uuid.Parse(id) 29 | return UserID(userId), err 30 | } 31 | 32 | func NewChatID() ChatID { 33 | return ChatID(uuid.New()) 34 | } 35 | 36 | type Chat struct { 37 | ID ChatID 38 | CreatedAt Timestamp 39 | } 40 | 41 | func (c *Chat) ChatID() ChatID { 42 | return c.ID 43 | } 44 | 45 | type Chatter interface { 46 | ChatID() ChatID 47 | IsMember(UserID) bool 48 | ValidateCanSend(UserID) error 49 | } 50 | 51 | func NormilizeMembers(members []UserID) []UserID { 52 | met := make(map[UserID]struct{}, len(members)) 53 | normMembers := make([]UserID, 0, len(members)) 54 | 55 | for _, member := range members { 56 | if _, ok := met[member]; !ok { 57 | normMembers = append(normMembers, member) 58 | met[member] = struct{}{} 59 | } 60 | } 61 | 62 | return normMembers 63 | } 64 | 65 | func ValidateGroupInfo(name, description string) error { 66 | var errs []error 67 | if name == "" { 68 | errs = append(errs, ErrGroupNameEmpty) 69 | } 70 | if len(name) > maxGroupNameLen { 71 | errs = append(errs, ErrGroupNameTooLong) 72 | } 73 | if len(description) > maxDescriptionLen { 74 | errs = append(errs, ErrGroupDescTooLong) 75 | } 76 | return errors.Join(errs...) 77 | } 78 | -------------------------------------------------------------------------------- /messaging-service/internal/domain/errors.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | type Error struct { 4 | text string 5 | } 6 | 7 | func (e Error) Error() string { 8 | return e.text 9 | } 10 | 11 | // NOTE: 12 | // If you add an error here 13 | // You alse should add it to `errmap` package 14 | var ( 15 | ErrAdminNotMember = Error{"group members doesn't include admin"} 16 | ErrGroupNameEmpty = Error{"group name is empty"} 17 | ErrGroupNameTooLong = Error{"group name is too long"} 18 | ErrGroupDescTooLong = Error{"group description is too long"} 19 | ErrUserAlreadyMember = Error{"user is already a member of a chat"} 20 | ErrMemberIsAdmin = Error{"group member is admin"} 21 | ErrGroupPhotoEmpty = Error{"group photo is empty"} 22 | ErrChatWithMyself = Error{"chat with myself"} 23 | ErrChatBlocked = Error{"chat is blocked"} 24 | ErrFileTooBig = Error{"file is too big"} 25 | ErrReactionNotFromUser = Error{"the reaction is not from this user"} 26 | ErrTooManyTextRunes = Error{"too many runes in text"} 27 | ErrTextEmpty = Error{"the text is empty"} 28 | ErrUserNotSender = Error{"user is not update's sender"} 29 | ErrUpdateNotFromChat = Error{"update is not from this chat"} 30 | ErrUpdateDeleted = Error{"update is deleted"} 31 | ErrAlreadyBlocked = Error{"chat is already blocked"} 32 | ErrAlreadyUnblocked = Error{"chat is already unblocked"} 33 | ErrSenderNotAdmin = Error{"sender is not admin"} 34 | ErrUserNotMember = Error{"user is not member of a chat"} 35 | ErrInvalidDeleteMode = Error{"invalid delete mode"} 36 | ErrInvalidReactionType = Error{"invalid reaction type"} 37 | ) 38 | -------------------------------------------------------------------------------- /messaging-service/internal/domain/file_message.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | ) 6 | 7 | const ( 8 | MaxFileSize = 1 << 30 9 | ) 10 | 11 | type FileMeta struct { 12 | FileId uuid.UUID 13 | FileName string 14 | MimeType string 15 | FileSize int64 16 | FileURL URL 17 | CreatedAt Timestamp 18 | } 19 | 20 | type FileMessage struct { 21 | Message 22 | File FileMeta 23 | } 24 | 25 | func NewFileMessage(chat Chatter, sender UserID, file *FileMeta, replyTo *Message) (*FileMessage, error) { 26 | if err := chat.ValidateCanSend(sender); err != nil { 27 | return nil, err 28 | } 29 | 30 | if err := validateFile(file); err != nil { 31 | return nil, err 32 | } 33 | 34 | if replyTo != nil { 35 | if err := validateCanReply(chat, sender, replyTo); err != nil { 36 | return nil, err 37 | } 38 | } 39 | 40 | var replyToID *UpdateID 41 | if replyTo != nil { 42 | replyToID = &replyTo.UpdateID 43 | } 44 | 45 | return &FileMessage{ 46 | Message: Message{ 47 | Update: Update{ 48 | ChatID: chat.ChatID(), 49 | SenderID: sender, 50 | }, 51 | ReplyTo: replyToID, 52 | }, 53 | File: *file, 54 | }, nil 55 | } 56 | 57 | func (m *FileMessage) Forward(chat Chatter, sender UserID, destChat Chatter) (*FileMessage, error) { 58 | if !chat.IsMember(sender) { 59 | return nil, ErrUserNotMember 60 | } 61 | if err := destChat.ValidateCanSend(sender); err != nil { 62 | return nil, err 63 | } 64 | 65 | return &FileMessage{ 66 | Message: Message{ 67 | Update: Update{ 68 | ChatID: destChat.ChatID(), 69 | SenderID: sender, 70 | }, 71 | Forwarded: true, 72 | }, 73 | File: m.File, 74 | }, nil 75 | } 76 | 77 | func validateFile(file *FileMeta) error { 78 | if file.FileSize > MaxFileSize { 79 | return ErrFileTooBig 80 | } 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /messaging-service/internal/domain/file_message_test.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestFileMessage(t *testing.T) { 10 | user1, _ := NewUserID("3d7ca3ef-3b0d-4113-91c9-20b7bf874324") 11 | user2, _ := NewUserID("ce30ebc7-4058-4351-9a8f-66c71f987fdf") 12 | user3, _ := NewUserID("fb048277-ad4f-4730-88eb-5e453c9ca5ce") 13 | chat := &FakeChat{ 14 | Chat: Chat{ 15 | ID: NewChatID(), 16 | }, 17 | Members: [2]UserID{user1, user2}, 18 | } 19 | 20 | file := &FileMeta{ 21 | FileId: [16]byte{}, 22 | FileName: "text.txt", 23 | MimeType: "plain/txt", 24 | FileSize: 1000, 25 | FileURL: "https://url.ru", 26 | } 27 | 28 | t.Run("New", func(t *testing.T) { 29 | _, err := NewFileMessage(chat, user3, file, nil) 30 | require.ErrorIs(t, err, ErrUserNotMember) 31 | 32 | msg1, err := NewFileMessage(chat, user1, file, nil) 33 | require.NoError(t, err) 34 | require.Equal(t, chat.ChatID(), msg1.ChatID) 35 | require.Equal(t, user1, msg1.SenderID) 36 | }) 37 | 38 | t.Run("Delete", func(t *testing.T) { 39 | msg := FileMessage{ 40 | Message: Message{ 41 | Update: Update{ 42 | UpdateID: 12, 43 | ChatID: chat.ChatID(), 44 | SenderID: user1, 45 | }, 46 | }, 47 | } 48 | 49 | err := msg.Delete(chat, user3, DeleteModeForAll) 50 | require.ErrorIs(t, err, ErrUserNotMember) 51 | 52 | err = msg.Delete(chat, user2, DeleteModeForSender) 53 | require.NoError(t, err) 54 | require.True(t, msg.DeletedFor(user2)) 55 | require.False(t, msg.DeletedFor(user1)) 56 | 57 | err = msg.Delete(chat, user1, DeleteModeForAll) 58 | require.NoError(t, err) 59 | require.True(t, msg.DeletedFor(user2)) 60 | }) 61 | } 62 | 63 | type FakeChat struct { 64 | Chat 65 | Members [2]UserID 66 | } 67 | 68 | func (c *FakeChat) ChatID() ChatID { 69 | return c.Chat.ID 70 | } 71 | 72 | func (c *FakeChat) ValidateCanSend(user UserID) error { 73 | if !c.IsMember(user) { 74 | return ErrUserNotMember 75 | } 76 | return nil 77 | } 78 | 79 | func (c *FakeChat) IsMember(user UserID) bool { 80 | return user == c.Members[0] || user == c.Members[1] 81 | } 82 | -------------------------------------------------------------------------------- /messaging-service/internal/domain/message.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | type Message struct { 4 | Update 5 | 6 | ReplyTo *UpdateID 7 | Forwarded bool 8 | } 9 | 10 | func (m *Message) Delete(chat Chatter, sender UserID, mode DeleteMode) error { 11 | if err := chat.ValidateCanSend(sender); err != nil { 12 | return err 13 | } 14 | 15 | if chat.ChatID() != m.ChatID { 16 | return ErrUpdateNotFromChat 17 | } 18 | 19 | if m.DeletedFor(sender) { 20 | return ErrUpdateDeleted 21 | } 22 | 23 | m.AddDeletion(sender, mode) 24 | return nil 25 | } 26 | 27 | func validateCanReply(chat Chatter, sender UserID, replyTo *Message) error { 28 | if replyTo.DeletedFor(sender) { 29 | return ErrUpdateDeleted 30 | } 31 | if chat.ChatID() != replyTo.ChatID { 32 | return ErrUpdateNotFromChat 33 | } 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /messaging-service/internal/domain/personal/personal_chat.go: -------------------------------------------------------------------------------- 1 | package personal 2 | 3 | import ( 4 | "slices" 5 | 6 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 7 | ) 8 | 9 | type PersonalChat struct { 10 | domain.Chat 11 | Members [2]domain.UserID 12 | 13 | BlockedBy []domain.UserID 14 | } 15 | 16 | func NewPersonalChat(users [2]domain.UserID) (*PersonalChat, error) { 17 | if users[0] == users[1] { 18 | return nil, domain.ErrChatWithMyself 19 | } 20 | 21 | return &PersonalChat{ 22 | Chat: domain.Chat{ 23 | ID: domain.NewChatID(), 24 | }, 25 | Members: users, 26 | BlockedBy: nil, 27 | }, nil 28 | } 29 | 30 | func (c *PersonalChat) Delete(sender domain.UserID) error { 31 | if !c.IsMember(sender) { 32 | return domain.ErrUserNotMember 33 | } 34 | return nil 35 | } 36 | 37 | func (c *PersonalChat) BlockBy(user domain.UserID) error { 38 | if !c.IsMember(user) { 39 | return domain.ErrUserNotMember 40 | } 41 | 42 | if slices.Contains(c.BlockedBy, user) { 43 | return domain.ErrAlreadyBlocked 44 | } 45 | 46 | c.BlockedBy = append(c.BlockedBy, user) 47 | 48 | return nil 49 | } 50 | 51 | func (c *PersonalChat) UnblockBy(user domain.UserID) error { 52 | if !c.IsMember(user) { 53 | return domain.ErrUserNotMember 54 | } 55 | 56 | if !slices.Contains(c.BlockedBy, user) { 57 | return domain.ErrAlreadyUnblocked 58 | } 59 | 60 | c.BlockedBy = slices.DeleteFunc(c.BlockedBy, func(member domain.UserID) bool { 61 | return member == user 62 | }) 63 | 64 | return nil 65 | } 66 | 67 | func (c *PersonalChat) Blocked() bool { 68 | return len(c.BlockedBy) > 0 69 | } 70 | 71 | func (c *PersonalChat) IsMember(user domain.UserID) bool { 72 | return user == c.Members[0] || user == c.Members[1] 73 | } 74 | 75 | func (c *PersonalChat) ValidateCanSend(sender domain.UserID) error { 76 | if !c.IsMember(sender) { 77 | return domain.ErrUserNotMember 78 | } 79 | if c.Blocked() { 80 | return domain.ErrChatBlocked 81 | } 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /messaging-service/internal/domain/reaction.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | // If you want to add reaction to this list it says that it should be made more flexible 4 | var allowedReactionTypes = map[ReactionType]struct{}{ 5 | "heart": {}, 6 | "like": {}, 7 | "thunder": {}, 8 | "cry": {}, 9 | "dislike": {}, 10 | "bzZZ": {}, 11 | } 12 | 13 | type ReactionType string 14 | 15 | type Reaction struct { 16 | Update 17 | 18 | Type ReactionType 19 | MessageID UpdateID 20 | } 21 | 22 | func NewReaction( 23 | chat Chatter, 24 | sender UserID, 25 | m *Message, 26 | reaction ReactionType, 27 | ) (*Reaction, error) { 28 | if err := chat.ValidateCanSend(sender); err != nil { 29 | return nil, err 30 | } 31 | 32 | if chat.ChatID() != m.ChatID { 33 | return nil, ErrUpdateNotFromChat 34 | } 35 | 36 | if m.DeletedFor(sender) { 37 | return nil, ErrUpdateDeleted 38 | } 39 | 40 | if err := validateReactionType(reaction); err != nil { 41 | return nil, err 42 | } 43 | 44 | return &Reaction{ 45 | Update: Update{ 46 | ChatID: chat.ChatID(), 47 | SenderID: sender, 48 | }, 49 | Type: reaction, 50 | MessageID: m.UpdateID, 51 | }, nil 52 | } 53 | 54 | func (r *Reaction) Delete(chat Chatter, sender UserID) error { 55 | if err := chat.ValidateCanSend(sender); err != nil { 56 | return err 57 | } 58 | 59 | if chat.ChatID() != r.ChatID { 60 | return ErrUpdateNotFromChat 61 | } 62 | 63 | if r.SenderID != sender { 64 | return ErrReactionNotFromUser 65 | } 66 | 67 | if r.DeletedFor(sender) { 68 | return ErrUpdateDeleted 69 | } 70 | 71 | r.AddDeletion(sender, DeleteModeForAll) 72 | return nil 73 | } 74 | 75 | func validateReactionType(r ReactionType) error { 76 | if _, ok := allowedReactionTypes[r]; ok { 77 | return nil 78 | } 79 | return ErrInvalidReactionType 80 | } 81 | -------------------------------------------------------------------------------- /messaging-service/internal/domain/reaction_test.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestReaction(t *testing.T) { 10 | user1, _ := NewUserID("3d7ca3ef-3b0d-4113-91c9-20b7bf874324") 11 | user2, _ := NewUserID("ce30ebc7-4058-4351-9a8f-66c71f987fdf") 12 | user3, _ := NewUserID("fb048277-ad4f-4730-88eb-5e453c9ca5ce") 13 | chat := &FakeChat{ 14 | Chat: Chat{ 15 | ID: NewChatID(), 16 | }, 17 | Members: [2]UserID{user1, user2}, 18 | } 19 | 20 | txtMsg := TextMessage{ 21 | Message: Message{ 22 | Update: Update{ 23 | UpdateID: 12, 24 | ChatID: chat.ID, 25 | SenderID: user1, 26 | }, 27 | }, 28 | } 29 | 30 | _, err := NewReaction(chat, user3, &txtMsg.Message, "heart") 31 | require.ErrorIs(t, err, ErrUserNotMember) 32 | 33 | reaction, err := NewReaction(chat, user1, &txtMsg.Message, "heart") 34 | require.NoError(t, err) 35 | require.Equal(t, chat.ID, reaction.ChatID) 36 | require.Equal(t, user1, reaction.SenderID) 37 | 38 | err = reaction.Delete(chat, user3) 39 | require.ErrorIs(t, err, ErrUserNotMember) 40 | 41 | err = reaction.Delete(chat, user2) 42 | require.ErrorIs(t, err, ErrReactionNotFromUser) 43 | 44 | err = reaction.Delete(chat, user1) 45 | require.NoError(t, err) 46 | require.True(t, reaction.DeletedFor(user1)) 47 | require.True(t, reaction.DeletedFor(user2)) 48 | } 49 | -------------------------------------------------------------------------------- /messaging-service/internal/domain/secpersonal/secret_personal_chat.go: -------------------------------------------------------------------------------- 1 | package secpersonal 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 7 | ) 8 | 9 | type SecretPersonalChat struct { 10 | domain.SecretChat 11 | Members [2]domain.UserID 12 | } 13 | 14 | func NewSecretPersonalChatService(users [2]domain.UserID) (*SecretPersonalChat, error) { 15 | if users[0] == users[1] { 16 | return nil, domain.ErrChatWithMyself 17 | } 18 | 19 | return &SecretPersonalChat{ 20 | SecretChat: domain.SecretChat{ 21 | Chat: domain.Chat{ 22 | ID: domain.NewChatID(), 23 | }, 24 | }, 25 | Members: users, 26 | }, nil 27 | } 28 | 29 | func (c *SecretPersonalChat) SetExpiration(sender domain.UserID, exp *time.Duration) error { 30 | if !c.IsMember(sender) { 31 | return domain.ErrUserNotMember 32 | } 33 | c.Exp = exp 34 | return nil 35 | } 36 | 37 | func (c *SecretPersonalChat) Delete(sender domain.UserID) error { 38 | if !c.IsMember(sender) { 39 | return domain.ErrUserNotMember 40 | } 41 | return nil 42 | } 43 | 44 | func (c *SecretPersonalChat) IsMember(user domain.UserID) bool { 45 | return user == c.Members[0] || user == c.Members[1] 46 | } 47 | 48 | func (c *SecretPersonalChat) ValidateCanSend(sender domain.UserID) error { 49 | if !c.IsMember(sender) { 50 | return domain.ErrUserNotMember 51 | } 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /messaging-service/internal/domain/secret_chat.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "time" 4 | 5 | type SecretChat struct { 6 | Chat 7 | 8 | Exp *time.Duration 9 | } 10 | 11 | func (c *SecretChat) Expiration() *time.Duration { 12 | return c.Exp 13 | } 14 | 15 | type SecretChatter interface { 16 | Chatter 17 | Expiration() *time.Duration 18 | } 19 | -------------------------------------------------------------------------------- /messaging-service/internal/domain/secret_update.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | var TimeFunc = func() time.Time { 8 | return time.Now() 9 | } 10 | 11 | type ( 12 | SecretKeyHash []byte 13 | Encrypted []byte 14 | InitializationVector []byte 15 | ) 16 | 17 | type SecretData struct { 18 | KeyHash SecretKeyHash 19 | Payload Encrypted 20 | IV InitializationVector 21 | } 22 | 23 | type SecretUpdate struct { 24 | Update 25 | Data SecretData 26 | } 27 | 28 | func (u *SecretUpdate) Delete(chat SecretChatter, sender UserID) error { 29 | if !chat.IsMember(sender) { 30 | return ErrUserNotMember 31 | } 32 | 33 | if chat.ChatID() != u.ChatID { 34 | return ErrUpdateNotFromChat 35 | } 36 | 37 | if u.DeletedFor(sender) { 38 | return ErrUpdateDeleted 39 | } 40 | 41 | u.AddDeletion(sender, DeleteModeForAll) 42 | return nil 43 | } 44 | 45 | func (u *SecretUpdate) Expired(exp *time.Duration) bool { 46 | if exp == nil { 47 | return false 48 | } 49 | now := TimeFunc() 50 | expTime := u.CreatedAt.Time().Add(*exp) 51 | return expTime.Before(now) 52 | } 53 | 54 | func NewSecretUpdate(chat SecretChatter, sender UserID, data SecretData) (*SecretUpdate, error) { 55 | if err := chat.ValidateCanSend(sender); err != nil { 56 | return nil, err 57 | } 58 | 59 | return &SecretUpdate{ 60 | Update: Update{ 61 | ChatID: chat.ChatID(), 62 | SenderID: sender, 63 | }, 64 | Data: data, 65 | }, nil 66 | } 67 | -------------------------------------------------------------------------------- /messaging-service/internal/infrastructure/kafkamq/mq.go: -------------------------------------------------------------------------------- 1 | package kafkamq 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/segmentio/kafka-go" 7 | "go.opentelemetry.io/otel" 8 | "go.opentelemetry.io/otel/trace" 9 | ) 10 | 11 | type KafkaMQPublisher struct { 12 | writer *kafka.Writer 13 | tracer trace.Tracer 14 | } 15 | 16 | func NewKafkaMQPublisher(writer *kafka.Writer) *KafkaMQPublisher { 17 | return &KafkaMQPublisher{ 18 | writer: writer, 19 | tracer: otel.GetTracerProvider().Tracer("kafka-mq-publisher"), 20 | } 21 | } 22 | 23 | func (k *KafkaMQPublisher) Publish(ctx context.Context, raw []byte) error { 24 | var span trace.Span 25 | ctx, span = k.tracer.Start(ctx, "write") 26 | defer span.End() 27 | 28 | msg := kafka.Message{ 29 | Value: raw, 30 | } 31 | 32 | if err := k.writer.WriteMessages(ctx, msg); err != nil { 33 | span.RecordError(err) 34 | return err 35 | } 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /messaging-service/internal/infrastructure/postgres/chat/chatter_repository.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/storage" 9 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/storage/repository" 10 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 11 | "github.com/jackc/pgx/v5" 12 | ) 13 | 14 | type ChatterRepository struct { 15 | // I am sorry for this cringe, just don't wanna copy-paste code 16 | personalRepo *PersonalChatRepository 17 | groupRepo *GroupChatRepository 18 | secpPersonalRepo *SecretPersonalChatRepository 19 | secGroupRepo *SecretGroupChatRepository 20 | } 21 | 22 | func NewChatterRepository() *ChatterRepository { 23 | return &ChatterRepository{ 24 | personalRepo: NewPersonalChatRepository(), 25 | groupRepo: NewGroupChatRepository(), 26 | secpPersonalRepo: NewSecretPersonalChatRepository(), 27 | secGroupRepo: NewSecretGroupChatRepository(), 28 | } 29 | } 30 | 31 | func (r *ChatterRepository) FindChatter( 32 | ctx context.Context, db storage.ExecQuerier, id domain.ChatID, 33 | ) (domain.Chatter, error) { 34 | q := `SELECT chat_type FROM messaging.chat WHERE chat_id = $1` 35 | 36 | var ( 37 | chatType string 38 | ) 39 | row := db.QueryRow(ctx, q, id) 40 | 41 | err := row.Scan(&chatType) 42 | if err != nil { 43 | if errors.Is(err, pgx.ErrNoRows) { 44 | return nil, repository.ErrNotFound 45 | } 46 | return nil, err 47 | } 48 | 49 | if chatType == domain.ChatTypePersonal { 50 | return r.personalRepo.FindById(ctx, db, id) 51 | } 52 | 53 | if chatType == domain.ChatTypeGroup { 54 | return r.groupRepo.FindById(ctx, db, id) 55 | } 56 | 57 | if chatType == domain.ChatTypeSecretPersonal { 58 | return r.secpPersonalRepo.FindById(ctx, db, id) 59 | } 60 | 61 | if chatType == domain.ChatTypeSecretGroup { 62 | return r.secGroupRepo.FindById(ctx, db, id) 63 | } 64 | 65 | return nil, errors.Join(repository.ErrNotFound, fmt.Errorf("unknown Chatter type: %s", chatType)) 66 | } 67 | -------------------------------------------------------------------------------- /messaging-service/internal/infrastructure/postgres/chat/utils.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "github.com/chakchat/chakchat-backend/messaging-service/internal/domain" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | func userIDs(arr []uuid.UUID) []domain.UserID { 9 | res := make([]domain.UserID, len(arr)) 10 | for i := range res { 11 | res[i] = domain.UserID(arr[i]) 12 | } 13 | return res 14 | } 15 | 16 | func uuids(s []domain.UserID) []uuid.UUID { 17 | res := make([]uuid.UUID, len(s)) 18 | for i := range res { 19 | res[i] = uuid.UUID(s[i]) 20 | } 21 | return res 22 | } 23 | 24 | func sliceMisses[T comparable](orig, comp []T) []T { 25 | compMap := make(map[T]bool, len(comp)) 26 | for _, t := range comp { 27 | compMap[t] = true 28 | } 29 | 30 | var misses []T 31 | 32 | for _, t := range orig { 33 | if !compMap[t] { 34 | misses = append(misses, t) 35 | } 36 | } 37 | 38 | return misses 39 | } 40 | -------------------------------------------------------------------------------- /messaging-service/internal/infrastructure/proto/file_storage.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/external" 8 | "github.com/chakchat/chakchat-backend/messaging-service/internal/infrastructure/proto/filestorage" 9 | "github.com/google/uuid" 10 | "google.golang.org/grpc" 11 | "google.golang.org/grpc/codes" 12 | "google.golang.org/grpc/status" 13 | ) 14 | 15 | type FileStorage struct { 16 | client filestorage.FileStorageServiceClient 17 | } 18 | 19 | func NewFileStorage(conn *grpc.ClientConn) *FileStorage { 20 | return &FileStorage{ 21 | client: filestorage.NewFileStorageServiceClient(conn), 22 | } 23 | } 24 | 25 | func (s *FileStorage) GetById(ctx context.Context, id uuid.UUID) (*external.FileMeta, error) { 26 | resp, err := s.client.GetFile(ctx, &filestorage.GetFileRequest{ 27 | FileId: &filestorage.UUID{Value: id.String()}, 28 | }) 29 | if err != nil { 30 | if status, ok := status.FromError(err); ok && status.Code() == codes.NotFound { 31 | return nil, external.ErrFileNotFound 32 | } 33 | return nil, err 34 | } 35 | 36 | fileId, err := uuid.Parse(resp.FileId.Value) 37 | if err != nil { 38 | return nil, fmt.Errorf("cannot parse fileId from file storage: %s", err) 39 | } 40 | 41 | return &external.FileMeta{ 42 | FileId: fileId, 43 | FileName: resp.FileName, 44 | MimeType: resp.MimeType, 45 | FileSize: resp.FileSize, 46 | FileUrl: resp.FileUrl, 47 | CreatedAt: resp.CreatedAtUNIX, 48 | }, nil 49 | } 50 | -------------------------------------------------------------------------------- /messaging-service/internal/rest/handlers/chat/utils.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chakchat/chakchat-backend/shared/go/auth" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func getUserID(ctx context.Context) uuid.UUID { 11 | return uuid.MustParse(auth.GetClaims(ctx)[auth.ClaimId].(string)) 12 | } 13 | -------------------------------------------------------------------------------- /messaging-service/internal/rest/handlers/update/generic_update_handler.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | 7 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/generic" 8 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/request" 9 | "github.com/chakchat/chakchat-backend/messaging-service/internal/rest/errmap" 10 | "github.com/chakchat/chakchat-backend/messaging-service/internal/rest/restapi" 11 | "github.com/gin-gonic/gin" 12 | "github.com/google/uuid" 13 | ) 14 | 15 | const ( 16 | queryParamFrom = "from" 17 | queryParamTo = "to" 18 | ) 19 | 20 | type GenericUpdateService interface { 21 | GetUpdatesRange(context.Context, request.GetUpdatesRange) ([]generic.Update, error) 22 | GetUpdate(context.Context, request.GetUpdate) (*generic.Update, error) 23 | } 24 | 25 | type GenericUpdateHandler struct { 26 | service GenericUpdateService 27 | } 28 | 29 | func NewGenericUpdateHandler(service GenericUpdateService) *GenericUpdateHandler { 30 | return &GenericUpdateHandler{ 31 | service: service, 32 | } 33 | } 34 | 35 | func (h *GenericUpdateHandler) GetUpdatesRange(c *gin.Context) { 36 | chatID, err := uuid.Parse(c.Param(paramChatID)) 37 | if err != nil { 38 | restapi.SendInvalidChatID(c) 39 | return 40 | } 41 | from, err := strconv.ParseInt(c.Query(queryParamFrom), 10, 64) 42 | if err != nil { 43 | restapi.SendValidationError(c, []restapi.ErrorDetail{{ 44 | Field: queryParamFrom, 45 | Message: "'from' query parameter is required integer", 46 | }}) 47 | } 48 | to, err := strconv.ParseInt(c.Query(queryParamTo), 10, 64) 49 | if err != nil { 50 | restapi.SendValidationError(c, []restapi.ErrorDetail{{ 51 | Field: queryParamTo, 52 | Message: "'to' query parameter is required integer", 53 | }}) 54 | } 55 | userID := getUserID(c.Request.Context()) 56 | 57 | updates, err := h.service.GetUpdatesRange(c.Request.Context(), request.GetUpdatesRange{ 58 | ChatID: chatID, 59 | SenderID: userID, 60 | From: from, 61 | To: to, 62 | }) 63 | if err != nil { 64 | errmap.Respond(c, err) 65 | return 66 | } 67 | 68 | restapi.SendSuccess(c, gin.H{ 69 | "updates": updates, 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /messaging-service/internal/rest/handlers/update/group_file_handler.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/dto" 7 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/generic" 8 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/request" 9 | "github.com/chakchat/chakchat-backend/messaging-service/internal/rest/errmap" 10 | "github.com/chakchat/chakchat-backend/messaging-service/internal/rest/restapi" 11 | "github.com/gin-gonic/gin" 12 | "github.com/google/uuid" 13 | ) 14 | 15 | type GroupFileService interface { 16 | SendFileMessage(ctx context.Context, req request.SendFileMessage) (*dto.FileMessageDTO, error) 17 | } 18 | 19 | type GroupFileHandler struct { 20 | service GroupFileService 21 | } 22 | 23 | func NewGroupFileHandler(service GroupFileService) *GroupFileHandler { 24 | return &GroupFileHandler{ 25 | service: service, 26 | } 27 | } 28 | 29 | func (h *GroupFileHandler) SendFileMessage(c *gin.Context) { 30 | chatID, err := uuid.Parse(c.Param(paramChatID)) 31 | if err != nil { 32 | restapi.SendInvalidChatID(c) 33 | return 34 | } 35 | userID := getUserID(c.Request.Context()) 36 | 37 | req := struct { 38 | FileID uuid.UUID `json:"file_id"` 39 | ReplyTo *int64 `json:"reply_to"` 40 | }{} 41 | if err := c.ShouldBindBodyWithJSON(&req); err != nil { 42 | restapi.SendUnprocessableJSON(c) 43 | } 44 | 45 | msg, err := h.service.SendFileMessage(c.Request.Context(), request.SendFileMessage{ 46 | ChatID: chatID, 47 | SenderID: userID, 48 | FileID: req.FileID, 49 | ReplyToMessage: req.ReplyTo, 50 | }) 51 | if err != nil { 52 | errmap.Respond(c, err) 53 | return 54 | } 55 | 56 | restapi.SendSuccess(c, generic.FromFileMessageDTO(msg)) 57 | } 58 | -------------------------------------------------------------------------------- /messaging-service/internal/rest/handlers/update/personal_file_handler.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/dto" 7 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/generic" 8 | "github.com/chakchat/chakchat-backend/messaging-service/internal/application/request" 9 | "github.com/chakchat/chakchat-backend/messaging-service/internal/rest/errmap" 10 | "github.com/chakchat/chakchat-backend/messaging-service/internal/rest/restapi" 11 | "github.com/gin-gonic/gin" 12 | "github.com/google/uuid" 13 | ) 14 | 15 | type PersonalFileService interface { 16 | SendFileMessage(ctx context.Context, req request.SendFileMessage) (*dto.FileMessageDTO, error) 17 | } 18 | 19 | type PersonalFileHandler struct { 20 | service PersonalFileService 21 | } 22 | 23 | func NewFileHandler(service PersonalFileService) *PersonalFileHandler { 24 | return &PersonalFileHandler{ 25 | service: service, 26 | } 27 | } 28 | 29 | func (h *PersonalFileHandler) SendFileMessage(c *gin.Context) { 30 | chatID, err := uuid.Parse(c.Param(paramChatID)) 31 | if err != nil { 32 | restapi.SendInvalidChatID(c) 33 | return 34 | } 35 | userID := getUserID(c.Request.Context()) 36 | 37 | req := struct { 38 | FileID uuid.UUID `json:"file_id"` 39 | ReplyTo *int64 `json:"reply_to"` 40 | }{} 41 | if err := c.ShouldBindBodyWithJSON(&req); err != nil { 42 | restapi.SendUnprocessableJSON(c) 43 | } 44 | 45 | msg, err := h.service.SendFileMessage(c.Request.Context(), request.SendFileMessage{ 46 | ChatID: chatID, 47 | SenderID: userID, 48 | FileID: req.FileID, 49 | ReplyToMessage: req.ReplyTo, 50 | }) 51 | if err != nil { 52 | errmap.Respond(c, err) 53 | return 54 | } 55 | 56 | restapi.SendSuccess(c, generic.FromFileMessageDTO(msg)) 57 | } 58 | -------------------------------------------------------------------------------- /messaging-service/internal/rest/handlers/update/utils.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chakchat/chakchat-backend/shared/go/auth" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func getUserID(ctx context.Context) uuid.UUID { 11 | return uuid.MustParse(auth.GetClaims(ctx)[auth.ClaimId].(string)) 12 | } 13 | -------------------------------------------------------------------------------- /messaging-service/internal/rest/middleware/internal_error.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func InternalError() gin.HandlerFunc { 10 | return func(c *gin.Context) { 11 | c.Next() 12 | 13 | status := c.Writer.Status() 14 | if status >= 500 { 15 | c.Error(errors.New("internal error middleware: got status code >=500")) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /messaging-service/internal/rest/restapi/common.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func SendSuccess(c *gin.Context, data any) { 10 | c.JSON(http.StatusOK, SuccessResponse{ 11 | Data: data, 12 | }) 13 | } 14 | 15 | func SendUnprocessableJSON(c *gin.Context) { 16 | c.JSON(http.StatusUnprocessableEntity, ErrorResponse{ 17 | ErrorType: ErrTypeInvalidJson, 18 | ErrorMessage: "Body has invalid JSON", 19 | }) 20 | } 21 | 22 | func SendValidationError(c *gin.Context, errors []ErrorDetail) { 23 | c.JSON(http.StatusBadRequest, ErrorResponse{ 24 | ErrorType: ErrTypeValidationFailed, 25 | ErrorMessage: "Validation has failed", 26 | ErrorDetails: errors, 27 | }) 28 | } 29 | 30 | func SendInternalError(c *gin.Context) { 31 | errResp := ErrorResponse{ 32 | ErrorType: ErrTypeInternal, 33 | ErrorMessage: "Internal Server Error", 34 | } 35 | c.JSON(http.StatusInternalServerError, errResp) 36 | } 37 | 38 | func SendInvalidChatID(c *gin.Context) { 39 | SendValidationError(c, []ErrorDetail{ 40 | { 41 | Field: "chatId", 42 | Message: "Invalid chatId route parameter", 43 | }, 44 | }) 45 | } 46 | 47 | func SendInvalidUpdateID(c *gin.Context) { 48 | SendValidationError(c, []ErrorDetail{ 49 | { 50 | Field: "updateId", 51 | Message: "Invalid updateId route parameter", 52 | }, 53 | }) 54 | } 55 | 56 | func SendInvalidMemberID(c *gin.Context) { 57 | SendValidationError(c, []ErrorDetail{ 58 | { 59 | Field: "chatId", 60 | Message: "Invalid chatId route parameter", 61 | }, 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /messaging-service/internal/rest/restapi/response.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | // Specified by contract.md in /api folder 4 | 5 | const ( 6 | ErrTypeInternal = "internal" 7 | ErrTypeInvalidJson = "invalid_json" 8 | ErrTypeValidationFailed = "validation_failed" 9 | ErrTypeNotFound = "not_found" 10 | ErrTypeIdempotencyKeyMissing = "idempotency_key_missing" 11 | ErrTypeUnautorized = "unauthorized" 12 | 13 | ErrTypeForbidden = "forbidden" 14 | ErrTypeInvalidParam = "invalid_param" 15 | 16 | ErrTypeAdminNotMember = "admin_not_member" 17 | ) 18 | 19 | type ErrorDetail struct { 20 | Field string `json:"field,omitempty"` 21 | Message string `json:"message,omitempty"` 22 | } 23 | 24 | type ErrorResponse struct { 25 | ErrorType string `json:"error_type,omitempty"` 26 | ErrorMessage string `json:"error_message,omitempty"` 27 | ErrorDetails []ErrorDetail `json:"error_details,omitempty"` 28 | } 29 | 30 | type SuccessResponse struct { 31 | Data any `json:"data,omitempty"` 32 | } 33 | -------------------------------------------------------------------------------- /messaging-service/migrations/V002__chat_type_trigger.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION messaging.check_chat_type() RETURNS TRIGGER AS $$ 2 | DECLARE 3 | must_chat_type messaging.chat_type; 4 | BEGIN 5 | IF TG_TABLE_NAME = 'personal_chat' THEN 6 | must_chat_type := 'personal'; 7 | ELSIF TG_TABLE_NAME = 'group_chat' THEN 8 | must_chat_type := 'group'; 9 | ELSIF TG_TABLE_NAME = 'secret_personal_chat' THEN 10 | must_chat_type := 'secret_personal'; 11 | ELSIF TG_TABLE_NAME = 'secret_group_chat' THEN 12 | must_chat_type := 'secret_group'; 13 | ELSE 14 | RAISE EXCEPTION 'Unknown chat relation %', TG_TABLE_NAME; 15 | END IF; 16 | 17 | IF (SELECT chat_type != must_chat_type FROM messaging.chat WHERE chat_id = NEW.chat_id) THEN 18 | RAISE EXCEPTION 'The created chat must be of type %', must_chat_type; 19 | END IF; 20 | 21 | RETURN NEW; 22 | END; 23 | $$ LANGUAGE plpgsql; 24 | 25 | CREATE TRIGGER ensure_personal_chat_type 26 | BEFORE INSERT 27 | ON messaging.personal_chat 28 | FOR EACH ROW 29 | EXECUTE PROCEDURE messaging.check_chat_type(); 30 | 31 | CREATE TRIGGER ensure_group_chat_type 32 | BEFORE INSERT 33 | ON messaging.group_chat 34 | FOR EACH ROW 35 | EXECUTE PROCEDURE messaging.check_chat_type(); 36 | 37 | CREATE TRIGGER ensure_secret_personal_chat_type 38 | BEFORE INSERT 39 | ON messaging.secret_personal_chat 40 | FOR EACH ROW 41 | EXECUTE PROCEDURE messaging.check_chat_type(); 42 | 43 | CREATE TRIGGER ensure_secret_group_chat_type 44 | BEFORE INSERT 45 | ON messaging.secret_group_chat 46 | FOR EACH ROW 47 | EXECUTE PROCEDURE messaging.check_chat_type(); -------------------------------------------------------------------------------- /messaging-service/migrations/V003__prohibit_subchats_delete.sql: -------------------------------------------------------------------------------- 1 | CREATE FUNCTION messaging.check_cannot_delete_subchat() RETURNS TRIGGER AS $$ 2 | BEGIN 3 | IF EXISTS (SELECT * FROM messaging.chat WHERE chat_id = old.chat_id) THEN 4 | RAISE EXCEPTION 'cannot delete a row from % table because you should delete from messaging.chat table instead', 5 | TG_TABLE_NAME; 6 | END IF; 7 | RETURN OLD; 8 | END; 9 | $$ LANGUAGE plpgsql; 10 | 11 | CREATE TRIGGER ensure_cannot_delete_from_personal_chat_t 12 | BEFORE DELETE 13 | ON messaging.personal_chat 14 | FOR EACH ROW 15 | EXECUTE PROCEDURE messaging.check_cannot_delete_subchat(); 16 | 17 | CREATE TRIGGER ensure_cannot_delete_from_group_chat_t 18 | BEFORE DELETE 19 | ON messaging.group_chat 20 | FOR EACH ROW 21 | EXECUTE PROCEDURE messaging.check_cannot_delete_subchat(); 22 | 23 | CREATE TRIGGER ensure_cannot_delete_from_secret_personal_chat_t 24 | BEFORE DELETE 25 | ON messaging.secret_personal_chat 26 | FOR EACH ROW 27 | EXECUTE PROCEDURE messaging.check_cannot_delete_subchat(); 28 | 29 | CREATE TRIGGER ensure_cannot_delete_from_secret_group_chat_t 30 | BEFORE DELETE 31 | ON messaging.secret_group_chat 32 | FOR EACH ROW 33 | EXECUTE PROCEDURE messaging.check_cannot_delete_subchat(); -------------------------------------------------------------------------------- /messaging-service/migrations/V005__update_id_sequence.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE messaging.chat_sequence ( 2 | chat_id UUID PRIMARY KEY, 3 | last_update_id BIGINT NOT NULL DEFAULT 0 4 | ); 5 | 6 | CREATE FUNCTION messaging.get_next_update_id() 7 | RETURNS TRIGGER AS $$ 8 | DECLARE 9 | next_id BIGINT; 10 | BEGIN 11 | PERFORM pg_advisory_xact_lock(hashtext(NEW.chat_id::text)); 12 | 13 | INSERT INTO messaging.chat_sequence (chat_id, last_update_id) 14 | VALUES (NEW.chat_id, 0) 15 | ON CONFLICT (chat_id) DO NOTHING; 16 | 17 | UPDATE messaging.chat_sequence 18 | SET last_update_id = last_update_id + 1 19 | WHERE chat_id = NEW.chat_id 20 | RETURNING last_update_id INTO next_id; 21 | 22 | NEW.update_id := next_id; 23 | RETURN NEW; 24 | END; 25 | $$ LANGUAGE plpgsql; 26 | 27 | CREATE TRIGGER set_update_id_trigger 28 | BEFORE INSERT ON messaging.update 29 | FOR EACH ROW 30 | WHEN (NEW.update_id IS NULL OR NEW.update_id = 0) 31 | EXECUTE FUNCTION messaging.get_next_update_id(); 32 | -------------------------------------------------------------------------------- /nginx/static/final_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chakchat/chakchat-backend/eb082a286ca829845da7b25c34b1cb62a7225dbb/nginx/static/final_icon.ico -------------------------------------------------------------------------------- /nginx/static/fonts/Jost-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chakchat/chakchat-backend/eb082a286ca829845da7b25c34b1cb62a7225dbb/nginx/static/fonts/Jost-VariableFont_wght.ttf -------------------------------------------------------------------------------- /nginx/static/fonts/RammettoOne-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chakchat/chakchat-backend/eb082a286ca829845da7b25c34b1cb62a7225dbb/nginx/static/fonts/RammettoOne-Regular.ttf -------------------------------------------------------------------------------- /nginx/static/images/faq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chakchat/chakchat-backend/eb082a286ca829845da7b25c34b1cb62a7225dbb/nginx/static/images/faq.png -------------------------------------------------------------------------------- /nginx/static/images/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chakchat/chakchat-backend/eb082a286ca829845da7b25c34b1cb62a7225dbb/nginx/static/images/github.png -------------------------------------------------------------------------------- /nginx/static/images/tg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chakchat/chakchat-backend/eb082a286ca829845da7b25c34b1cb62a7225dbb/nginx/static/images/tg.png -------------------------------------------------------------------------------- /nginx/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Мой сайт 8 | 9 | 10 |
11 |

Chak

12 |

Chat

13 |
14 | 15 | 16 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /nginx/static/style.css: -------------------------------------------------------------------------------- 1 | /* style.css */ 2 | @font-face { 3 | font-family: 'rammetto'; 4 | src: url('fonts/RammettoOne-Regular.ttf') format('truetype'); 5 | font-weight: normal; 6 | font-style: normal; 7 | } 8 | 9 | @font-face { 10 | font-family: 'jost'; 11 | src: url('fonts/Jost-VariableFont_wght.ttf') format('truetype'); 12 | font-weight: normal; 13 | font-style: normal; 14 | } 15 | 16 | body { 17 | font-family: 'rammetto', Arial; 18 | font-size: 60px; 19 | text-align: center; 20 | background: radial-gradient(circle, #FFBF00, #FF6200); 21 | margin: 0; 22 | padding: 20px; 23 | min-height: 100vh; 24 | position: relative; 25 | } 26 | 27 | .content { 28 | display: flex; 29 | flex-direction: column; 30 | align-items: center; 31 | justify-content: center; 32 | height: 100%; 33 | } 34 | 35 | h1 { 36 | margin-top: 10px; 37 | margin-bottom: -60px; 38 | } 39 | 40 | .links-row { 41 | display: flex; 42 | justify-content: center; 43 | gap: 80px; /* Расстояние между ссылками */ 44 | margin-top: 250px; 45 | flex-wrap: wrap; 46 | } 47 | 48 | .link-item { 49 | display: flex; 50 | flex-direction: column; 51 | align-items: center; 52 | text-decoration: none; 53 | color: #333; 54 | transition: transform 0.3s; 55 | } 56 | 57 | .link-item:hover { 58 | transform: scale(1.05); 59 | } 60 | 61 | .link-icon { 62 | width: 80px; 63 | height: 80px; 64 | border-radius: 15px; 65 | object-fit: cover; 66 | margin-bottom: 10px; 67 | } 68 | 69 | .link-name { 70 | font-family: 'jost', Arial; 71 | font-size: 18px; 72 | font-weight: bold; 73 | } 74 | 75 | #myButton { 76 | background: blue; 77 | color: white; 78 | margin-top: 20px; 79 | } -------------------------------------------------------------------------------- /notification-service/config.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chakchat/chakchat-backend/eb082a286ca829845da7b25c34b1cb62a7225dbb/notification-service/config.yml -------------------------------------------------------------------------------- /notification-service/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/chakchat/chakchat-backend/notification-service 2 | 3 | go 1.24.0 4 | 5 | require github.com/gofrs/uuid v4.4.0+incompatible 6 | 7 | require ( 8 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 9 | github.com/fsnotify/fsnotify v1.8.0 // indirect 10 | github.com/go-logr/logr v1.4.2 // indirect 11 | github.com/go-logr/stdr v1.2.2 // indirect 12 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 13 | github.com/golang-jwt/jwt/v4 v4.4.1 // indirect 14 | github.com/google/uuid v1.6.0 // indirect 15 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect 16 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 17 | github.com/sagikazarmark/locafero v0.7.0 // indirect 18 | github.com/sourcegraph/conc v0.3.0 // indirect 19 | github.com/spf13/afero v1.12.0 // indirect 20 | github.com/spf13/cast v1.7.1 // indirect 21 | github.com/spf13/pflag v1.0.6 // indirect 22 | github.com/subosito/gotenv v1.6.0 // indirect 23 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 24 | go.opentelemetry.io/otel v1.35.0 // indirect 25 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect 26 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 27 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 28 | go.opentelemetry.io/proto/otlp v1.5.0 // indirect 29 | go.uber.org/atomic v1.9.0 // indirect 30 | go.uber.org/multierr v1.9.0 // indirect 31 | google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect 32 | gopkg.in/yaml.v3 v3.0.1 // indirect 33 | ) 34 | 35 | require ( 36 | github.com/sideshow/apns2 v0.25.0 37 | github.com/spf13/viper v1.20.1 38 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 39 | go.opentelemetry.io/otel/sdk v1.35.0 40 | golang.org/x/net v0.35.0 // indirect 41 | golang.org/x/sys v0.30.0 // indirect 42 | golang.org/x/text v0.22.0 // indirect 43 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect 44 | google.golang.org/grpc v1.72.0 // indirect 45 | google.golang.org/protobuf v1.36.6 // indirect 46 | ) 47 | -------------------------------------------------------------------------------- /notification-service/internal/grpc_service/grpc_service.go: -------------------------------------------------------------------------------- 1 | package grpc_service 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/chakchat/chakchat-backend/notification-service/internal/identity" 9 | "github.com/chakchat/chakchat-backend/notification-service/internal/user" 10 | "github.com/gofrs/uuid" 11 | ) 12 | 13 | type GRPCClients struct { 14 | userService user.UserServiceClient 15 | identityService identity.IdentityServiceClient 16 | } 17 | 18 | func NewGrpcClients(userService user.UserServiceClient, identityService identity.IdentityServiceClient) *GRPCClients { 19 | return &GRPCClients{ 20 | userService: userService, 21 | identityService: identityService, 22 | } 23 | } 24 | 25 | func (c *GRPCClients) GetChatType() (string, error) { 26 | return "", nil 27 | } 28 | func (c *GRPCClients) GetGroupName() (string, error) { 29 | return "", nil 30 | } 31 | func (c *GRPCClients) GetName(ctx context.Context, userId uuid.UUID) (*string, error) { 32 | resp, err := c.userService.GetName(ctx, &user.GetNameRequest{ 33 | UserId: userId.String(), 34 | }) 35 | 36 | if err != nil { 37 | return nil, fmt.Errorf("get name gRPC call failed: %s", err) 38 | } 39 | 40 | switch resp.Status { 41 | case user.UserResponseStatus_SUCCESS: 42 | return resp.Name, nil 43 | case user.UserResponseStatus_NOT_FOUND: 44 | return nil, errors.New("user not found") 45 | case user.UserResponseStatus_FAILED: 46 | return nil, errors.New("unknown gRPC GetName() error") 47 | } 48 | 49 | return resp.Name, nil 50 | } 51 | 52 | func (c *GRPCClients) GetDeviceToken(ctx context.Context, userId uuid.UUID) (*string, error) { 53 | resp, err := c.identityService.GetDeviceTokens(ctx, &identity.DeviceTokenRequest{ 54 | UserId: &identity.UUID{Value: userId.String()}, 55 | }) 56 | 57 | if err != nil { 58 | return nil, fmt.Errorf("get device token gRPC call failed: %s", err) 59 | } 60 | 61 | switch resp.Status { 62 | case identity.DeviceTokenResponseStatus_FAILED: 63 | return nil, errors.New("unknown gRPC GetDeviceToken() error") 64 | case identity.DeviceTokenResponseStatus_NOT_FOUND: 65 | return nil, nil 66 | case identity.DeviceTokenResponseStatus_SUCCESS: 67 | return resp.DeviceToken, nil 68 | } 69 | return resp.DeviceToken, nil 70 | } 71 | -------------------------------------------------------------------------------- /notification-service/internal/notifier/apns_service.go: -------------------------------------------------------------------------------- 1 | package notifier 2 | 3 | import "github.com/sideshow/apns2" 4 | 5 | type APNSClient interface { 6 | SendNotification(deviceToken, title string) error 7 | } 8 | 9 | type APNsClient struct { 10 | client *apns2.Client 11 | } 12 | 13 | func NewAPNsClient(certPath, keyID, teamID string) *APNSClient { 14 | return nil 15 | } 16 | 17 | func (a *APNsClient) SendNotification(deviceToken *string, title *string) error { 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /notification-service/internal/notifier/kafka_consumer.go: -------------------------------------------------------------------------------- 1 | package notifier 2 | -------------------------------------------------------------------------------- /otel-collector-config.yaml: -------------------------------------------------------------------------------- 1 | receivers: 2 | otlp: 3 | protocols: 4 | grpc: 5 | endpoint: 0.0.0.0:4317 6 | 7 | exporters: 8 | otlp: 9 | endpoint: jaeger:4317 10 | tls: 11 | insecure: true 12 | 13 | service: 14 | pipelines: 15 | traces: 16 | receivers: [otlp] 17 | processors: [] 18 | exporters: [otlp] 19 | -------------------------------------------------------------------------------- /shared/go/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "strings" 7 | 8 | "github.com/chakchat/chakchat-backend/shared/go/internal/restapi" 9 | "github.com/chakchat/chakchat-backend/shared/go/jwt" 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | const ( 14 | headerAuthorization = "Authorization" 15 | ) 16 | 17 | // It is a copy of jwt claim names 18 | const ( 19 | ClaimName = "name" 20 | ClaimUsername = "username" 21 | ClaimId = "sub" 22 | ) 23 | 24 | type keyClaimsType int 25 | 26 | var keyClaims = keyClaimsType(69) 27 | 28 | type Claims map[string]any 29 | 30 | type JWTConfig struct { 31 | Conf *jwt.Config 32 | Aud string 33 | DefaultHeader string 34 | } 35 | 36 | func NewJWT(conf *JWTConfig) gin.HandlerFunc { 37 | return func(c *gin.Context) { 38 | var headerName string 39 | if conf.DefaultHeader != "" { 40 | headerName = conf.DefaultHeader 41 | } else { 42 | headerName = headerAuthorization 43 | } 44 | 45 | header := c.Request.Header.Get(headerName) 46 | token, ok := strings.CutPrefix(header, "Bearer ") 47 | if !ok { 48 | log.Println("Unautorized because of invalid header") 49 | restapi.SendUnautorized(c) 50 | c.Abort() 51 | return 52 | } 53 | 54 | var claims jwt.Claims 55 | var err error 56 | if conf.Aud != "" { 57 | claims, err = jwt.ParseWithAud(conf.Conf, jwt.Token(token), conf.Aud) 58 | } else { 59 | claims, err = jwt.Parse(conf.Conf, jwt.Token(token)) 60 | } 61 | if err != nil { 62 | log.Printf("Unauthorized: %s", err) 63 | restapi.SendUnautorized(c) 64 | c.Abort() 65 | return 66 | } 67 | 68 | ctx := context.WithValue( 69 | c.Request.Context(), 70 | keyClaims, Claims(claims), 71 | ) 72 | c.Request = c.Request.WithContext(ctx) 73 | 74 | c.Next() 75 | } 76 | } 77 | 78 | func GetClaims(ctx context.Context) Claims { 79 | if val := ctx.Value(keyClaims); val != nil { 80 | return val.(Claims) 81 | } 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /shared/go/idempotency/locker.go: -------------------------------------------------------------------------------- 1 | package idempotency 2 | 3 | import "sync" 4 | 5 | type counterMu struct { 6 | mu sync.Mutex 7 | locked int 8 | } 9 | 10 | type Locker struct { 11 | keyMu map[string]*counterMu 12 | mu sync.Mutex 13 | } 14 | 15 | func NewLocker() *Locker { 16 | return &Locker{ 17 | keyMu: make(map[string]*counterMu), 18 | mu: sync.Mutex{}, 19 | } 20 | } 21 | 22 | func (l *Locker) Lock(key string) { 23 | l.mu.Lock() 24 | keyed, ok := l.keyMu[key] 25 | if !ok { 26 | keyed = new(counterMu) 27 | l.keyMu[key] = keyed 28 | } 29 | keyed.locked++ 30 | l.mu.Unlock() 31 | 32 | keyed.mu.Lock() 33 | } 34 | 35 | func (l *Locker) Unlock(key string) { 36 | l.mu.Lock() 37 | keyed, ok := l.keyMu[key] 38 | if !ok { 39 | // Unknown key 40 | l.mu.Unlock() 41 | return 42 | } 43 | l.mu.Unlock() 44 | 45 | keyed.mu.Unlock() 46 | 47 | l.mu.Lock() 48 | keyed.locked-- 49 | if keyed.locked <= 0 { 50 | delete(l.keyMu, key) 51 | } 52 | l.mu.Unlock() 53 | } 54 | -------------------------------------------------------------------------------- /shared/go/internal/restapi/common.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func SendUnautorized(c *gin.Context) { 10 | c.JSON(http.StatusUnauthorized, ErrorResponse{ 11 | ErrorType: ErrTypeUnautorized, 12 | ErrorMessage: "Unauthorized", 13 | }) 14 | } 15 | 16 | func SendSuccess(c *gin.Context, data any) { 17 | c.JSON(http.StatusOK, SuccessResponse{ 18 | Data: data, 19 | }) 20 | } 21 | 22 | func SendUnprocessableJSON(c *gin.Context) { 23 | c.JSON(http.StatusUnprocessableEntity, ErrorResponse{ 24 | ErrorType: ErrTypeInvalidJson, 25 | ErrorMessage: "Body has invalid JSON", 26 | }) 27 | } 28 | 29 | func SendValidationError(c *gin.Context, errors []ErrorDetail) { 30 | c.JSON(http.StatusBadRequest, ErrorResponse{ 31 | ErrorType: ErrTypeValidationFailed, 32 | ErrorMessage: "Validation has failed", 33 | ErrorDetails: errors, 34 | }) 35 | } 36 | 37 | func SendInternalError(c *gin.Context) { 38 | errResp := ErrorResponse{ 39 | ErrorType: ErrTypeInternal, 40 | ErrorMessage: "Internal Server Error", 41 | } 42 | c.JSON(http.StatusInternalServerError, errResp) 43 | } 44 | -------------------------------------------------------------------------------- /shared/go/internal/restapi/response.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | // Specified by contract.md in /api folder 4 | 5 | const ( 6 | ErrTypeInternal = "internal" 7 | ErrTypeInvalidJson = "invalid_json" 8 | ErrTypeValidationFailed = "validation_failed" 9 | ErrTypeNotFound = "not_found" 10 | ErrTypeIdempotencyKeyMissing = "idempotency_key_missing" 11 | ErrTypeUnautorized = "unauthorized" 12 | ) 13 | 14 | type ErrorDetail struct { 15 | Field string `json:"field,omitempty"` 16 | Message string `json:"message,omitempty"` 17 | } 18 | 19 | type ErrorResponse struct { 20 | ErrorType string `json:"error_type,omitempty"` 21 | ErrorMessage string `json:"error_message,omitempty"` 22 | ErrorDetails []ErrorDetail `json:"error_details,omitempty"` 23 | } 24 | 25 | type SuccessResponse struct { 26 | Data any `json:"data,omitempty"` 27 | } 28 | -------------------------------------------------------------------------------- /shared/go/postgres/sql.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jackc/pgx/v5" 7 | "github.com/jackc/pgx/v5/pgconn" 8 | ) 9 | 10 | type SQLer interface { 11 | TxProvider 12 | ExecQuerier 13 | } 14 | 15 | type TxProvider interface { 16 | BeginTx(context.Context, pgx.TxOptions) (pgx.Tx, error) 17 | Begin(context.Context) (pgx.Tx, error) 18 | } 19 | 20 | type ExecQuerier interface { 21 | Exec(_ context.Context, query string, args ...any) (pgconn.CommandTag, error) 22 | Query(_ context.Context, query string, args ...any) (pgx.Rows, error) 23 | QueryRow(_ context.Context, query string, args ...any) pgx.Row 24 | } 25 | -------------------------------------------------------------------------------- /stubs/sms-service-stub/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | FROM golang:1.23.1 AS builder 3 | 4 | # Set the working directory inside the container 5 | WORKDIR /app 6 | 7 | # Copy go.mod and go.sum files for dependency installation 8 | COPY go.mod go.sum ./ 9 | 10 | # Download and cache dependencies 11 | RUN go mod download 12 | 13 | # Copy the rest of the application code 14 | COPY . . 15 | 16 | # Build the application binary 17 | RUN go build -o main . 18 | 19 | # Stage 2: Run 20 | FROM ubuntu:latest 21 | 22 | # Set the working directory inside the container 23 | WORKDIR /app 24 | 25 | # Copy the compiled binary from the builder stage 26 | COPY --from=builder /app/main . 27 | 28 | # Expose the port your application runs on (optional) 29 | EXPOSE 5023 30 | 31 | # Command to run the application 32 | CMD ["./main"] 33 | -------------------------------------------------------------------------------- /stubs/sms-service-stub/go.mod: -------------------------------------------------------------------------------- 1 | module sms-service-stub 2 | 3 | go 1.23.1 4 | 5 | require github.com/gin-gonic/gin v1.10.0 6 | 7 | require ( 8 | github.com/bytedance/sonic v1.11.6 // indirect 9 | github.com/bytedance/sonic/loader v0.1.1 // indirect 10 | github.com/cloudwego/base64x v0.1.4 // indirect 11 | github.com/cloudwego/iasm v0.2.0 // indirect 12 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 13 | github.com/gin-contrib/sse v0.1.0 // indirect 14 | github.com/go-playground/locales v0.14.1 // indirect 15 | github.com/go-playground/universal-translator v0.18.1 // indirect 16 | github.com/go-playground/validator/v10 v10.20.0 // indirect 17 | github.com/goccy/go-json v0.10.2 // indirect 18 | github.com/json-iterator/go v1.1.12 // indirect 19 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 20 | github.com/leodido/go-urn v1.4.0 // indirect 21 | github.com/mattn/go-isatty v0.0.20 // indirect 22 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 23 | github.com/modern-go/reflect2 v1.0.2 // indirect 24 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 25 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 26 | github.com/ugorji/go/codec v1.2.12 // indirect 27 | golang.org/x/arch v0.8.0 // indirect 28 | golang.org/x/crypto v0.23.0 // indirect 29 | golang.org/x/net v0.25.0 // indirect 30 | golang.org/x/sys v0.20.0 // indirect 31 | golang.org/x/text v0.15.0 // indirect 32 | google.golang.org/protobuf v1.34.1 // indirect 33 | gopkg.in/yaml.v3 v3.0.1 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /stubs/sms-service-stub/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | func main() { 11 | r := gin.New() 12 | 13 | m := sync.Map{} 14 | 15 | r.POST("/", func(c *gin.Context) { 16 | type Req struct { 17 | Phone string `json:"phone"` 18 | Message string `json:"message"` 19 | } 20 | req := new(Req) 21 | if err := c.ShouldBindBodyWithJSON(req); err != nil { 22 | c.String(http.StatusBadRequest, "it is not valid json") 23 | return 24 | } 25 | m.Store(req.Phone, req.Message) 26 | c.Status(http.StatusOK) 27 | }) 28 | 29 | r.GET("/:phone", func(c *gin.Context) { 30 | phone := c.Param("phone") 31 | if code, ok := m.Load(phone); ok { 32 | c.String(http.StatusOK, "%s", code) 33 | } else { 34 | c.String(http.StatusNotFound, "not found") 35 | } 36 | }) 37 | 38 | r.Run(":5023") 39 | } 40 | -------------------------------------------------------------------------------- /stubs/user-service-stub/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | FROM golang:1.23.1 AS builder 3 | 4 | # Set the working directory inside the container 5 | WORKDIR /app 6 | 7 | # Copy go.mod and go.sum files for dependency installation 8 | COPY go.mod go.sum ./ 9 | 10 | # Download and cache dependencies 11 | RUN go mod download 12 | 13 | # Copy the rest of the application code 14 | COPY . . 15 | 16 | # Build the application binary 17 | RUN go build -o main . 18 | 19 | # Stage 2: Run 20 | FROM ubuntu:latest 21 | 22 | # Set the working directory inside the container 23 | WORKDIR /app 24 | 25 | # Copy the compiled binary from the builder stage 26 | COPY --from=builder /app/main . 27 | 28 | # Expose the port your application runs on (optional) 29 | EXPOSE 9090 30 | 31 | # Command to run the application 32 | CMD ["./main"] 33 | -------------------------------------------------------------------------------- /stubs/user-service-stub/go.mod: -------------------------------------------------------------------------------- 1 | module user-service-stub 2 | 3 | go 1.23.1 4 | 5 | require ( 6 | google.golang.org/grpc v1.69.2 7 | google.golang.org/protobuf v1.36.1 8 | ) 9 | 10 | require ( 11 | github.com/google/uuid v1.6.0 12 | golang.org/x/net v0.30.0 // indirect 13 | golang.org/x/sys v0.26.0 // indirect 14 | golang.org/x/text v0.19.0 // indirect 15 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /tests/identity-service/Makefile: -------------------------------------------------------------------------------- 1 | keys-rsa: 2 | mkdir keys 3 | openssl genrsa -out keys/rsa 2048 4 | openssl rsa -in keys/rsa -pubout -out keys/rsa.pub 5 | 6 | keys-sym: 7 | openssl rand -hex 64 > keys/sym 8 | 9 | gen: keys-rsa keys-sym 10 | 11 | test: 12 | docker compose up --build --abort-on-container-exit --exit-code-from test 13 | 14 | gen-grpc: 15 | protoc --go_out="./user-service-stub/userservice" --go-grpc_out="./user-service-stub/userservice" --proto_path="../../api/user-service" user.proto -------------------------------------------------------------------------------- /tests/identity-service/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | test: 3 | build: 4 | context: service-test 5 | dockerfile: Dockerfile 6 | volumes: 7 | - ./keys:/app/keys:ro 8 | 9 | user-service-stub: 10 | build: 11 | context: ../../stubs/user-service-stub 12 | dockerfile: Dockerfile 13 | identity-service: 14 | build: 15 | context: ../../identity-service 16 | dockerfile: Dockerfile 17 | volumes: 18 | - ./identity-service-config.yml:/app/config.yml 19 | - ./keys:/app/keys:ro 20 | depends_on: 21 | - redis 22 | redis: 23 | image: redis:latest 24 | ports: [6379:6379] 25 | environment: 26 | - REDIS_PASSWORD=secret 27 | sms-service-stub: 28 | build: 29 | context: ../../stubs/sms-service-stub 30 | dockerfile: Dockerfile 31 | otel-collector: 32 | image: otel/opentelemetry-collector-contrib:latest -------------------------------------------------------------------------------- /tests/identity-service/identity-service-config.yml: -------------------------------------------------------------------------------- 1 | access_token: 2 | signing_method: HS512 3 | lifetime: 1m # Just for demo 4 | issuer: identity_service 5 | audience: 6 | - client 7 | key_file_path: /app/keys/sym 8 | refresh_token: 9 | signing_method: HS512 10 | lifetime: 3m # Just for demo 11 | issuer: identity_service 12 | audience: 13 | - client 14 | key_file_path: /app/keys/sym 15 | internal_token: 16 | signing_method: RS256 17 | lifetime: 1m # Just for demo 18 | issuer: identity_service 19 | audience: 20 | - identity_service 21 | key_file_path: /app/keys/rsa 22 | invalidated_token_storage: 23 | exp: 4m # A little bit longer than refresh_token.lifetime 24 | userservice: 25 | grpc_addr: user-service-stub:9090 26 | redis: 27 | addr: redis:6379 28 | password: secret 29 | db: 0 30 | signin_meta: 31 | lifetime: 2m 32 | signup_meta: 33 | lifetime: 2m 34 | idempotency: 35 | data_exp: 10m 36 | phone_code: 37 | send_frequency: 1m 38 | sms: 39 | type: stub 40 | stub: 41 | addr: http://sms-service-stub:5023 42 | otlp: 43 | grpc_addr: otel-collector:4317 -------------------------------------------------------------------------------- /tests/identity-service/service-test/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23.1 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod go.sum ./ 6 | 7 | RUN go mod download 8 | 9 | COPY . . 10 | 11 | ENTRYPOINT ["go", "test", "-v", "."] -------------------------------------------------------------------------------- /tests/identity-service/service-test/common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "strconv" 7 | "sync/atomic" 8 | "testing" 9 | 10 | "github.com/google/uuid" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | const ( 15 | ServiceUrl = "http://identity-service:5000" 16 | 17 | HeaderIdemotencyKey = "Idempotency-Key" 18 | ) 19 | 20 | var incNumber atomic.Uint64 21 | 22 | const ( 23 | PhoneExisting = "1" 24 | PhoneErroring = "2" 25 | PhoneNotFound = "0" 26 | ) 27 | 28 | type ErrorDetail struct { 29 | Field string `json:"field,omitempty"` 30 | Message string `json:"message,omitempty"` 31 | } 32 | 33 | type StdResp struct { 34 | ErrorType string `json:"error_type,omitempty"` 35 | ErrorMessage string `json:"error_message,omitempty"` 36 | ErrorDetails []ErrorDetail `json:"error_details,omitempty"` 37 | 38 | Data json.RawMessage `json:"data,omitempty"` 39 | } 40 | 41 | func NewPhone(phoneState string) string { 42 | templ := "7900000000*" 43 | n := incNumber.Add(1) 44 | nStr := strconv.FormatUint(n, 10) 45 | return templ[:10-len(nStr)] + nStr + phoneState 46 | } 47 | 48 | func MatchUserId(phone string) string { 49 | id := uuid.Nil.String() 50 | id = id[:len(id)-11] + phone 51 | return id 52 | } 53 | 54 | func GetBody(t *testing.T, body io.ReadCloser) *StdResp { 55 | defer body.Close() 56 | raw, err := io.ReadAll(body) 57 | require.NoError(t, err) 58 | 59 | // log.Printf("body: \"%s\"", raw) 60 | resp := new(StdResp) 61 | err = json.Unmarshal(raw, resp) 62 | require.NoError(t, err) 63 | 64 | return resp 65 | } 66 | -------------------------------------------------------------------------------- /tests/identity-service/service-test/go.mod: -------------------------------------------------------------------------------- 1 | module test 2 | 3 | go 1.23.1 4 | 5 | require github.com/stretchr/testify v1.10.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/golang-jwt/jwt v3.2.2+incompatible 10 | github.com/google/uuid v1.6.0 11 | github.com/pmezard/go-difflib v1.0.0 // indirect 12 | gopkg.in/yaml.v3 v3.0.1 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /tests/identity-service/service-test/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 4 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 5 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 6 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 10 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 14 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 15 | -------------------------------------------------------------------------------- /tests/identity-service/service-test/signin_send_code_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "test/common" 8 | "testing" 9 | 10 | "github.com/google/uuid" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func Test_SignInSendCode(t *testing.T) { 15 | type TestCase struct { 16 | Name string 17 | Phone string 18 | StatusCode int 19 | ErrorType string 20 | } 21 | cases := []TestCase{ 22 | { 23 | Name: "Success", 24 | Phone: common.NewPhone(common.PhoneExisting), 25 | StatusCode: http.StatusOK, 26 | }, 27 | { 28 | Name: "UserNotFound", 29 | Phone: common.NewPhone(common.PhoneNotFound), 30 | StatusCode: http.StatusNotFound, 31 | ErrorType: "user_not_found", 32 | }, 33 | { 34 | Name: "InvalidPhone", 35 | Phone: "it-is-not-phone-number", 36 | StatusCode: http.StatusBadRequest, 37 | ErrorType: "validation_failed", 38 | }, 39 | } 40 | 41 | for _, test := range cases { 42 | t.Run(test.Name, func(t *testing.T) { 43 | resp := doSignInSendCodeRequest(t, test.Phone, uuid.New()) 44 | body := common.GetBody(t, resp.Body) 45 | 46 | require.Equal(t, test.StatusCode, resp.StatusCode) 47 | require.Equal(t, test.ErrorType, body.ErrorType) 48 | }) 49 | } 50 | } 51 | 52 | func Test_SignInSendCode_FreqExceeded(t *testing.T) { 53 | phone := common.NewPhone(common.PhoneExisting) 54 | 55 | resp1 := doSignInSendCodeRequest(t, phone, uuid.New()) 56 | require.Equal(t, http.StatusOK, resp1.StatusCode) 57 | 58 | resp2 := doSignInSendCodeRequest(t, phone, uuid.New()) 59 | require.Equal(t, http.StatusBadRequest, resp2.StatusCode) 60 | 61 | body := common.GetBody(t, resp2.Body) 62 | require.Equal(t, "send_code_freq_exceeded", body.ErrorType) 63 | } 64 | 65 | func doSignInSendCodeRequest(t *testing.T, phone string, idempotencyKey uuid.UUID) *http.Response { 66 | type Req struct { 67 | Phone string `json:"phone"` 68 | } 69 | reqBody, _ := json.Marshal(Req{ 70 | Phone: phone, 71 | }) 72 | 73 | req, err := http.NewRequest(http.MethodPost, common.ServiceUrl+"/v1.0/signin/send-phone-code", bytes.NewReader(reqBody)) 74 | require.NoError(t, err) 75 | req.Header.Add(common.HeaderIdemotencyKey, idempotencyKey.String()) 76 | 77 | resp, err := http.DefaultClient.Do(req) 78 | require.NoError(t, err) 79 | return resp 80 | } 81 | -------------------------------------------------------------------------------- /tests/identity-service/service-test/signout_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "test/common" 8 | "testing" 9 | "time" 10 | 11 | "github.com/golang-jwt/jwt" 12 | "github.com/google/uuid" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func Test_SignsOut(t *testing.T) { 17 | symKey := getKey(t, "/app/keys/sym") 18 | 19 | refreshJWT := jwt.NewWithClaims(jwt.SigningMethodHS512, jwt.MapClaims{ 20 | "typ": "refresh", 21 | "sub": uuid.NewString(), 22 | "jti": uuid.NewString(), 23 | "iat": time.Now().Add(-1 * time.Minute).Unix(), 24 | "exp": time.Now().Add(10 * time.Minute).Unix(), 25 | "iss": "identity_service", // watch identity-service-config.yml 26 | "aud": []string{"client"}, 27 | }) 28 | refreshToken, err := refreshJWT.SignedString(symKey) 29 | require.NoError(t, err) 30 | 31 | signOutResp := doSignOutRequest(t, refreshToken) 32 | require.Equal(t, http.StatusOK, signOutResp.StatusCode) 33 | } 34 | 35 | func doSignOutRequest(t *testing.T, refreshToken string) *http.Response { 36 | type Request struct { 37 | RefreshJWT string `json:"refresh_token"` 38 | } 39 | reqBody, _ := json.Marshal(Request{ 40 | RefreshJWT: refreshToken, 41 | }) 42 | req, err := http.NewRequest(http.MethodPut, common.ServiceUrl+"/v1.0/sign-out", bytes.NewReader(reqBody)) 43 | require.NoError(t, err) 44 | req.Header.Add(common.HeaderIdemotencyKey, uuid.NewString()) 45 | 46 | resp, err := http.DefaultClient.Do(req) 47 | require.NoError(t, err) 48 | return resp 49 | } 50 | -------------------------------------------------------------------------------- /tests/identity-service/service-test/signup_send_code_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "test/common" 8 | "testing" 9 | 10 | "github.com/google/uuid" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func Test_SignUpSendCode(t *testing.T) { 15 | type TestCase struct { 16 | Name string 17 | Phone string 18 | StatusCode int 19 | ErrorType string 20 | } 21 | cases := []TestCase{ 22 | { 23 | Name: "Success", 24 | Phone: common.NewPhone(common.PhoneNotFound), 25 | StatusCode: http.StatusOK, 26 | }, 27 | { 28 | Name: "UserExists", 29 | Phone: common.NewPhone(common.PhoneExisting), 30 | StatusCode: http.StatusBadRequest, 31 | ErrorType: "user_already_exists", 32 | }, 33 | { 34 | Name: "InvalidPhone", 35 | Phone: "it-is-not-phone-number", 36 | StatusCode: http.StatusBadRequest, 37 | ErrorType: "validation_failed", 38 | }, 39 | } 40 | 41 | for _, test := range cases { 42 | t.Run(test.Name, func(t *testing.T) { 43 | resp := doSignUpSendCodeRequest(t, test.Phone, uuid.New()) 44 | body := common.GetBody(t, resp.Body) 45 | 46 | require.Equal(t, test.StatusCode, resp.StatusCode) 47 | require.Equal(t, test.ErrorType, body.ErrorType) 48 | }) 49 | } 50 | } 51 | 52 | func Test_SignUpSendCode_FreqExceeded(t *testing.T) { 53 | phone := common.NewPhone(common.PhoneNotFound) 54 | 55 | resp1 := doSignUpSendCodeRequest(t, phone, uuid.New()) 56 | require.Equal(t, http.StatusOK, resp1.StatusCode) 57 | 58 | resp2 := doSignUpSendCodeRequest(t, phone, uuid.New()) 59 | require.Equal(t, http.StatusBadRequest, resp2.StatusCode) 60 | 61 | body := common.GetBody(t, resp2.Body) 62 | require.Equal(t, "send_code_freq_exceeded", body.ErrorType) 63 | } 64 | 65 | func doSignUpSendCodeRequest(t *testing.T, phone string, idempotencyKey uuid.UUID) *http.Response { 66 | type Req struct { 67 | Phone string `json:"phone"` 68 | } 69 | reqBody, _ := json.Marshal(Req{ 70 | Phone: phone, 71 | }) 72 | 73 | req, err := http.NewRequest(http.MethodPost, common.ServiceUrl+"/v1.0/signup/send-phone-code", bytes.NewReader(reqBody)) 74 | require.NoError(t, err) 75 | req.Header.Add(common.HeaderIdemotencyKey, idempotencyKey.String()) 76 | 77 | resp, err := http.DefaultClient.Do(req) 78 | require.NoError(t, err) 79 | return resp 80 | } 81 | -------------------------------------------------------------------------------- /tests/user-service/Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | docker compose up --build --abort-on-container-exit --exit-code-from test 3 | 4 | gen-grpc: 5 | protoc --go_out="./service-test/userservice" --go-grpc_out="./service-test/userservice" --proto_path="../../api/user-service" user.proto 6 | 7 | keys-rsa: 8 | mkdir keys 9 | openssl genrsa -out keys/rsa 2048 10 | openssl rsa -in keys/rsa -pubout -out keys/rsa.pub 11 | -------------------------------------------------------------------------------- /tests/user-service/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | test: 3 | build: 4 | context: ./service-test 5 | environment: 6 | - USER_SERVICE_ADDR=user-service:50051 # TODO: Pass your user-service address here 7 | - USER_SERVICE_HTTP_ADDR=user-service:5004 8 | depends_on: 9 | - user-service 10 | volumes: 11 | - ./keys:/app/keys:ro 12 | user-service: 13 | build: 14 | context: ../../user-service # It is built using ../../user-service/Dockerfile 15 | environment: 16 | - DB_DSN=postgres://user:secret@postgres:5432/userdb?sslmode=disable 17 | depends_on: 18 | - postgres 19 | volumes: 20 | - ./user-service-config.yml:/app/config.yml 21 | - ./keys:/app/keys:ro 22 | ports: 23 | - "5004:5004" 24 | postgres: 25 | image: postgres:15.3 26 | environment: 27 | POSTGRES_USER: user 28 | POSTGRES_PASSWORD: secret 29 | POSTGRES_DB: userdb 30 | ports: 31 | - "5432:5432" -------------------------------------------------------------------------------- /tests/user-service/service-test/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod go.sum ./ 6 | 7 | RUN go mod download 8 | 9 | COPY . . 10 | 11 | ENTRYPOINT ["go", "test", "-v", "."] -------------------------------------------------------------------------------- /tests/user-service/service-test/go.mod: -------------------------------------------------------------------------------- 1 | module test 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/chakchat/chakchat-backend/shared/go v0.0.0-20250223145144-ba994bff66e7 7 | github.com/google/uuid v1.6.0 8 | github.com/stretchr/testify v1.10.0 9 | google.golang.org/grpc v1.70.0 10 | google.golang.org/protobuf v1.36.5 11 | ) 12 | 13 | require ( 14 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 15 | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 16 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 17 | golang.org/x/net v0.32.0 // indirect 18 | golang.org/x/sys v0.28.0 // indirect 19 | golang.org/x/text v0.21.0 // indirect 20 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect 21 | gopkg.in/yaml.v3 v3.0.1 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /tests/user-service/user-service-config.yml: -------------------------------------------------------------------------------- 1 | server: 2 | grpc-port: "50051" 3 | 4 | db: 5 | dsn: "postgres://user:secret@postgres:5432/userdb?sslmode=disable" 6 | 7 | jwt: 8 | signing_method: RS256 9 | lifetime: 3m 10 | issuer: user_service 11 | audience: 12 | - user_service 13 | key_file_path: /app/keys/rsa.pub 14 | -------------------------------------------------------------------------------- /user-service/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | FROM golang:1.24 AS builder 3 | 4 | # Set the working directory inside the container 5 | WORKDIR /app 6 | 7 | # Copy go.mod and go.sum files for dependency installation 8 | COPY go.mod go.sum ./ 9 | 10 | # Download and cache dependencies 11 | RUN go mod download 12 | 13 | # Copy the rest of the application code 14 | COPY . . 15 | 16 | # Build the application binary 17 | RUN go build -o main . 18 | 19 | # Stage 2: Run 20 | FROM ubuntu:latest 21 | 22 | # Set the working directory inside the container 23 | WORKDIR /app 24 | 25 | # Copy the compiled binary from the builder stage 26 | COPY --from=builder /app/main . 27 | 28 | # Expose the port your application runs on (optional) 29 | EXPOSE 5004 30 | 31 | # Command to run the application 32 | CMD ["./main"] 33 | -------------------------------------------------------------------------------- /user-service/Makefile: -------------------------------------------------------------------------------- 1 | keys-rsa: 2 | mkdir keys 3 | openssl genrsa -out keys/rsa 2048 4 | openssl rsa -in keys/rsa -pubout -out keys/rsa.pub 5 | 6 | keys-sym: 7 | openssl rand -hex 64 > keys/sym 8 | 9 | gen: keys-rsa keys-sym 10 | 11 | gen-grpc: 12 | protoc --go_out="./internal/grpcservice" --go-grpc_out="./internal/grpcservice" --proto_path="../api/user-service" user.proto -------------------------------------------------------------------------------- /user-service/config.yml: -------------------------------------------------------------------------------- 1 | server: 2 | grpc-port: "50051" 3 | 4 | jwt: 5 | signing_method: RS256 6 | lifetime: 3m 7 | issuer: identity_service 8 | audience: 9 | - user_service 10 | key_file_path: /app/keys/rsa.pub 11 | 12 | otlp: 13 | grpc_addr: otel-collector:4317 14 | 15 | filestorage: 16 | grpc_addr: file-storage-service:9090 -------------------------------------------------------------------------------- /user-service/internal/handlers/check_user.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | "github.com/chakchat/chakchat-backend/user-service/internal/restapi" 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | type CheckUserServer interface { 12 | CheckUserByUsername(ctx context.Context, username string) (*bool, error) 13 | } 14 | 15 | func CheckUserByUsername(service CheckUserServer) gin.HandlerFunc { 16 | return func(c *gin.Context) { 17 | log.Println("Start checking") 18 | username := c.Param("username") 19 | log.Println("Got parametr") 20 | 21 | res, err := service.CheckUserByUsername(c.Request.Context(), username) 22 | if err != nil { 23 | c.Error(err) 24 | restapi.SendInternalError(c) 25 | return 26 | } 27 | log.Println("Return") 28 | 29 | restapi.SendSuccess(c, gin.H{"user_exists": res}) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /user-service/internal/handlers/teapot.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func AmITeapot() gin.HandlerFunc { 10 | return func(c *gin.Context) { 11 | c.JSON(http.StatusTeapot, gin.H{ 12 | "message": "I'm a teapot", 13 | }) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /user-service/internal/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | type Restriction string 10 | 11 | const ( 12 | RestrictionAll = "everyone" 13 | RestrictionSpecified = "specified" 14 | RestrictionNone = "only_me" 15 | ) 16 | 17 | type User struct { 18 | ID uuid.UUID 19 | Name string 20 | Username string 21 | Phone string 22 | DateOfBirth *time.Time 23 | PhotoURL string 24 | CreatedAt int64 25 | 26 | DateOfBirthVisibility Restriction `gorm:"default:everyone"` 27 | PhoneVisibility Restriction `gorm:"default:everyone"` 28 | } 29 | 30 | type FieldRestriction struct { 31 | OwnerID uuid.UUID `gorm:"primaryKey"` 32 | FieldName string 33 | SpecifiedUsers []uuid.UUID 34 | } 35 | 36 | type FieldRestrictionUser struct { 37 | UserID uuid.UUID `gorm:"type:uuid"` 38 | FieldRestrictionId uuid.UUID `gorm:"type:uuid"` 39 | FieldRestriction *FieldRestriction `gorm:"constraint:OnDelete:CASCADE"` 40 | } 41 | -------------------------------------------------------------------------------- /user-service/internal/restapi/common.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func SendSuccess(c *gin.Context, data any) { 10 | c.JSON(http.StatusOK, SuccessResponse{ 11 | Data: data, 12 | }) 13 | } 14 | 15 | func SendUnprocessableJSON(c *gin.Context) { 16 | c.JSON(http.StatusUnprocessableEntity, ErrorResponse{ 17 | ErrorType: ErrTypeInvalidJson, 18 | ErrorMessage: "Body has invalid JSON", 19 | }) 20 | } 21 | 22 | func SendValidationError(c *gin.Context, errors []ErrorDetail) { 23 | c.JSON(http.StatusBadRequest, ErrorResponse{ 24 | ErrorType: ErrTypeValidationFailed, 25 | ErrorMessage: "Validation has failed", 26 | ErrorDetails: errors, 27 | }) 28 | } 29 | 30 | func SendUnauthorizedError(c *gin.Context, errors []ErrorDetail) { 31 | c.JSON(http.StatusUnauthorized, ErrorResponse{ 32 | ErrorType: ErrTypeUnautorized, 33 | ErrorMessage: "Failed JWT token authentication", 34 | ErrorDetails: errors, 35 | }) 36 | } 37 | 38 | func SendInternalError(c *gin.Context) { 39 | c.JSON(http.StatusInternalServerError, ErrorResponse{ 40 | ErrorType: ErrTypeInternal, 41 | ErrorMessage: "Internal Server Error", 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /user-service/internal/restapi/response.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | const ( 4 | ErrTypeNotFound = "not_found" 5 | ErrTypeUnautorized = "unauthorized" 6 | ErrTypeInternal = "internal" 7 | ErrTypeInvalidJson = "invalid_json" 8 | ErrTypeValidationFailed = "validation_failed" 9 | ErrTypeBadRequest = "invalid_input" 10 | ) 11 | 12 | type ErrorDetail struct { 13 | Field string `json:"field,omitempty"` 14 | Message string `json:"message,omitempty"` 15 | } 16 | 17 | type ErrorResponse struct { 18 | ErrorType string `json:"error_type,omitempty"` 19 | ErrorMessage string `json:"error_message,omitempty"` 20 | ErrorDetails []ErrorDetail `json:"error_details,omitempty"` 21 | } 22 | 23 | type SuccessResponse struct { 24 | Data any `json:"data,omitempty"` 25 | } 26 | -------------------------------------------------------------------------------- /user-service/internal/services/get_restrictions.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | type GetRestrictionsRepository interface { 10 | GetAllowedUserIDs(ctx context.Context, id uuid.UUID, field string) ([]uuid.UUID, error) 11 | } 12 | 13 | type GetRestrictionService struct { 14 | restrictionRepo GetRestrictionsRepository 15 | } 16 | 17 | func NewGetRestrictionService(restrictionRepo GetRestrictionsRepository) *GetRestrictionService { 18 | return &GetRestrictionService{ 19 | restrictionRepo: restrictionRepo, 20 | } 21 | } 22 | 23 | func (g *GetRestrictionService) GetAllowedUserIDs(ctx context.Context, id uuid.UUID, field string) ([]uuid.UUID, error) { 24 | restr, err := g.restrictionRepo.GetAllowedUserIDs(ctx, id, field) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return restr, nil 30 | } 31 | -------------------------------------------------------------------------------- /user-service/internal/services/update_restrictions.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/chakchat/chakchat-backend/user-service/internal/storage" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | type UpdateRestrictionsRepo interface { 12 | UpdateRestrictions(ctx context.Context, id uuid.UUID, restr storage.FieldRestrictions) (*storage.FieldRestrictions, error) 13 | } 14 | 15 | type UpdateRestrictionsService struct { 16 | repo UpdateRestrictionsRepo 17 | } 18 | 19 | func NewUpdateRestrService(repo UpdateRestrictionsRepo) *UpdateRestrictionsService { 20 | return &UpdateRestrictionsService{ 21 | repo: repo, 22 | } 23 | } 24 | 25 | func (s *UpdateRestrictionsService) UpdateRestrictions(ctx context.Context, id uuid.UUID, restr storage.FieldRestrictions) (*storage.FieldRestrictions, error) { 26 | updatedRestr, err := s.repo.UpdateRestrictions(ctx, id, restr) 27 | if err != nil { 28 | if errors.Is(err, storage.ErrNotFound) { 29 | return nil, ErrValidationError 30 | } 31 | return nil, err 32 | } 33 | return updatedRestr, nil 34 | } 35 | -------------------------------------------------------------------------------- /user-service/internal/services/update_user.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/chakchat/chakchat-backend/user-service/internal/models" 8 | "github.com/chakchat/chakchat-backend/user-service/internal/storage" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | var ErrValidationError = errors.New("invalid input") 13 | 14 | type UpdateUserRepository interface { 15 | UpdateUser(ctx context.Context, user *models.User, req *storage.UpdateUserRequest) (*models.User, error) 16 | DeleteUser(ctx context.Context, id uuid.UUID) error 17 | } 18 | 19 | type UpdateUserService struct { 20 | updateRepo UpdateUserRepository 21 | } 22 | 23 | func NewUpdateUserService(updateRepo UpdateUserRepository) *UpdateUserService { 24 | return &UpdateUserService{ 25 | updateRepo: updateRepo, 26 | } 27 | } 28 | 29 | func (u *UpdateUserService) UpdateUser(ctx context.Context, user *models.User, req *storage.UpdateUserRequest) (*models.User, error) { 30 | updatedUser, err := u.updateRepo.UpdateUser(ctx, user, req) 31 | if err != nil { 32 | if errors.Is(err, storage.ErrNotFound) { 33 | return nil, ErrValidationError 34 | } 35 | if errors.Is(err, storage.ErrAlreadyExists) { 36 | return nil, ErrValidationError 37 | } 38 | return nil, err 39 | } 40 | return updatedUser, nil 41 | } 42 | 43 | func (u *UpdateUserService) DeleteUser(ctx context.Context, id uuid.UUID) error { 44 | err := u.updateRepo.DeleteUser(ctx, id) 45 | if err != nil { 46 | if errors.Is(err, storage.ErrNotFound) { 47 | return ErrNotFound 48 | } 49 | return err 50 | } 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /user-service/internal/services/user_service.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/chakchat/chakchat-backend/user-service/internal/models" 8 | "github.com/chakchat/chakchat-backend/user-service/internal/storage" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | var ErrNotFound = errors.New("not found") 13 | var ErrAlreadyExists = errors.New("already exists") 14 | 15 | type UserRepository interface { 16 | // Returns NotFound error if not found. 17 | GetUserByPhone(ctx context.Context, phone string) (*models.User, error) 18 | CreateUser(ctx context.Context, user *models.User) (*models.User, error) 19 | GetUserById(ctx context.Context, id uuid.UUID) (*models.User, error) 20 | } 21 | 22 | type UserService struct { 23 | userRepo UserRepository 24 | } 25 | 26 | func NewGetUserService(userHandler UserRepository) *UserService { 27 | return &UserService{ 28 | userRepo: userHandler, 29 | } 30 | } 31 | 32 | func (s *UserService) GetUser(ctx context.Context, phone string) (*models.User, error) { 33 | user, err := s.userRepo.GetUserByPhone(ctx, phone) 34 | if err != nil { 35 | if errors.Is(err, storage.ErrNotFound) { 36 | return nil, ErrNotFound 37 | } 38 | return nil, err 39 | } 40 | 41 | return user, nil 42 | } 43 | 44 | func (s *UserService) CreateUser(ctx context.Context, user *models.User) (*models.User, error) { 45 | newUser, err := s.userRepo.CreateUser(ctx, user) 46 | if errors.Is(err, storage.ErrAlreadyExists) { 47 | return nil, ErrAlreadyExists 48 | } 49 | return newUser, nil 50 | } 51 | 52 | func (s *UserService) GetName(ctx context.Context, id uuid.UUID) (*string, error) { 53 | user, err := s.userRepo.GetUserById(ctx, id) 54 | if err != nil { 55 | if errors.Is(err, storage.ErrNotFound) { 56 | return nil, ErrNotFound 57 | } 58 | return nil, err 59 | } 60 | return &user.Name, nil 61 | } 62 | -------------------------------------------------------------------------------- /user-service/migrations/V001__user.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA "users"; 2 | 3 | CREATE TYPE users.user_field AS ENUM ('date_of_birth', 'phone'); 4 | 5 | CREATE TYPE users.field_visibility AS ENUM ('everyone', 'specified', 'only_me'); 6 | 7 | CREATE TABLE IF NOT EXISTS users.user ( 8 | id UUID PRIMARY KEY, 9 | name TEXT NOT NULL, 10 | username TEXT NOT NULL, 11 | phone TEXT, 12 | date_of_birth TIMESTAMP WITH TIME ZONE, 13 | photo_url TEXT, 14 | created_at BIGINT NOT NULL, 15 | 16 | date_of_birth_visibility users.field_visibility DEFAULT 'everyone', 17 | phone_visibility users.field_visibility DEFAULT 'everyone' 18 | ); 19 | 20 | CREATE TABLE IF NOT EXISTS users.field_restrictions ( 21 | owner_user_id UUID REFERENCES users.user (id) ON DELETE CASCADE, 22 | field_name users.user_field NOT NULL, 23 | permitted_user_id UUID REFERENCES users.user (id) ON DELETE CASCADE, 24 | PRIMARY KEY ( 25 | owner_user_id, 26 | field_name, 27 | permitted_user_id 28 | ) 29 | ); --------------------------------------------------------------------------------