├── .github └── workflows │ ├── deploy.yml │ └── ktlint.yml ├── .gitignore ├── Dockerfile ├── README.md ├── build.gradle.kts ├── docker └── docker-compose.yml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── graphql.config.yml ├── settings.gradle.kts └── src ├── main ├── kotlin │ └── com │ │ └── doongjun │ │ └── commitmon │ │ ├── CommitmonApplication.kt │ │ ├── animation │ │ └── AdventureController.kt │ │ ├── api │ │ ├── AccountController.kt │ │ ├── CommitmonController.kt │ │ ├── MeController.kt │ │ └── data │ │ │ ├── ChangeCommitmonRequest.kt │ │ │ ├── CommitmonResponse.kt │ │ │ ├── MeDetailResponse.kt │ │ │ ├── MeResponse.kt │ │ │ ├── RedirectDestination.kt │ │ │ ├── RefreshTokenRequest.kt │ │ │ └── RefreshTokenResponse.kt │ │ ├── app │ │ ├── AccountFacade.kt │ │ ├── AdventureFacade.kt │ │ ├── GithubOAuth2Service.kt │ │ ├── GithubService.kt │ │ ├── Theme.kt │ │ ├── UserFetchType.kt │ │ ├── UserService.kt │ │ └── data │ │ │ ├── AuthDto.kt │ │ │ ├── CreateUserDto.kt │ │ │ ├── GetSimpleUserDto.kt │ │ │ ├── GetUserCommitInfo.kt │ │ │ ├── GetUserDto.kt │ │ │ ├── GetUserFollowInfoDto.kt │ │ │ └── UpdateUserDto.kt │ │ ├── config │ │ ├── AsyncConfig.kt │ │ ├── CacheConfig.kt │ │ ├── JpaConfig.kt │ │ ├── RedisConfig.kt │ │ ├── SwaggerConfig.kt │ │ ├── ThymeleafConfig.kt │ │ ├── WebClientConfig.kt │ │ └── security │ │ │ ├── JwtFilter.kt │ │ │ ├── RefreshToken.kt │ │ │ ├── RefreshTokenRepository.kt │ │ │ ├── RestAccessDeniedHandler.kt │ │ │ ├── RestAuthenticationEntryPoint.kt │ │ │ ├── SecurityConfig.kt │ │ │ └── TokenProvider.kt │ │ ├── core │ │ ├── AdventureGenerator.kt │ │ ├── BaseEntity.kt │ │ ├── NoArgs.kt │ │ └── error │ │ │ ├── AnimationExceptionHandler.kt │ │ │ ├── GlobalExceptionHandler.kt │ │ │ └── response │ │ │ ├── ErrorCode.kt │ │ │ └── ErrorResponse.kt │ │ ├── domain │ │ ├── Commitmon.kt │ │ ├── CommitmonLevel.kt │ │ ├── Follow.kt │ │ ├── User.kt │ │ └── UserRepository.kt │ │ ├── event │ │ ├── ApplicationEventListener.kt │ │ └── UpdateUserInfo.kt │ │ ├── extension │ │ ├── EnumExtension.kt │ │ ├── SecurityExtension.kt │ │ └── StringExtension.kt │ │ └── infra │ │ ├── GithubGraphqlApi.kt │ │ ├── GithubOAuth2Api.kt │ │ ├── GithubRestApi.kt │ │ └── data │ │ ├── GraphqlRequest.kt │ │ ├── GraphqlResponse.kt │ │ ├── OAuthLoginResponse.kt │ │ ├── UserCommitSearchResponse.kt │ │ ├── UserFollowInfoResponse.kt │ │ ├── UserFollowInfoVariables.kt │ │ ├── UserFollowersResponse.kt │ │ ├── UserFollowingResponse.kt │ │ └── UserInfoResponse.kt └── resources │ ├── application.yml │ ├── graphql │ ├── user-follow-info-query.graphql │ ├── user-followers-query.graphql │ └── user-following-query.graphql │ ├── static │ └── theme │ │ ├── desert.svg │ │ ├── grassland.svg │ │ └── transparent.svg │ └── templates │ ├── adventure.svg │ ├── asset │ ├── agumon.svg │ ├── angemon.svg │ ├── angewomon.svg │ ├── atlurkabuterimon.svg │ ├── birdramon.svg │ ├── botamon.svg │ ├── bubbmon.svg │ ├── egg.svg │ ├── gabumon.svg │ ├── garudamon.svg │ ├── garurumon.svg │ ├── gatomon.svg │ ├── gomamon.svg │ ├── greymon.svg │ ├── herculeskabuterimon.svg │ ├── holyangemon.svg │ ├── holydramon.svg │ ├── ikkakumon.svg │ ├── kabuterimon.svg │ ├── koromon.svg │ ├── lilymon.svg │ ├── metalgarurumon.svg │ ├── metalgreymon.svg │ ├── motimon.svg │ ├── nyaromon.svg │ ├── nyokimon.svg │ ├── palmon.svg │ ├── patamon.svg │ ├── phoenixmon.svg │ ├── pichimon.svg │ ├── piyomon.svg │ ├── poyomon.svg │ ├── pukamon.svg │ ├── punimon.svg │ ├── pyokomon.svg │ ├── rosemon.svg │ ├── salamon.svg │ ├── seraphimon.svg │ ├── snowbotamon.svg │ ├── tanemon.svg │ ├── tentomon.svg │ ├── togemon.svg │ ├── tokomon.svg │ ├── tsunomon.svg │ ├── vikemon.svg │ ├── wargreymon.svg │ ├── weregarurumon.svg │ ├── yuramon.svg │ └── zudomon.svg │ └── error.svg └── test ├── kotlin └── com │ └── doongjun │ └── commitmon │ ├── app │ ├── BaseAppTest.kt │ ├── GithubServiceTest.kt │ └── UserServiceTest.kt │ ├── container │ ├── TestMariaDBContainer.kt │ └── TestRedisContainer.kt │ └── domain │ └── UserTest.kt └── resources └── application-test.yml /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: SpringBoot APP CI/CD 2 | on: 3 | push: 4 | branches: [ main ] 5 | jobs: 6 | deploy: 7 | name: Build Image 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Check out code 11 | uses: actions/checkout@v2 12 | - name: Configure AWS credentials 13 | uses: aws-actions/configure-aws-credentials@v2 14 | with: 15 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 16 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 17 | aws-region: ap-northeast-2 18 | - name: Login to Amazon ECR 19 | id: login-ecr 20 | uses: aws-actions/amazon-ecr-login@v1 21 | - name: Build, tag, and push image to Amazon ECR 22 | env: 23 | ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} 24 | ECR_REPOSITORY: commitmon 25 | IMAGE_TAG: ${{ github.run_number }} 26 | run: | 27 | docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -t $ECR_REGISTRY/$ECR_REPOSITORY:latest . 28 | docker push $ECR_REGISTRY/$ECR_REPOSITORY --all-tags 29 | - name: Deploy to Amazon EC2 30 | run: | 31 | aws deploy create-deployment \ 32 | --application-name ${{ secrets.AWS_CODE_DEPLOY_APPLICATION }} \ 33 | --deployment-config-name CodeDeployDefault.OneAtATime \ 34 | --deployment-group-name ${{ secrets.AWS_CODE_DEPLOY_GROUP }} \ 35 | --description "Deploy commitmon" \ 36 | --s3-location bucket=${{ secrets.AWS_S3_BUCKET_NAME }},key=${{ secrets.AWS_REVISION_S3_KEY }},bundleType=tar -------------------------------------------------------------------------------- /.github/workflows/ktlint.yml: -------------------------------------------------------------------------------- 1 | name: reviewdog 2 | on: [pull_request] 3 | jobs: 4 | ktlint: 5 | name: Check Code Quality 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - name: Clone repo 10 | uses: actions/checkout@master 11 | with: 12 | fetch-depth: 1 13 | - name: ktlint 14 | uses: ScaCap/action-ktlint@master 15 | with: 16 | github_token: ${{ secrets.github_token }} 17 | reporter: github-pr-check 18 | fail_on_error: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | 39 | ### Kotlin ### 40 | .kotlin 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:21-jdk AS TEMP_BUILD_IMAGE 2 | MAINTAINER doongjun.kim@gmail.com 3 | ENV APP_HOME=/app 4 | WORKDIR $APP_HOME 5 | COPY . ./ 6 | RUN microdnf install findutils 7 | RUN ./gradlew build -x test --stacktrace 8 | 9 | FROM openjdk:21-jdk 10 | ENV ARTIFACT_NAME=commitmon-0.0.1-SNAPSHOT.jar 11 | ENV APP_HOME=/app 12 | WORKDIR $APP_HOME 13 | COPY --from=TEMP_BUILD_IMAGE $APP_HOME/build/libs/$ARTIFACT_NAME . 14 | EXPOSE 8080 15 | CMD ["java", "-jar", "commitmon-0.0.1-SNAPSHOT.jar"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Commitmon Adventure 2 | 3 |
4 | 5 | logo 6 | 7 |

Welcome to the Digital World: Growing with your Commit

8 | 9 | Get Your Own Commitmon and Level Up With Your Skills! 10 | 11 | Displays the Commitmon of friends who are following each other. 12 | 13 | [🌟Thank You For Your Star! 🌟](https://github.com/doongjun/commitmon/stargazers) 14 | 15 | 16 | commitmon 17 | 18 | 19 |
20 | 21 | ## Getting Start 22 | 23 | #### Image URL 24 | 25 | ```shell 26 | https://commitmon.me/adventure?username=${username}&theme=${theme}&userFetchType=${userFetchType} 27 | ``` 28 | 29 | | Parameter | Type | Description | Default Value | 30 | |:----------------|:----------------------------------------------------------------------------------|:-------------------------------|:--------------| 31 | | `username` | `string` | **Required**. Your Github Name | - | 32 | | `theme` | `grassland` \| `desert` \| `transparent` | Theme Of Commitmon. | `grassland` | 33 | | `userFetchType` | `all`(following and followers) \| `mutual`(following each other) \| `solo`(alone) | Type of Friends to fetch | `mutual` | 34 | 35 | Actually, you can use the image URL directly in the markdown file. 36 | 37 | ```markdown 38 | 39 | commitmon 40 | 41 | ``` 42 | 43 | ## Tips 44 | 45 | - The Digimon is generated and fixed when the image is first loaded. 46 | - The number of commits is updated periodically. If it's not reflected in real time, please wait a moment. 47 | 48 | ## Digimon Encyclopedia 49 | 50 | | BABY | IN_TRAINING | ROOKIE | CHAMPION | PERFECT | ULTIMATE | 51 | |:-----------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------:|:-----------------------------------------------------------------------------------------------:|:----------------------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------------------------:| 52 | | ![botamon](https://github.com/user-attachments/assets/92ad7b3c-403f-43b3-bd18-f227fbb14c09) | ![koromon](https://github.com/user-attachments/assets/a62c6bb3-2f55-4c3a-b642-c552ff6c3164) | ![agumon](https://github.com/user-attachments/assets/74bc18e5-1a83-4063-b41b-61ba8647012b) | ![greymon](https://github.com/user-attachments/assets/885e9bb6-a5e0-4688-a31a-13991d5f986f) | ![metalgreymon](https://github.com/user-attachments/assets/6102a2ba-4abf-4f5e-8b99-8adaee7ee8aa) | ![wargreymon](https://github.com/user-attachments/assets/e0456bad-f794-41e6-a231-dd658d1ba5c9) | 53 | | ![punimon](https://github.com/user-attachments/assets/9198eb04-43a1-494a-b489-588bbd782174) | ![tsunomon](https://github.com/user-attachments/assets/e2562a56-4c8b-443d-bbd7-9ccd2ae87acb) | ![gabumon](https://github.com/user-attachments/assets/689f2c2d-3af4-437e-b4a0-fcaca8acc8d6) | ![garurumon](https://github.com/user-attachments/assets/a997e0d0-e900-4587-bf2c-0bf894b990d2) | ![weregarurumon](https://github.com/user-attachments/assets/aa9bcf33-c907-45c0-9eb8-16d621ed4bc4) | ![metalgarurumon](https://github.com/user-attachments/assets/8c1a0cf8-1ca6-4f83-98d4-617078fa7253) | 54 | | ![nyokimon](https://github.com/user-attachments/assets/3aeda959-c610-4064-ae28-2fc104811ffd) | ![pyokomon](https://github.com/user-attachments/assets/4385f3ac-f5e5-45d7-b4b4-78262245103c) | ![piyomon](https://github.com/user-attachments/assets/43bba60f-a2af-4364-b3d0-03a080545315) | ![birdramon](https://github.com/user-attachments/assets/234aff1e-f948-4018-963c-a7b2175750e2) | ![garudamon](https://github.com/user-attachments/assets/25597573-7df5-44c9-8258-6e1719945522) | ![phoenixmon](https://github.com/user-attachments/assets/f689bae2-448e-4cc1-a6bb-0da0451b56ab) | 55 | | ![bubbmon](https://github.com/user-attachments/assets/87241aba-07d1-471e-8b45-f88cecb2f91a) | ![motimon](https://github.com/user-attachments/assets/43c4b8f3-f5c0-4508-89e2-5a42e27c6126) | ![tentomon](https://github.com/user-attachments/assets/b3449327-c33e-4e71-8cd6-acabec639399) | ![kabuterimon](https://github.com/user-attachments/assets/ceb141ef-a202-4458-b898-fa0e28fb2267) | ![atlurkabuterimon](https://github.com/user-attachments/assets/ed95b985-6510-4170-a3f3-64a96835351a) | ![herculeskabuterimon](https://github.com/user-attachments/assets/8b56c92d-1f5c-4c21-b941-3f2bda158bf7) | 56 | | ![poyomon](https://github.com/user-attachments/assets/e8cd1047-3ee9-4121-88db-ca889f730999) | ![tokomon](https://github.com/user-attachments/assets/094f8458-ae5c-48ed-a2d5-1f94622af3cf) | ![patamon](https://github.com/user-attachments/assets/4d418f83-33b5-47c8-88c0-29012b61ebb7) | ![angemon](https://github.com/user-attachments/assets/5357fc98-86cc-4105-b417-31610ccf21c0) | ![holyangemon](https://github.com/user-attachments/assets/46b71fe8-a349-46b3-adab-0063f2a5c787) | ![seraphimon](https://github.com/user-attachments/assets/2a5caba1-02f6-47eb-924b-a3c9bf8385c7) | 57 | | ![yuramon](https://github.com/user-attachments/assets/62559caa-85f1-4f81-adb1-dc452b958921) | ![tanemon](https://github.com/user-attachments/assets/4679a714-2251-49b9-bfb3-9e4b30ac9530) | ![palmon](https://github.com/user-attachments/assets/bb9a5798-3a31-4e88-a818-455d4cc331e3) | ![togemon](https://github.com/user-attachments/assets/57ae3f4b-69db-430a-982f-72bbd4cad475) | ![lilymon](https://github.com/user-attachments/assets/8ff73c13-8391-4237-9f4c-1164fa7112c7) | ![rosemon](https://github.com/user-attachments/assets/3dc08cbd-d21e-4a5b-8f1f-4a2fac1ac65e) | 58 | | ![pichimon](https://github.com/user-attachments/assets/4f100746-c6c6-4e55-a12b-b80e4058ae6e) | ![pukamon](https://github.com/user-attachments/assets/3d7155a8-2e03-4d2f-a157-d1577e3200d6) | ![gomamon](https://github.com/user-attachments/assets/329fc8ee-9e3f-45fd-8bf3-f6da80ab178a) | ![ikkakumon](https://github.com/user-attachments/assets/72eb0805-c8ea-4844-ac70-f22139c56ab8) | ![zudomon](https://github.com/user-attachments/assets/619d968a-94fd-4747-871c-fe15ba03ffe6) | ![vikemon](https://github.com/user-attachments/assets/034a4d4f-57da-4eba-a6b1-db11eb4d91f4) | 59 | | ![snowbotamon](https://github.com/user-attachments/assets/6841ca8e-72f8-42ae-b505-e7ccd272833e) | ![nyaromon](https://github.com/user-attachments/assets/cbf3c798-a78a-45ca-bb38-86ac5ff6b1e5) | ![salamon](https://github.com/user-attachments/assets/b349a29f-3b19-48bb-a515-191394adee7f) | ![gatomon](https://github.com/user-attachments/assets/921c7125-0b8d-445c-b97c-678e415105a2) | ![angewomon](https://github.com/user-attachments/assets/bfb9b87a-5b58-4fd1-9e90-467cd154a7d8) | ![holydramon](https://github.com/user-attachments/assets/37ced155-c5e4-4924-8a7b-f4e68fdf9f03) | 60 | 61 | and more.... 62 | 63 | ## Commitmon Change Request 64 | 65 | If you'd like to change your Commitmon, please send an email to **[doongjun.kim@gmail.com](mailto:doongjun.kim@gmail.com)**. 66 | We'll be happy to assist you! 67 | 68 | ## Image License Information 69 | 70 | The images are provided by Loco. 71 | 72 | ## 73 | 74 |
75 | 76 | If you have any ideas or discover a bug, please issue it or contribute. 77 | We will do our best to make it better. 78 | 79 | [![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fdoongjun%2Fcommitmon&count_bg=%23E4770A&title_bg=%23000000&icon=&icon_color=%23000000&title=hits&edge_flat=false)](https://hits.seeyoufarm.com) 80 | ![GitHub Repo stars](https://img.shields.io/github/stars/doongjun/commitmon?style=flat&labelColor=%23000000&color=%230a65af) 81 | 82 |
83 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") version "1.9.25" 3 | kotlin("plugin.spring") version "1.9.25" 4 | kotlin("plugin.jpa") version "1.9.25" 5 | kotlin("plugin.allopen") version "1.9.25" 6 | kotlin("plugin.noarg") version "1.9.25" 7 | id("org.springframework.boot") version "3.3.3" 8 | id("io.spring.dependency-management") version "1.1.6" 9 | id("org.jlleitschuh.gradle.ktlint") version "12.1.1" 10 | } 11 | 12 | allOpen { 13 | annotation("jakarta.persistence.Entity") 14 | annotation("jakarta.persistence.MappedSuperclass") 15 | annotation("jakarta.persistence.Embeddable") 16 | } 17 | 18 | noArg { 19 | annotation("jakarta.persistence.Entity") 20 | annotation("jakarta.persistence.MappedSuperclass") 21 | annotation("jakarta.persistence.Embeddable") 22 | annotations("com.doongjun.commitmon.core.NoArgs") 23 | } 24 | 25 | group = "com.doongjun" 26 | version = "0.0.1-SNAPSHOT" 27 | 28 | java { 29 | toolchain { 30 | languageVersion = JavaLanguageVersion.of(21) 31 | } 32 | } 33 | 34 | repositories { 35 | mavenCentral() 36 | } 37 | 38 | dependencies { 39 | implementation("org.springframework.boot:spring-boot-starter-web") 40 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin") 41 | implementation("org.jetbrains.kotlin:kotlin-reflect") 42 | implementation("org.springframework.boot:spring-boot-starter-webflux") 43 | implementation("org.springframework.boot:spring-boot-starter-data-jpa") 44 | implementation("org.springframework.boot:spring-boot-starter-thymeleaf") 45 | implementation("org.springframework.boot:spring-boot-starter-data-redis") 46 | implementation("org.springframework.boot:spring-boot-starter-security") 47 | implementation("org.springframework.boot:spring-boot-starter-validation") 48 | implementation("com.github.f4b6a3:tsid-creator:5.2.6") 49 | 50 | runtimeOnly("org.mariadb.jdbc:mariadb-java-client") 51 | 52 | implementation("io.jsonwebtoken:jjwt-api:0.12.6") 53 | implementation("io.jsonwebtoken:jjwt-impl:0.12.6") 54 | runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") 55 | 56 | implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2") 57 | 58 | testImplementation("org.springframework.boot:spring-boot-starter-test") 59 | testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") 60 | testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") 61 | testImplementation("org.testcontainers:junit-jupiter:1.20.1") 62 | testImplementation("org.testcontainers:testcontainers:1.20.1") 63 | testImplementation("org.testcontainers:mariadb:1.20.1") 64 | testRuntimeOnly("org.junit.platform:junit-platform-launcher") 65 | } 66 | 67 | kotlin { 68 | compilerOptions { 69 | freeCompilerArgs.addAll("-Xjsr305=strict") 70 | } 71 | } 72 | 73 | tasks.withType { 74 | useJUnitPlatform() 75 | } 76 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mariadb: 3 | image: mariadb:11.5.2 4 | container_name: mariadb 5 | restart: always 6 | environment: 7 | - MARIADB_ROOT_PASSWORD=root 8 | - MARIADB_DATABASE=commitmon 9 | ports: 10 | - 3306:3306 11 | redis: 12 | image: redis:7.4.0 13 | container_name: redis 14 | restart: always 15 | ports: 16 | - 6379:6379 -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doongjun/commitmon/28a64cf3f945f50e60446d86c6bbe1043b457be7/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /graphql.config.yml: -------------------------------------------------------------------------------- 1 | schema: 2 | - https://api.github.com/graphql: 3 | headers: 4 | Authorization: Bearer ${TOKEN} -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "commitmon" 2 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/CommitmonApplication.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | 6 | @SpringBootApplication 7 | class CommitmonApplication 8 | 9 | fun main(args: Array) { 10 | runApplication(*args) 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/animation/AdventureController.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.animation 2 | 3 | import com.doongjun.commitmon.app.AdventureFacade 4 | import com.doongjun.commitmon.app.Theme 5 | import com.doongjun.commitmon.app.UserFetchType 6 | import com.doongjun.commitmon.domain.Commitmon 7 | import com.doongjun.commitmon.extension.currentUserId 8 | import com.doongjun.commitmon.extension.findBy 9 | import io.swagger.v3.oas.annotations.Operation 10 | import jakarta.servlet.http.HttpServletResponse 11 | import org.springframework.http.HttpHeaders 12 | import org.springframework.web.bind.annotation.GetMapping 13 | import org.springframework.web.bind.annotation.RequestParam 14 | import org.springframework.web.bind.annotation.RestController 15 | 16 | @RestController 17 | class AdventureController( 18 | private val adventureFacade: AdventureFacade, 19 | ) { 20 | @Operation(summary = "유저 애니메이션 SVG") 21 | @GetMapping("/adventure", produces = ["image/svg+xml"]) 22 | fun getAdventure( 23 | @RequestParam username: String, 24 | @RequestParam(required = false) theme: String?, 25 | @RequestParam(required = false) userFetchType: String?, 26 | response: HttpServletResponse, 27 | ): String { 28 | response.apply { 29 | setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate, max-age=3600") 30 | setHeader(HttpHeaders.PRAGMA, "no-cache") 31 | } 32 | return adventureFacade.getAnimation( 33 | username = username, 34 | theme = Theme::assetName.findBy(theme?.lowercase()), 35 | userFetchType = UserFetchType::title.findBy(userFetchType?.lowercase()), 36 | ) 37 | } 38 | 39 | @Operation(summary = "로그인 유저 애니메이션 미리보기 SVG") 40 | @GetMapping("/adventure/preview", produces = ["image/svg+xml"]) 41 | fun getAdventurePreview( 42 | @RequestParam(required = false) commitmon: Commitmon?, 43 | @RequestParam(defaultValue = "GRASSLAND") theme: Theme, 44 | @RequestParam(defaultValue = "SOLO") userFetchType: UserFetchType, 45 | response: HttpServletResponse, 46 | ): String = 47 | adventureFacade.getAnimationPreview( 48 | userId = currentUserId!!, 49 | commitmon = commitmon, 50 | theme = theme, 51 | userFetchType = userFetchType, 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.api 2 | 3 | import com.doongjun.commitmon.api.data.RedirectDestination 4 | import com.doongjun.commitmon.api.data.RefreshTokenRequest 5 | import com.doongjun.commitmon.api.data.RefreshTokenResponse 6 | import com.doongjun.commitmon.app.AccountFacade 7 | import com.doongjun.commitmon.app.GithubOAuth2Service 8 | import io.swagger.v3.oas.annotations.Operation 9 | import jakarta.validation.Valid 10 | import org.springframework.http.HttpHeaders 11 | import org.springframework.http.HttpStatus 12 | import org.springframework.http.ResponseEntity 13 | import org.springframework.web.bind.annotation.GetMapping 14 | import org.springframework.web.bind.annotation.PathVariable 15 | import org.springframework.web.bind.annotation.PostMapping 16 | import org.springframework.web.bind.annotation.RequestBody 17 | import org.springframework.web.bind.annotation.RequestHeader 18 | import org.springframework.web.bind.annotation.RequestMapping 19 | import org.springframework.web.bind.annotation.RequestParam 20 | import org.springframework.web.bind.annotation.RestController 21 | 22 | @RestController 23 | @RequestMapping("/api/v1/account") 24 | class AccountController( 25 | private val accountFacade: AccountFacade, 26 | private val githubOAuth2Service: GithubOAuth2Service, 27 | ) { 28 | @Operation(summary = "로그인") 29 | @GetMapping("/login") 30 | fun login( 31 | @RequestHeader("Redirect-Destination", defaultValue = "LOCAL") destination: RedirectDestination, 32 | ): ResponseEntity = 33 | ResponseEntity 34 | .status(HttpStatus.MOVED_PERMANENTLY) 35 | .header( 36 | HttpHeaders.LOCATION, 37 | githubOAuth2Service.getRedirectUrl(destination), 38 | ).build() 39 | 40 | @Operation(summary = "로그인 Callback (시스템에서 호출)") 41 | @GetMapping("/oauth/github/callback/{destination}") 42 | fun loginCallback( 43 | @PathVariable destination: RedirectDestination, 44 | @RequestParam code: String, 45 | ): ResponseEntity { 46 | val (accessToken, refreshToken) = accountFacade.login(code) 47 | return ResponseEntity 48 | .status(HttpStatus.TEMPORARY_REDIRECT) 49 | .header( 50 | HttpHeaders.LOCATION, 51 | destination.getClientUrl(accessToken, refreshToken), 52 | ).build() 53 | } 54 | 55 | @Operation(summary = "토큰 갱신") 56 | @PostMapping("/refresh") 57 | fun refresh( 58 | @Valid @RequestBody request: RefreshTokenRequest, 59 | ): ResponseEntity { 60 | val (accessToken, refreshToken) = accountFacade.refresh(token = request.refreshToken!!) 61 | return ResponseEntity.ok(RefreshTokenResponse.of(accessToken, refreshToken)) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/api/CommitmonController.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.api 2 | 3 | import com.doongjun.commitmon.api.data.CommitmonResponse 4 | import io.swagger.v3.oas.annotations.Operation 5 | import org.springframework.web.bind.annotation.GetMapping 6 | import org.springframework.web.bind.annotation.RequestMapping 7 | import org.springframework.web.bind.annotation.RestController 8 | 9 | @RestController 10 | @RequestMapping("/api/v1/commitmons") 11 | class CommitmonController { 12 | @Operation(summary = "커밋몬 목록 조회") 13 | @GetMapping 14 | fun getAll(): CommitmonResponse = CommitmonResponse.of() 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/api/MeController.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.api 2 | 3 | import com.doongjun.commitmon.api.data.ChangeCommitmonRequest 4 | import com.doongjun.commitmon.api.data.MeDetailResponse 5 | import com.doongjun.commitmon.api.data.MeResponse 6 | import com.doongjun.commitmon.app.UserFetchType 7 | import com.doongjun.commitmon.app.UserService 8 | import com.doongjun.commitmon.extension.currentUserId 9 | import io.swagger.v3.oas.annotations.Operation 10 | import jakarta.validation.Valid 11 | import org.springframework.web.bind.annotation.GetMapping 12 | import org.springframework.web.bind.annotation.PostMapping 13 | import org.springframework.web.bind.annotation.RequestBody 14 | import org.springframework.web.bind.annotation.RequestMapping 15 | import org.springframework.web.bind.annotation.RestController 16 | 17 | @RestController 18 | @RequestMapping("/api/v1/me") 19 | class MeController( 20 | private val userService: UserService, 21 | ) { 22 | @Operation(summary = "로그인 유저 조회") 23 | @GetMapping 24 | fun get(): MeResponse = 25 | MeResponse.from( 26 | user = userService.getSimple(currentUserId!!), 27 | ) 28 | 29 | @Operation(summary = "로그인 유저 조회 (팔로워, 팔로잉 포함)") 30 | @GetMapping("/detail") 31 | fun getDetail(): MeDetailResponse = 32 | MeDetailResponse.from( 33 | user = 34 | userService.get( 35 | id = currentUserId!!, 36 | userFetchType = UserFetchType.ALL, 37 | ), 38 | ) 39 | 40 | @Operation(summary = "로그인 유저 커밋몬 변경") 41 | @PostMapping("/commitmon/change") 42 | fun changeCommitmon( 43 | @Valid @RequestBody request: ChangeCommitmonRequest, 44 | ) { 45 | userService.changeCommitmon( 46 | id = currentUserId!!, 47 | commitmon = request.commitmon!!, 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/api/data/ChangeCommitmonRequest.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.api.data 2 | 3 | import com.doongjun.commitmon.domain.Commitmon 4 | import jakarta.validation.constraints.NotNull 5 | 6 | data class ChangeCommitmonRequest( 7 | @field:NotNull 8 | val commitmon: Commitmon?, 9 | ) 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/api/data/CommitmonResponse.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.api.data 2 | 3 | import com.doongjun.commitmon.domain.Commitmon 4 | import com.doongjun.commitmon.domain.CommitmonLevel 5 | 6 | data class CommitmonResponse( 7 | val commitmons: List, 8 | ) { 9 | data class CommitmonData( 10 | val commitmon: Commitmon, 11 | val name: String, 12 | val nameKo: String, 13 | val level: CommitmonLevel, 14 | val seed: Int, 15 | val imageUrl: String, 16 | ) 17 | 18 | companion object { 19 | private val commitmons = 20 | Commitmon.entries.filter { it != Commitmon.EGG }.map { 21 | CommitmonData( 22 | commitmon = it, 23 | name = it.name.lowercase(), 24 | nameKo = it.nameKo, 25 | level = it.level, 26 | seed = it.seed ?: 0, 27 | imageUrl = "https://s3-commitmon.s3.ap-northeast-2.amazonaws.com/static/${it.assetName}.png", 28 | ) 29 | } 30 | 31 | fun of(): CommitmonResponse = CommitmonResponse(commitmons) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/api/data/MeDetailResponse.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.api.data 2 | 3 | import com.doongjun.commitmon.app.data.GetSimpleUserDto 4 | import com.doongjun.commitmon.app.data.GetUserDto 5 | import com.doongjun.commitmon.domain.Commitmon 6 | 7 | data class MeDetailResponse( 8 | val id: Long, 9 | val name: String, 10 | val totalCommitCount: Long, 11 | val commitmon: Commitmon, 12 | val exp: Int = 0, 13 | val followers: List, 14 | val following: List, 15 | ) { 16 | companion object { 17 | fun from(user: GetUserDto): MeDetailResponse = 18 | MeDetailResponse( 19 | id = user.id, 20 | name = user.name, 21 | totalCommitCount = user.totalCommitCount, 22 | commitmon = user.commitmon, 23 | exp = user.exp, 24 | followers = user.followers, 25 | following = user.following, 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/api/data/MeResponse.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.api.data 2 | 3 | import com.doongjun.commitmon.app.data.GetSimpleUserDto 4 | import com.doongjun.commitmon.domain.Commitmon 5 | 6 | data class MeResponse( 7 | val id: Long, 8 | val name: String, 9 | val totalCommitCount: Long, 10 | val commitmon: Commitmon, 11 | val exp: Int = 0, 12 | ) { 13 | companion object { 14 | fun from(user: GetSimpleUserDto): MeResponse = 15 | MeResponse( 16 | id = user.id, 17 | name = user.name, 18 | totalCommitCount = user.totalCommitCount, 19 | commitmon = user.commitmon, 20 | exp = user.exp, 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.api.data 2 | 3 | enum class RedirectDestination( 4 | val callbackUrl: String, 5 | private val clientUrl: String, 6 | ) { 7 | PRODUCTION( 8 | "https://commitmon.me/api/v1/account/oauth/github/callback/PRODUCTION", 9 | "https://commitmon-client.vercel.app/account", 10 | ), 11 | LOCAL( 12 | "https://commitmon.me/api/v1/account/oauth/github/callback/LOCAL", 13 | "http://localhost:3000/account", 14 | ), 15 | ; 16 | 17 | fun getClientUrl( 18 | accessToken: String, 19 | refreshToken: String, 20 | ): String = "$clientUrl?accessToken=$accessToken&refreshToken=$refreshToken" 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/api/data/RefreshTokenRequest.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.api.data 2 | 3 | import jakarta.validation.constraints.NotNull 4 | 5 | data class RefreshTokenRequest( 6 | @field:NotNull 7 | val refreshToken: String?, 8 | ) 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/api/data/RefreshTokenResponse.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.api.data 2 | 3 | data class RefreshTokenResponse( 4 | val accessToken: String, 5 | val refreshToken: String, 6 | ) { 7 | companion object { 8 | fun of( 9 | accessToken: String, 10 | refreshToken: String, 11 | ): RefreshTokenResponse = RefreshTokenResponse(accessToken, refreshToken) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.app 2 | 3 | import com.doongjun.commitmon.app.data.AuthDto 4 | import com.doongjun.commitmon.app.data.CreateUserDto 5 | import com.doongjun.commitmon.app.data.GetUserDto 6 | import com.doongjun.commitmon.config.security.TokenProvider 7 | import org.springframework.security.authentication.AccountExpiredException 8 | import org.springframework.stereotype.Component 9 | 10 | @Component 11 | class AccountFacade( 12 | private val userService: UserService, 13 | private val githubService: GithubService, 14 | private val githubOAuth2Service: GithubOAuth2Service, 15 | private val tokenProvider: TokenProvider, 16 | ) { 17 | fun login(code: String): AuthDto { 18 | val userLogin = githubOAuth2Service.getUserLogin(code) 19 | val user = getOrCreateUser(userLogin) 20 | 21 | return AuthDto( 22 | accessToken = tokenProvider.createAccessToken(user.id), 23 | refreshToken = tokenProvider.createRefreshToken(user.id), 24 | ) 25 | } 26 | 27 | private fun getOrCreateUser(username: String): GetUserDto = 28 | runCatching { 29 | userService.getByName( 30 | name = username, 31 | userFetchType = UserFetchType.SOLO, 32 | ) 33 | }.getOrElse { 34 | val (totalCommitCount) = githubService.getUserCommitInfo(username) 35 | val (followerNames, followingNames) = githubService.getUserFollowInfo(username, 100) 36 | val userId = 37 | CreateUserDto( 38 | name = username, 39 | totalCommitCount = totalCommitCount, 40 | followerNames = followerNames, 41 | followingNames = followingNames, 42 | ).let { dto -> 43 | userService.create(dto) 44 | } 45 | userService.get(userId, UserFetchType.SOLO) 46 | } 47 | 48 | fun refresh(token: String): AuthDto { 49 | val refreshToken = 50 | tokenProvider.getRefreshToken(token) 51 | ?: throw AccountExpiredException("Refresh token is expired") 52 | 53 | return AuthDto( 54 | accessToken = tokenProvider.createAccessToken(refreshToken.userId!!), 55 | refreshToken = 56 | when (refreshToken.isLessThanWeek()) { 57 | true -> { 58 | val user = userService.get(refreshToken.userId, UserFetchType.SOLO) 59 | tokenProvider.expireRefreshToken(token) 60 | tokenProvider.createRefreshToken(user.id) 61 | } 62 | false -> refreshToken.token!! 63 | }, 64 | ) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/app/AdventureFacade.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.app 2 | 3 | import com.doongjun.commitmon.app.data.CreateUserDto 4 | import com.doongjun.commitmon.app.data.GetUserDto 5 | import com.doongjun.commitmon.core.AdventureGenerator 6 | import com.doongjun.commitmon.domain.Commitmon 7 | import com.doongjun.commitmon.event.UpdateUserInfo 8 | import org.slf4j.LoggerFactory 9 | import org.springframework.context.ApplicationEventPublisher 10 | import org.springframework.core.io.ClassPathResource 11 | import org.springframework.stereotype.Component 12 | import org.thymeleaf.context.Context 13 | import org.thymeleaf.spring6.SpringTemplateEngine 14 | import java.nio.charset.Charset 15 | 16 | @Component 17 | class AdventureFacade( 18 | private val userService: UserService, 19 | private val githubService: GithubService, 20 | private val svgTemplateEngine: SpringTemplateEngine, 21 | private val publisher: ApplicationEventPublisher, 22 | ) { 23 | private val log = LoggerFactory.getLogger(javaClass) 24 | 25 | fun getAnimation( 26 | username: String, 27 | theme: Theme?, 28 | userFetchType: UserFetchType?, 29 | ): String = 30 | createAnimation( 31 | user = 32 | getOrCreateUser( 33 | username = username, 34 | userFetchType = userFetchType ?: UserFetchType.MUTUAL, 35 | ), 36 | theme = theme ?: Theme.GRASSLAND, 37 | ) 38 | 39 | fun getAnimationPreview( 40 | userId: Long, 41 | commitmon: Commitmon?, 42 | theme: Theme, 43 | userFetchType: UserFetchType, 44 | ): String { 45 | val user = 46 | userService.get( 47 | id = userId, 48 | userFetchType = userFetchType, 49 | ) 50 | 51 | user.commitmon = commitmon ?: user.commitmon 52 | 53 | return createAnimation( 54 | user = user, 55 | theme = theme, 56 | ) 57 | } 58 | 59 | private fun getOrCreateUser( 60 | username: String, 61 | userFetchType: UserFetchType, 62 | ): GetUserDto = 63 | runCatching { 64 | userService.getByName( 65 | name = username, 66 | userFetchType = userFetchType, 67 | ) 68 | }.onSuccess { existsUser -> 69 | publisher.publishEvent(UpdateUserInfo(existsUser.id)) 70 | }.getOrElse { 71 | log.info("Creating user: $username") 72 | val (totalCommitCount) = githubService.getUserCommitInfo(username) 73 | val (followerNames, followingNames) = githubService.getUserFollowInfo(username, 100) 74 | val userId = 75 | CreateUserDto( 76 | name = username, 77 | totalCommitCount = totalCommitCount, 78 | followerNames = followerNames, 79 | followingNames = followingNames, 80 | ).let { dto -> 81 | userService.create(dto) 82 | } 83 | 84 | log.info("User created: $username") 85 | userService.get(userId, userFetchType) 86 | } 87 | 88 | private fun createAnimation( 89 | user: GetUserDto, 90 | theme: Theme, 91 | ): String { 92 | val templates = 93 | (user.fetchedUsers + user.toSimple()).joinToString { u -> 94 | svgTemplateEngine.process( 95 | "asset/${u.commitmon.assetName}", 96 | Context().apply { 97 | setVariable("id", u.id) 98 | setVariable("motion", AdventureGenerator.generateMotion()) 99 | setVariable("y", AdventureGenerator.generateY(u.commitmon.isFlying)) 100 | setVariable("username", u.name) 101 | setVariable("exp", u.exp) 102 | }, 103 | ) 104 | } 105 | 106 | return svgTemplateEngine.process( 107 | "adventure", 108 | Context().apply { 109 | setVariable("username", user.name) 110 | setVariable("totalCommitCount", user.totalCommitCount) 111 | setVariable( 112 | "theme", 113 | ClassPathResource( 114 | "static/theme/${theme.assetName}.svg", 115 | ).getContentAsString(Charset.defaultCharset()), 116 | ) 117 | setVariable("templates", templates) 118 | }, 119 | ) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/app/GithubOAuth2Service.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.app 2 | 3 | import com.doongjun.commitmon.api.data.RedirectDestination 4 | import com.doongjun.commitmon.infra.GithubOAuth2Api 5 | import com.doongjun.commitmon.infra.GithubRestApi 6 | import org.springframework.beans.factory.annotation.Value 7 | import org.springframework.stereotype.Service 8 | 9 | @Service 10 | class GithubOAuth2Service( 11 | private val githubOAuth2Api: GithubOAuth2Api, 12 | private val githubRestApi: GithubRestApi, 13 | @Value("\${app.github.oauth2.base-url}") 14 | private val githubOAuth2BaseUrl: String, 15 | @Value("\${app.github.oauth2.client-id}") 16 | private val githubClientId: String, 17 | @Value("\${app.github.oauth2.client-secret}") 18 | private val githubClientSecret: String, 19 | ) { 20 | fun getRedirectUrl(destination: RedirectDestination): String = 21 | "$githubOAuth2BaseUrl/authorize?client_id=$githubClientId&redirect_uri=${destination.callbackUrl}" 22 | 23 | fun getUserLogin(code: String): String { 24 | val userToken = 25 | githubOAuth2Api 26 | .fetchAccessToken( 27 | code = code, 28 | clientId = githubClientId, 29 | clientSecret = githubClientSecret, 30 | ).accessToken 31 | 32 | return githubRestApi.fetchUserInfo(userToken) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/app/GithubService.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.app 2 | 3 | import com.doongjun.commitmon.app.data.GetUserCommitInfo 4 | import com.doongjun.commitmon.app.data.GetUserFollowInfoDto 5 | import com.doongjun.commitmon.infra.GithubGraphqlApi 6 | import com.doongjun.commitmon.infra.GithubRestApi 7 | import com.doongjun.commitmon.infra.data.FollowInfo 8 | import org.springframework.cache.annotation.Cacheable 9 | import org.springframework.stereotype.Service 10 | 11 | @Service 12 | class GithubService( 13 | private val githubRestApi: GithubRestApi, 14 | private val githubGraphqlApi: GithubGraphqlApi, 15 | ) { 16 | @Cacheable(value = ["userCommitInfo"], key = "#username") 17 | fun getUserCommitInfo(username: String): GetUserCommitInfo { 18 | val (totalCount) = githubRestApi.fetchUserCommitSearchInfo(username) 19 | 20 | return GetUserCommitInfo( 21 | totalCommitCount = totalCount, 22 | ) 23 | } 24 | 25 | @Cacheable(value = ["userFollowInfo"], key = "#username") 26 | fun getUserFollowInfo( 27 | username: String, 28 | size: Int, 29 | ): GetUserFollowInfoDto { 30 | if (size > 100) { 31 | throw IllegalArgumentException("Size should be less than or equal to 100") 32 | } 33 | 34 | val (followers, following) = 35 | githubGraphqlApi 36 | .fetchUserFollowInfo(username, size) 37 | 38 | return GetUserFollowInfoDto( 39 | followerNames = followers.nodes.map { it.login }, 40 | followingNames = following.nodes.map { it.login }, 41 | ) 42 | } 43 | 44 | fun getAllUserFollowInfo( 45 | username: String, 46 | pageSize: Int, 47 | ): GetUserFollowInfoDto { 48 | val (followers, following) = 49 | githubGraphqlApi 50 | .fetchUserFollowInfo(username, pageSize) 51 | 52 | return GetUserFollowInfoDto( 53 | followerNames = 54 | fetchAllNames(followers) { cursor -> 55 | githubGraphqlApi 56 | .fetchUserFollowers(username, pageSize, cursor) 57 | .followers 58 | }, 59 | followingNames = 60 | fetchAllNames(following) { cursor -> 61 | githubGraphqlApi 62 | .fetchUserFollowing(username, pageSize, cursor) 63 | .following 64 | }, 65 | ) 66 | } 67 | 68 | private fun fetchAllNames( 69 | initialInfo: FollowInfo, 70 | fetchMore: (String?) -> FollowInfo, 71 | ): List { 72 | val names = initialInfo.nodes.map { it.login }.toMutableList() 73 | var hasNextPage = initialInfo.pageInfo.hasNextPage 74 | var cursor = initialInfo.pageInfo.endCursor 75 | 76 | while (hasNextPage) { 77 | val nextInfo = fetchMore(cursor) 78 | names.addAll(nextInfo.nodes.map { it.login }) 79 | 80 | hasNextPage = nextInfo.pageInfo.hasNextPage 81 | cursor = nextInfo.pageInfo.endCursor 82 | } 83 | 84 | return names 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/app/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.app 2 | 3 | enum class Theme( 4 | val assetName: String, 5 | ) { 6 | DESERT("desert"), 7 | GRASSLAND("grassland"), 8 | TRANSPARENT("transparent"), 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/app/UserFetchType.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.app 2 | 3 | enum class UserFetchType( 4 | val title: String, 5 | ) { 6 | ALL("all"), 7 | MUTUAL("mutual"), 8 | SOLO("solo"), 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/app/UserService.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.app 2 | 3 | import com.doongjun.commitmon.app.data.CreateUserDto 4 | import com.doongjun.commitmon.app.data.GetSimpleUserDto 5 | import com.doongjun.commitmon.app.data.GetUserDto 6 | import com.doongjun.commitmon.app.data.UpdateUserDto 7 | import com.doongjun.commitmon.domain.Commitmon 8 | import com.doongjun.commitmon.domain.User 9 | import com.doongjun.commitmon.domain.UserRepository 10 | import org.springframework.cache.CacheManager 11 | import org.springframework.cache.annotation.Cacheable 12 | import org.springframework.data.repository.findByIdOrNull 13 | import org.springframework.stereotype.Service 14 | import org.springframework.transaction.annotation.Transactional 15 | 16 | @Service 17 | @Transactional 18 | class UserService( 19 | private val userRepository: UserRepository, 20 | private val cacheManager: CacheManager, 21 | ) { 22 | @Transactional(readOnly = true) 23 | fun existsByName(name: String) = userRepository.existsByName(name) 24 | 25 | @Transactional(readOnly = true) 26 | fun getSimple(id: Long): GetSimpleUserDto = 27 | userRepository.findByIdOrNull(id)?.let { user -> GetSimpleUserDto.from(user) } 28 | ?: throw NoSuchElementException("Failed to fetch user by id: $id") 29 | 30 | @Transactional(readOnly = true) 31 | fun get( 32 | id: Long, 33 | userFetchType: UserFetchType, 34 | ): GetUserDto = 35 | userRepository.findByIdOrNull(id)?.let { user -> GetUserDto.from(user, userFetchType) } 36 | ?: throw NoSuchElementException("Failed to fetch user by id: $id") 37 | 38 | @Cacheable(value = ["userInfo"], key = "#name + '-' + #userFetchType.title") 39 | @Transactional(readOnly = true) 40 | fun getByName( 41 | name: String, 42 | userFetchType: UserFetchType, 43 | ): GetUserDto = 44 | userRepository.findByName(name)?.let { user -> GetUserDto.from(user, userFetchType) } 45 | ?: throw NoSuchElementException("Failed to fetch user by name: $name") 46 | 47 | fun create(dto: CreateUserDto): Long { 48 | val user = 49 | userRepository.save( 50 | User( 51 | name = dto.name, 52 | totalCommitCount = dto.totalCommitCount, 53 | followers = userRepository.findAllByNameIn(dto.followerNames), 54 | following = userRepository.findAllByNameIn(dto.followingNames), 55 | ), 56 | ) 57 | return user.id 58 | } 59 | 60 | fun update( 61 | id: Long, 62 | dto: UpdateUserDto, 63 | ) { 64 | val user = 65 | userRepository.findByIdOrNull(id) 66 | ?: throw NoSuchElementException("Failed to fetch user by id: $id") 67 | 68 | user.update( 69 | totalCommitCount = dto.totalCommitCount, 70 | followers = userRepository.findAllByNameIn(dto.followerNames), 71 | following = userRepository.findAllByNameIn(dto.followingNames), 72 | ) 73 | 74 | userRepository.save(user) 75 | } 76 | 77 | fun changeCommitmon( 78 | id: Long, 79 | commitmon: Commitmon, 80 | ) { 81 | val user = 82 | userRepository.findByIdOrNull(id) 83 | ?: throw NoSuchElementException("Failed to fetch user by id: $id") 84 | 85 | user.changeCommitmon(commitmon) 86 | 87 | userRepository.save(user) 88 | 89 | clearCache(user.name) 90 | } 91 | 92 | private fun clearCache(name: String) { 93 | val cache = cacheManager.getCache("userInfo") 94 | UserFetchType.entries.forEach { userFetchType -> 95 | cache?.evictIfPresent(name + '-' + userFetchType.title) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/app/data/AuthDto.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.app.data 2 | 3 | data class AuthDto( 4 | val accessToken: String, 5 | val refreshToken: String, 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/app/data/CreateUserDto.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.app.data 2 | 3 | data class CreateUserDto( 4 | val name: String, 5 | val totalCommitCount: Long, 6 | val followerNames: List, 7 | val followingNames: List, 8 | ) 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/app/data/GetSimpleUserDto.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.app.data 2 | 3 | import com.doongjun.commitmon.core.NoArgs 4 | import com.doongjun.commitmon.domain.Commitmon 5 | import com.doongjun.commitmon.domain.User 6 | 7 | @NoArgs 8 | data class GetSimpleUserDto( 9 | val id: Long, 10 | val name: String, 11 | val totalCommitCount: Long, 12 | val commitmon: Commitmon, 13 | val exp: Int = 0, 14 | ) { 15 | companion object { 16 | fun from(user: User): GetSimpleUserDto = 17 | GetSimpleUserDto( 18 | id = user.id, 19 | name = user.name, 20 | totalCommitCount = user.totalCommitCount, 21 | commitmon = user.commitmon, 22 | exp = user.exp, 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/app/data/GetUserCommitInfo.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.app.data 2 | 3 | import com.doongjun.commitmon.core.NoArgs 4 | import java.io.Serializable 5 | 6 | @NoArgs 7 | data class GetUserCommitInfo( 8 | val totalCommitCount: Long, 9 | ) : Serializable 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/app/data/GetUserDto.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.app.data 2 | 3 | import com.doongjun.commitmon.app.UserFetchType 4 | import com.doongjun.commitmon.core.NoArgs 5 | import com.doongjun.commitmon.domain.Commitmon 6 | import com.doongjun.commitmon.domain.User 7 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties 8 | 9 | @NoArgs 10 | @JsonIgnoreProperties(value = ["followerIds", "followingIds", "followers", "following"]) 11 | data class GetUserDto( 12 | val id: Long, 13 | val name: String, 14 | val totalCommitCount: Long, 15 | var commitmon: Commitmon, 16 | val exp: Int = 0, 17 | val fetchedUsers: List, 18 | private val followerIds: List, 19 | private val followingIds: List, 20 | ) { 21 | val followers: List by lazy { 22 | fetchedUsers.filter { it.id in followerIds } 23 | } 24 | val following: List by lazy { 25 | fetchedUsers.filter { it.id in followingIds } 26 | } 27 | 28 | fun toSimple(): GetSimpleUserDto = 29 | GetSimpleUserDto( 30 | id = id, 31 | name = name, 32 | totalCommitCount = totalCommitCount, 33 | commitmon = commitmon, 34 | exp = exp, 35 | ) 36 | 37 | companion object { 38 | fun from( 39 | user: User, 40 | userFetchType: UserFetchType, 41 | ): GetUserDto = 42 | GetUserDto( 43 | id = user.id, 44 | name = user.name, 45 | totalCommitCount = user.totalCommitCount, 46 | commitmon = user.commitmon, 47 | exp = user.exp, 48 | fetchedUsers = 49 | when (userFetchType) { 50 | UserFetchType.ALL -> 51 | listOf(user.followers, user.following) 52 | .flatten() 53 | .distinct() 54 | .map { GetSimpleUserDto.from(it) } 55 | UserFetchType.MUTUAL -> 56 | user.mutualFollowers 57 | .map { GetSimpleUserDto.from(it) } 58 | UserFetchType.SOLO -> emptyList() 59 | }, 60 | followerIds = user.followers.map { it.id }, 61 | followingIds = user.following.map { it.id }, 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/app/data/GetUserFollowInfoDto.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.app.data 2 | 3 | import com.doongjun.commitmon.core.NoArgs 4 | 5 | @NoArgs 6 | data class GetUserFollowInfoDto( 7 | val followerNames: List, 8 | val followingNames: List, 9 | ) 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/app/data/UpdateUserDto.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.app.data 2 | 3 | data class UpdateUserDto( 4 | val totalCommitCount: Long, 5 | val followerNames: List, 6 | val followingNames: List, 7 | ) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/config/AsyncConfig.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.config 2 | 3 | import org.springframework.context.annotation.Bean 4 | import org.springframework.context.annotation.Configuration 5 | import org.springframework.scheduling.annotation.EnableAsync 6 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor 7 | 8 | @EnableAsync 9 | @Configuration 10 | class AsyncConfig { 11 | @Bean 12 | fun taskExecutor(): ThreadPoolTaskExecutor { 13 | val executor = ThreadPoolTaskExecutor() 14 | executor.setThreadNamePrefix("async-executor-") 15 | executor.corePoolSize = POOL_SIZE 16 | executor.maxPoolSize = POOL_SIZE * 2 17 | executor.queueCapacity = QUEUE_SIZE 18 | executor.setWaitForTasksToCompleteOnShutdown(true) 19 | executor.initialize() 20 | return executor 21 | } 22 | 23 | companion object { 24 | private const val POOL_SIZE = 3 25 | private const val QUEUE_SIZE = 3 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/config/CacheConfig.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.config 2 | 3 | import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer 4 | import org.springframework.cache.annotation.EnableCaching 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | import org.springframework.data.redis.cache.CacheKeyPrefix 8 | import org.springframework.data.redis.cache.RedisCacheConfiguration 9 | import org.springframework.data.redis.cache.RedisCacheManager.RedisCacheManagerBuilder 10 | import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer 11 | import org.springframework.data.redis.serializer.RedisSerializationContext 12 | import org.springframework.data.redis.serializer.StringRedisSerializer 13 | import java.time.Duration 14 | 15 | @EnableCaching 16 | @Configuration 17 | class CacheConfig { 18 | @Bean 19 | fun redisCacheManagerBuilderCustomizer(): RedisCacheManagerBuilderCustomizer = 20 | RedisCacheManagerBuilderCustomizer { builder: RedisCacheManagerBuilder -> 21 | builder 22 | .withCacheConfiguration( 23 | "userInfo", 24 | RedisCacheConfiguration 25 | .defaultCacheConfig() 26 | .computePrefixWith(CacheKeyPrefix.simple()) 27 | .entryTtl(Duration.ofMinutes(5)) 28 | .disableCachingNullValues() 29 | .serializeKeysWith( 30 | RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()), 31 | ).serializeValuesWith( 32 | RedisSerializationContext.SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer()), 33 | ), 34 | ).withCacheConfiguration( 35 | "userCommitInfo", 36 | RedisCacheConfiguration 37 | .defaultCacheConfig() 38 | .computePrefixWith(CacheKeyPrefix.simple()) 39 | .entryTtl(Duration.ofMinutes(5)) 40 | .disableCachingNullValues() 41 | .serializeKeysWith( 42 | RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()), 43 | ).serializeValuesWith( 44 | RedisSerializationContext.SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer()), 45 | ), 46 | ).withCacheConfiguration( 47 | "userFollowInfo", 48 | RedisCacheConfiguration 49 | .defaultCacheConfig() 50 | .computePrefixWith(CacheKeyPrefix.simple()) 51 | .entryTtl(Duration.ofMinutes(60)) 52 | .disableCachingNullValues() 53 | .serializeKeysWith( 54 | RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()), 55 | ).serializeValuesWith( 56 | RedisSerializationContext.SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer()), 57 | ), 58 | ) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/config/JpaConfig.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.config 2 | 3 | import org.springframework.context.annotation.Configuration 4 | import org.springframework.data.jpa.repository.config.EnableJpaAuditing 5 | 6 | @Configuration 7 | @EnableJpaAuditing 8 | class JpaConfig 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/config/RedisConfig.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.config 2 | 3 | import org.springframework.beans.factory.annotation.Value 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.data.redis.connection.RedisStandaloneConfiguration 7 | import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory 8 | 9 | @Configuration 10 | class RedisConfig( 11 | @Value("\${spring.data.redis.host}") private val host: String, 12 | @Value("\${spring.data.redis.port}") private val port: Int, 13 | ) { 14 | @Bean 15 | fun redisConnectionFactory() = LettuceConnectionFactory(RedisStandaloneConfiguration(host, port)) 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/config/SwaggerConfig.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.config 2 | 3 | import io.swagger.v3.oas.annotations.OpenAPIDefinition 4 | import io.swagger.v3.oas.annotations.enums.SecuritySchemeType 5 | import io.swagger.v3.oas.annotations.info.Info 6 | import io.swagger.v3.oas.annotations.security.SecurityRequirement 7 | import io.swagger.v3.oas.annotations.security.SecurityScheme 8 | import io.swagger.v3.oas.annotations.servers.Server 9 | import org.springdoc.core.models.GroupedOpenApi 10 | import org.springframework.context.annotation.Bean 11 | import org.springframework.context.annotation.Configuration 12 | 13 | @OpenAPIDefinition( 14 | info = 15 | Info( 16 | title = "Commitmon API", 17 | description = "API Documents", 18 | version = "v1.0.0", 19 | ), 20 | security = [SecurityRequirement(name = "Bearer Authentication")], 21 | servers = [ 22 | Server(url = "https://commitmon.me"), 23 | Server(url = "http://localhost:8080"), 24 | ], 25 | ) 26 | @SecurityScheme( 27 | name = "Bearer Authentication", 28 | type = SecuritySchemeType.HTTP, 29 | bearerFormat = "JWT", 30 | scheme = "bearer", 31 | ) 32 | @Configuration 33 | class SwaggerConfig { 34 | @Bean 35 | fun api(): GroupedOpenApi = 36 | GroupedOpenApi 37 | .builder() 38 | .group("api-v1-definition") 39 | .pathsToMatch("/api/**") 40 | .build() 41 | 42 | @Bean 43 | fun animation(): GroupedOpenApi = 44 | GroupedOpenApi 45 | .builder() 46 | .group("animation-definition") 47 | .pathsToMatch("/adventure/**") 48 | .build() 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/config/ThymeleafConfig.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.config 2 | 3 | import org.springframework.context.annotation.Bean 4 | import org.springframework.context.annotation.Configuration 5 | import org.thymeleaf.spring6.SpringTemplateEngine 6 | import org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver 7 | import org.thymeleaf.templatemode.TemplateMode 8 | 9 | @Configuration 10 | class ThymeleafConfig { 11 | @Bean 12 | fun svgTemplateEngine(springResourceTemplateResolver: SpringResourceTemplateResolver): SpringTemplateEngine = 13 | SpringTemplateEngine().apply { 14 | setTemplateResolver(springResourceTemplateResolver) 15 | } 16 | 17 | @Bean 18 | fun springResourceTemplateResolver(): SpringResourceTemplateResolver = 19 | SpringResourceTemplateResolver().apply { 20 | prefix = "classpath:/templates/" 21 | suffix = ".svg" 22 | templateMode = TemplateMode.TEXT 23 | characterEncoding = "UTF-8" 24 | isCacheable = false 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/config/WebClientConfig.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.config 2 | 3 | import org.springframework.beans.factory.annotation.Value 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.http.HttpHeaders 7 | import org.springframework.http.MediaType 8 | import org.springframework.web.reactive.function.client.WebClient 9 | import org.springframework.web.util.DefaultUriBuilderFactory 10 | 11 | @Configuration 12 | class WebClientConfig { 13 | @Bean(name = ["githubGraphqlWebClient"]) 14 | fun githubGraphqlWebClient( 15 | @Value("\${app.github.token}") token: String, 16 | @Value("\${app.github.base-url}") baseUrl: String, 17 | ): WebClient = 18 | WebClient 19 | .builder() 20 | .baseUrl("$baseUrl/graphql") 21 | .defaultHeaders { httpHeaders -> 22 | httpHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) 23 | httpHeaders.add(HttpHeaders.AUTHORIZATION, "Bearer $token") 24 | }.build() 25 | 26 | @Bean(name = ["githubRestWebClient"]) 27 | fun githubRestWebClient( 28 | @Value("\${app.github.base-url}") baseUrl: String, 29 | ): WebClient = 30 | WebClient 31 | .builder() 32 | .uriBuilderFactory(DefaultUriBuilderFactory(baseUrl)) 33 | .baseUrl(baseUrl) 34 | .defaultHeaders { httpHeaders -> 35 | httpHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) 36 | }.build() 37 | 38 | @Bean(name = ["githubOAuth2WebClient"]) 39 | fun githubOAuth2WebClient( 40 | @Value("\${app.github.oauth2.base-url}") baseUrl: String, 41 | ): WebClient = 42 | WebClient 43 | .builder() 44 | .uriBuilderFactory(DefaultUriBuilderFactory(baseUrl)) 45 | .baseUrl(baseUrl) 46 | .defaultHeaders { httpHeaders -> 47 | httpHeaders.add(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) 48 | }.build() 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/config/security/JwtFilter.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.config.security 2 | 3 | import jakarta.servlet.FilterChain 4 | import jakarta.servlet.http.HttpServletRequest 5 | import jakarta.servlet.http.HttpServletResponse 6 | import org.springframework.http.HttpHeaders 7 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken 8 | import org.springframework.security.core.authority.SimpleGrantedAuthority 9 | import org.springframework.security.core.context.SecurityContextHolder 10 | import org.springframework.security.web.authentication.WebAuthenticationDetailsSource 11 | import org.springframework.security.web.context.RequestAttributeSecurityContextRepository 12 | import org.springframework.web.filter.OncePerRequestFilter 13 | 14 | class JwtFilter( 15 | private val tokenProvider: TokenProvider, 16 | ) : OncePerRequestFilter() { 17 | private val repository = RequestAttributeSecurityContextRepository() 18 | 19 | override fun doFilterInternal( 20 | request: HttpServletRequest, 21 | response: HttpServletResponse, 22 | filterChain: FilterChain, 23 | ) { 24 | val token = resolveToken(request) 25 | 26 | if (!token.isNullOrEmpty() && tokenProvider.validateAccessToken(token)) { 27 | val userId = tokenProvider.extractUserId(token) 28 | 29 | val authenticationToken = 30 | UsernamePasswordAuthenticationToken( 31 | userId, 32 | null, 33 | listOf(SimpleGrantedAuthority("ROLE_USER")), 34 | ) 35 | 36 | authenticationToken.details = WebAuthenticationDetailsSource().buildDetails(request) 37 | SecurityContextHolder.getContext().authentication = authenticationToken 38 | repository.saveContext(SecurityContextHolder.getContext(), request, response) 39 | } 40 | 41 | filterChain.doFilter(request, response) 42 | } 43 | 44 | private fun resolveToken(request: HttpServletRequest): String? { 45 | val authorization = request.getHeader(HttpHeaders.AUTHORIZATION) 46 | if (!authorization.isNullOrEmpty() && authorization.startsWith("Bearer ")) { 47 | return authorization.substring(7) 48 | } 49 | 50 | return null 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/config/security/RefreshToken.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.config.security 2 | 3 | import org.springframework.data.annotation.Id 4 | import org.springframework.data.redis.core.RedisHash 5 | import org.springframework.data.redis.core.TimeToLive 6 | import java.util.UUID 7 | 8 | @RedisHash(value = "refreshToken") 9 | data class RefreshToken( 10 | @Id 11 | var token: String? = UUID.randomUUID().toString(), 12 | @TimeToLive 13 | val ttl: Long = 1209600, 14 | val userId: Long?, 15 | ) { 16 | fun isLessThanWeek(): Boolean = ttl < 604800 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/config/security/RefreshTokenRepository.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.config.security 2 | 3 | import org.springframework.data.repository.CrudRepository 4 | 5 | interface RefreshTokenRepository : CrudRepository 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/config/security/RestAccessDeniedHandler.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.config.security 2 | 3 | import com.doongjun.commitmon.core.error.response.ErrorCode 4 | import com.doongjun.commitmon.core.error.response.ErrorResponse 5 | import com.doongjun.commitmon.extension.convertToString 6 | import jakarta.servlet.http.HttpServletRequest 7 | import jakarta.servlet.http.HttpServletResponse 8 | import org.springframework.security.access.AccessDeniedException 9 | import org.springframework.security.web.access.AccessDeniedHandler 10 | 11 | class RestAccessDeniedHandler : AccessDeniedHandler { 12 | override fun handle( 13 | request: HttpServletRequest?, 14 | response: HttpServletResponse, 15 | e: AccessDeniedException, 16 | ) { 17 | val errorResponse = ErrorResponse.of(ErrorCode.ACCESS_DENIED) 18 | response.contentType = "application/json" 19 | response.status = HttpServletResponse.SC_FORBIDDEN 20 | response.writer?.write(errorResponse.convertToString()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/config/security/RestAuthenticationEntryPoint.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.config.security 2 | 3 | import com.doongjun.commitmon.core.error.response.ErrorCode 4 | import com.doongjun.commitmon.core.error.response.ErrorResponse 5 | import com.doongjun.commitmon.extension.convertToString 6 | import jakarta.servlet.http.HttpServletRequest 7 | import jakarta.servlet.http.HttpServletResponse 8 | import org.springframework.security.core.AuthenticationException 9 | import org.springframework.security.web.AuthenticationEntryPoint 10 | 11 | class RestAuthenticationEntryPoint : AuthenticationEntryPoint { 12 | override fun commence( 13 | request: HttpServletRequest, 14 | response: HttpServletResponse, 15 | e: AuthenticationException, 16 | ) { 17 | val errorResponse = ErrorResponse.of(ErrorCode.UNAUTHORIZED) 18 | response.contentType = "application/json" 19 | response.status = HttpServletResponse.SC_UNAUTHORIZED 20 | response.writer?.write(errorResponse.convertToString()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/config/security/SecurityConfig.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.config.security 2 | 3 | import org.springframework.context.annotation.Bean 4 | import org.springframework.context.annotation.Configuration 5 | import org.springframework.security.config.annotation.web.builders.HttpSecurity 6 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity 7 | import org.springframework.security.config.http.SessionCreationPolicy 8 | import org.springframework.security.web.SecurityFilterChain 9 | import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter 10 | 11 | @Configuration 12 | @EnableWebSecurity 13 | class SecurityConfig( 14 | private val tokenProvider: TokenProvider, 15 | ) { 16 | @Bean 17 | fun filterChain(http: HttpSecurity): SecurityFilterChain { 18 | http 19 | .csrf { it.disable() } 20 | .formLogin { it.disable() } 21 | .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } 22 | .addFilterBefore(JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter::class.java) 23 | .authorizeHttpRequests { authorize -> 24 | authorize 25 | .requestMatchers("/swagger-ui/**", "/v3/api-docs/**") 26 | .permitAll() 27 | .requestMatchers("/adventure") 28 | .permitAll() 29 | .requestMatchers("/api/v1/account/**") 30 | .permitAll() 31 | .requestMatchers("/api/v1/commitmons/**") 32 | .permitAll() 33 | .anyRequest() 34 | .authenticated() 35 | }.exceptionHandling { exception -> 36 | exception 37 | .accessDeniedHandler(RestAccessDeniedHandler()) 38 | .authenticationEntryPoint(RestAuthenticationEntryPoint()) 39 | } 40 | 41 | return http.build() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/config/security/TokenProvider.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.config.security 2 | 3 | import io.jsonwebtoken.ExpiredJwtException 4 | import io.jsonwebtoken.Jwts 5 | import io.jsonwebtoken.MalformedJwtException 6 | import io.jsonwebtoken.UnsupportedJwtException 7 | import io.jsonwebtoken.io.Decoders 8 | import io.jsonwebtoken.security.Keys 9 | import org.slf4j.LoggerFactory 10 | import org.springframework.beans.factory.annotation.Value 11 | import org.springframework.data.repository.findByIdOrNull 12 | import org.springframework.stereotype.Component 13 | import java.util.Date 14 | 15 | @Component 16 | class TokenProvider( 17 | private val refreshTokenRepository: RefreshTokenRepository, 18 | @Value("\${app.auth.jwt.base64-secret}") 19 | private val base64Secret: String, 20 | @Value("\${app.auth.jwt.expired-ms}") 21 | private val jwtExpiredMs: Long, 22 | @Value("\${app.auth.refresh-token.expired-ms}") 23 | private val refreshTokenExpiredMs: Long, 24 | ) { 25 | private val log = LoggerFactory.getLogger(javaClass) 26 | 27 | fun createAccessToken(userId: Long): String { 28 | val now = Date() 29 | val expiration = Date(now.time + jwtExpiredMs) 30 | 31 | return Jwts 32 | .builder() 33 | .claims(mapOf("sub" to userId.toString())) 34 | .issuedAt(now) 35 | .expiration(expiration) 36 | .signWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret))) 37 | .compact() 38 | } 39 | 40 | fun extractUserId(accessToken: String): Long { 41 | val claims = 42 | Jwts 43 | .parser() 44 | .verifyWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret))) 45 | .build() 46 | .parseSignedClaims(accessToken) 47 | .payload 48 | return claims["sub"].toString().toLong() 49 | } 50 | 51 | fun validateAccessToken(accessToken: String): Boolean { 52 | try { 53 | Jwts 54 | .parser() 55 | .verifyWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret))) 56 | .build() 57 | .parseSignedClaims(accessToken) 58 | 59 | return true 60 | } catch (ex: MalformedJwtException) { 61 | log.error("Invalid JWT token") 62 | } catch (ex: ExpiredJwtException) { 63 | log.error("Expired JWT token") 64 | } catch (ex: UnsupportedJwtException) { 65 | log.error("Unsupported JWT token") 66 | } catch (ex: IllegalArgumentException) { 67 | log.error("JWT claims string is empty.") 68 | } 69 | return false 70 | } 71 | 72 | fun createRefreshToken(userId: Long) = 73 | refreshTokenRepository 74 | .save( 75 | RefreshToken(userId = userId, ttl = refreshTokenExpiredMs / 1000), 76 | ).token!! 77 | 78 | fun expireRefreshToken(refreshToken: String) = refreshTokenRepository.deleteById(refreshToken) 79 | 80 | fun getRefreshToken(refreshToken: String) = refreshTokenRepository.findByIdOrNull(refreshToken) 81 | } 82 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/core/AdventureGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.core 2 | 3 | import java.util.concurrent.ThreadLocalRandom 4 | 5 | class AdventureGenerator { 6 | companion object { 7 | private val random = ThreadLocalRandom.current() 8 | 9 | fun generateY(isFlying: Boolean): Int = if (isFlying) random.nextInt(5, 15) else random.nextInt(30, 70) 10 | 11 | fun generateMotion(): Motion { 12 | val duration = random.nextInt(30, 180) 13 | val toRight = random.nextBoolean() 14 | val d = if (toRight) -1 else 1 15 | 16 | val x1 = random.nextFloat(20f, 80f) 17 | val x2 = if (toRight) x1 + random.nextFloat() * (80 - x1) else x1 - random.nextFloat() * x1 18 | val x3 = if (toRight) x2 + random.nextFloat() * (80 - x2) else x2 - random.nextFloat() * x2 19 | val positions = 20 | listOf( 21 | Position(x1, d), 22 | Position(x2, d), 23 | Position(x3, d), 24 | Position(x3, d * -1), 25 | Position(if (toRight) random.nextFloat(x1, x3) else random.nextFloat(x3, x1), d * -1), 26 | Position(x1, d * -1), 27 | ) 28 | 29 | return Motion( 30 | duration, 31 | positions, 32 | ) 33 | } 34 | } 35 | 36 | data class Motion( 37 | val duration: Int, 38 | val positions: List, 39 | val namePositions: List = 40 | positions.map { 41 | NamePosition( 42 | x = if (it.direction == -1) it.x - 3.5f else it.x - 0.3f, 43 | ) 44 | }, 45 | ) 46 | 47 | data class Position( 48 | val x: Float, 49 | val direction: Int, 50 | ) 51 | 52 | data class NamePosition( 53 | val x: Float, 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/core/BaseEntity.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.core 2 | 3 | import com.github.f4b6a3.tsid.TsidCreator 4 | import jakarta.persistence.Column 5 | import jakarta.persistence.EntityListeners 6 | import jakarta.persistence.Id 7 | import jakarta.persistence.MappedSuperclass 8 | import jakarta.persistence.PostLoad 9 | import jakarta.persistence.PostPersist 10 | import org.hibernate.proxy.HibernateProxy 11 | import org.springframework.data.annotation.CreatedDate 12 | import org.springframework.data.annotation.LastModifiedDate 13 | import org.springframework.data.domain.Persistable 14 | import org.springframework.data.jpa.domain.support.AuditingEntityListener 15 | import java.io.Serializable 16 | import java.time.Instant 17 | import java.util.Objects 18 | 19 | @MappedSuperclass 20 | @EntityListeners(AuditingEntityListener::class) 21 | class BaseEntity : Persistable { 22 | @Id 23 | @Column(name = "id") 24 | private val id: Long = TsidCreator.getTsid().toLong() 25 | 26 | @CreatedDate 27 | @Column(nullable = false, updatable = false) 28 | var createdDate: Instant = Instant.now() 29 | protected set 30 | 31 | @LastModifiedDate 32 | @Column(nullable = false) 33 | var lastModifiedDate: Instant = Instant.now() 34 | protected set 35 | 36 | @Transient 37 | private var isNew = true 38 | 39 | override fun getId() = id 40 | 41 | override fun isNew() = isNew 42 | 43 | override fun equals(other: Any?): Boolean { 44 | if (other == null) { 45 | return false 46 | } 47 | 48 | if (other !is HibernateProxy && this::class != other::class) { 49 | return false 50 | } 51 | 52 | return id == getIdentifier(other) 53 | } 54 | 55 | private fun getIdentifier(obj: Any): Serializable = 56 | if (obj is HibernateProxy) { 57 | obj.hibernateLazyInitializer.identifier as Serializable 58 | } else { 59 | (obj as BaseEntity).id 60 | } 61 | 62 | override fun hashCode() = Objects.hashCode(id) 63 | 64 | @PostPersist 65 | @PostLoad 66 | protected fun load() { 67 | isNew = false 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/core/NoArgs.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.core 2 | 3 | annotation class NoArgs 4 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/core/error/AnimationExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.core.error 2 | 3 | import com.doongjun.commitmon.animation.AdventureController 4 | import org.springframework.http.MediaType 5 | import org.springframework.http.ResponseEntity 6 | import org.springframework.web.bind.MissingServletRequestParameterException 7 | import org.springframework.web.bind.annotation.ExceptionHandler 8 | import org.springframework.web.bind.annotation.RestControllerAdvice 9 | import org.springframework.web.context.request.WebRequest 10 | import org.thymeleaf.context.Context 11 | import org.thymeleaf.spring6.SpringTemplateEngine 12 | 13 | @RestControllerAdvice(basePackageClasses = [AdventureController::class]) 14 | class AnimationExceptionHandler( 15 | private val svgTemplateEngine: SpringTemplateEngine, 16 | ) { 17 | @ExceptionHandler(Exception::class) 18 | fun handleException(e: Exception): ResponseEntity = 19 | getErrorSvg("An error occurred while processing the request.").let { svg -> 20 | ResponseEntity 21 | .internalServerError() 22 | .contentType(MediaType("image", "svg+xml")) 23 | .body(svg) 24 | } 25 | 26 | @ExceptionHandler(MissingServletRequestParameterException::class) 27 | fun handleMissingServletRequestParameterException(e: MissingServletRequestParameterException): ResponseEntity = 28 | getErrorSvg(e.message).let { svg -> 29 | ResponseEntity 30 | .badRequest() 31 | .contentType(MediaType("image", "svg+xml")) 32 | .body(svg) 33 | } 34 | 35 | @ExceptionHandler(IllegalArgumentException::class) 36 | fun handleIllegalArgumentException( 37 | e: IllegalArgumentException, 38 | request: WebRequest, 39 | ): ResponseEntity = 40 | getErrorSvg("Could not resolve to a User with the username of '${request.getParameter("username")}'.").let { svg -> 41 | ResponseEntity 42 | .badRequest() 43 | .contentType(MediaType("image", "svg+xml")) 44 | .body(svg) 45 | } 46 | 47 | private fun getErrorSvg(errorMessage: String): String = 48 | svgTemplateEngine 49 | .process( 50 | "error", 51 | Context().apply { 52 | setVariable("errorMessage", errorMessage) 53 | }, 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/core/error/GlobalExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.core.error 2 | 3 | import com.doongjun.commitmon.core.error.response.ErrorCode 4 | import com.doongjun.commitmon.core.error.response.ErrorResponse 5 | import org.slf4j.LoggerFactory 6 | import org.springframework.http.HttpStatus 7 | import org.springframework.http.ResponseEntity 8 | import org.springframework.http.converter.HttpMessageNotReadableException 9 | import org.springframework.security.access.AccessDeniedException 10 | import org.springframework.security.authentication.AccountExpiredException 11 | import org.springframework.validation.BindException 12 | import org.springframework.web.HttpRequestMethodNotSupportedException 13 | import org.springframework.web.bind.MethodArgumentNotValidException 14 | import org.springframework.web.bind.MissingServletRequestParameterException 15 | import org.springframework.web.bind.annotation.ExceptionHandler 16 | import org.springframework.web.bind.annotation.RestController 17 | import org.springframework.web.bind.annotation.RestControllerAdvice 18 | import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException 19 | 20 | @RestControllerAdvice(annotations = [RestController::class]) 21 | class GlobalExceptionHandler { 22 | private val log = LoggerFactory.getLogger(javaClass) 23 | 24 | @ExceptionHandler(Exception::class) 25 | protected fun handleException(e: Exception): ResponseEntity { 26 | log.error("Exception", e) 27 | val response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR) 28 | return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response) 29 | } 30 | 31 | @ExceptionHandler(BindException::class) 32 | protected fun handleBindException(e: BindException): ResponseEntity { 33 | log.error("BindException", e) 34 | val response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.bindingResult) 35 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response) 36 | } 37 | 38 | @ExceptionHandler(MissingServletRequestParameterException::class) 39 | protected fun handleMissingServletRequestParameterException(e: MissingServletRequestParameterException): ResponseEntity { 40 | log.error("MethodArgumentTypeMismatchException", e) 41 | val response = ErrorResponse.of(e) 42 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response) 43 | } 44 | 45 | @ExceptionHandler(MethodArgumentTypeMismatchException::class) 46 | protected fun handleMethodArgumentTypeMismatchException(e: MethodArgumentTypeMismatchException): ResponseEntity { 47 | log.error("MethodArgumentTypeMismatchException", e) 48 | val response = ErrorResponse.of(e) 49 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response) 50 | } 51 | 52 | @ExceptionHandler(HttpRequestMethodNotSupportedException::class) 53 | protected fun handleHttpRequestMethodNotSupportedException(e: HttpRequestMethodNotSupportedException): ResponseEntity { 54 | log.error("HttpRequestMethodNotSupportedException", e) 55 | val response = ErrorResponse.of(ErrorCode.METHOD_NOT_ALLOWED) 56 | return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(response) 57 | } 58 | 59 | @ExceptionHandler(HttpMessageNotReadableException::class) 60 | protected fun handleHttpMessageNotReadableException(e: HttpMessageNotReadableException): ResponseEntity { 61 | log.error("HttpMessageNotReadableException", e) 62 | val response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE) 63 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response) 64 | } 65 | 66 | @ExceptionHandler(MethodArgumentNotValidException::class) 67 | protected fun handleMethodArgumentNotValidException(e: MethodArgumentNotValidException): ResponseEntity { 68 | log.error("MethodArgumentNotValidException", e) 69 | val response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.bindingResult) 70 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response) 71 | } 72 | 73 | @ExceptionHandler(AccessDeniedException::class) 74 | protected fun handleAccessDeniedException(e: AccessDeniedException): ResponseEntity { 75 | log.error("AccessDeniedException", e) 76 | val response = ErrorResponse.of(ErrorCode.ACCESS_DENIED) 77 | return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response) 78 | } 79 | 80 | @ExceptionHandler(AccountExpiredException::class) 81 | protected fun handleAccountExpiredException(e: AccountExpiredException): ResponseEntity { 82 | log.error("AccountExpiredException", e) 83 | val response = ErrorResponse.of(ErrorCode.UNAUTHORIZED) 84 | return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response) 85 | } 86 | 87 | @ExceptionHandler(IllegalArgumentException::class) 88 | protected fun handleIllegalArgumentException(e: IllegalArgumentException): ResponseEntity { 89 | log.error("IllegalArgumentException", e) 90 | val response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE) 91 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response) 92 | } 93 | 94 | @ExceptionHandler(NoSuchElementException::class) 95 | protected fun handleNoSuchElementException(e: NoSuchElementException): ResponseEntity { 96 | log.error("NoSuchElementException", e) 97 | val response = ErrorResponse.of(ErrorCode.NOT_FOUND) 98 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/core/error/response/ErrorCode.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.core.error.response 2 | 3 | enum class ErrorCode( 4 | val status: Int, 5 | val code: String, 6 | val message: String, 7 | ) { 8 | INVALID_INPUT_VALUE(400, "A001", "Invalid input value."), 9 | METHOD_NOT_ALLOWED(405, "A002", "Invalid input value."), 10 | INTERNAL_SERVER_ERROR(500, "A003", "Server Error."), 11 | NOT_FOUND(404, "A004", "Not Found."), 12 | INVALID_TYPE_VALUE(400, "A005", "Invalid type value."), 13 | ACCESS_DENIED(403, "A006", "Access is denied."), 14 | UNAUTHORIZED(401, "A007", "Unauthorized."), 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/core/error/response/ErrorResponse.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.core.error.response 2 | 3 | import org.springframework.validation.BindingResult 4 | import org.springframework.web.bind.MissingServletRequestParameterException 5 | import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException 6 | 7 | data class ErrorResponse private constructor( 8 | val message: String, 9 | val status: Int, 10 | val code: String, 11 | val errors: List, 12 | ) { 13 | companion object { 14 | fun of(errorCode: ErrorCode) = ErrorResponse(errorCode) 15 | 16 | fun of( 17 | errorCode: ErrorCode, 18 | bindingResult: BindingResult, 19 | ) = ErrorResponse(errorCode, FieldError.of(bindingResult)) 20 | 21 | fun of( 22 | errorCode: ErrorCode, 23 | errors: List, 24 | ) = ErrorResponse(errorCode, errors) 25 | 26 | fun of(e: MethodArgumentTypeMismatchException) = 27 | ErrorResponse(ErrorCode.INVALID_TYPE_VALUE, FieldError.of(e.name, e.value.toString(), e.errorCode)) 28 | 29 | fun of(e: MissingServletRequestParameterException) = 30 | ErrorResponse(ErrorCode.INVALID_INPUT_VALUE, FieldError.of(e.parameterName, null, e.message)) 31 | } 32 | 33 | private constructor(errorCode: ErrorCode, errors: List = emptyList()) : this( 34 | message = errorCode.message, 35 | status = errorCode.status, 36 | code = errorCode.code, 37 | errors = errors, 38 | ) 39 | } 40 | 41 | data class FieldError private constructor( 42 | val field: String? = "", 43 | val value: String? = "", 44 | val reason: String? = "", 45 | ) { 46 | companion object { 47 | fun of( 48 | field: String?, 49 | value: String?, 50 | reason: String?, 51 | ) = listOf(FieldError(field, value, reason)) 52 | 53 | fun of(bindingResult: BindingResult) = 54 | bindingResult.fieldErrors.map { error -> 55 | FieldError(error.field, error.rejectedValue?.toString(), error.defaultMessage) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/domain/CommitmonLevel.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.domain 2 | 3 | enum class CommitmonLevel( 4 | val exp: Long, 5 | val order: Int, 6 | ) { 7 | EGG(0, 0), // 알 8 | BABY(100, 1), // 유아기 9 | IN_TRAINING(200, 2), // 유년기 10 | ROOKIE(400, 3), // 성장기 11 | CHAMPION(800, 4), // 성숙기 12 | PERFECT(1600, 5), // 완전체 13 | ULTIMATE(3200, 6), // 궁극체 14 | ; 15 | 16 | fun nextLevel(): CommitmonLevel = entries.firstOrNull { it.order == this.order + 1 } ?: this 17 | 18 | companion object { 19 | fun fromExp(exp: Long): CommitmonLevel = entries.last { exp >= it.exp } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/domain/Follow.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.domain 2 | 3 | import com.doongjun.commitmon.core.BaseEntity 4 | import jakarta.persistence.Entity 5 | import jakarta.persistence.FetchType.LAZY 6 | import jakarta.persistence.Index 7 | import jakarta.persistence.JoinColumn 8 | import jakarta.persistence.ManyToOne 9 | import jakarta.persistence.Table 10 | import jakarta.persistence.UniqueConstraint 11 | 12 | @Entity 13 | @Table( 14 | name = "follow", 15 | uniqueConstraints = [ 16 | UniqueConstraint(name = "UNQ_FOLLOW_FOLLOWER_FOLLOWING", columnNames = ["follower_id", "following_id"]), 17 | ], 18 | indexes = [ 19 | Index(name = "IDX_FOLLOW_FOLLOWER_ID", columnList = "follower_id"), 20 | Index(name = "IDX_FOLLOW_FOLLOWING_ID", columnList = "following_id"), 21 | ], 22 | ) 23 | class Follow( 24 | @ManyToOne(fetch = LAZY) 25 | @JoinColumn(name = "follower_id") 26 | val follower: User, 27 | @ManyToOne(fetch = LAZY) 28 | @JoinColumn(name = "following_id") 29 | val following: User, 30 | ) : BaseEntity() 31 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/domain/User.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.domain 2 | 3 | import com.doongjun.commitmon.core.BaseEntity 4 | import jakarta.persistence.CascadeType.ALL 5 | import jakarta.persistence.Column 6 | import jakarta.persistence.Entity 7 | import jakarta.persistence.EnumType 8 | import jakarta.persistence.Enumerated 9 | import jakarta.persistence.FetchType.LAZY 10 | import jakarta.persistence.Index 11 | import jakarta.persistence.OneToMany 12 | import jakarta.persistence.Table 13 | 14 | @Entity 15 | @Table( 16 | name = "`user`", 17 | indexes = [ 18 | Index(name = "IDX_USER_NAME", columnList = "name", unique = true), 19 | ], 20 | ) 21 | class User( 22 | @Column(name = "name", unique = true, nullable = false) 23 | val name: String, 24 | totalCommitCount: Long = 0, 25 | followers: List = emptyList(), 26 | following: List = emptyList(), 27 | ) : BaseEntity() { 28 | @Column(name = "total_commit_count", nullable = false) 29 | var totalCommitCount: Long = totalCommitCount 30 | protected set 31 | 32 | @Enumerated(EnumType.STRING) 33 | @Column(name = "commitmon", nullable = false) 34 | var commitmon: Commitmon = Commitmon.randomCommitmon(CommitmonLevel.fromExp(totalCommitCount)) 35 | protected set 36 | 37 | @Column(name = "auto_level_up", nullable = false) 38 | var autoLevelUp: Boolean = true 39 | 40 | @OneToMany(mappedBy = "follower", fetch = LAZY, cascade = [ALL], orphanRemoval = true) 41 | protected val mutableFollowers: MutableSet = toFollowers(followers).toMutableSet() 42 | val followers: List get() = mutableFollowers.map { it.following } 43 | 44 | @OneToMany(mappedBy = "following", fetch = LAZY, cascade = [ALL], orphanRemoval = true) 45 | protected val mutableFollowing: MutableSet = toFollowing(following).toMutableSet() 46 | val following: List get() = mutableFollowing.map { it.follower } 47 | 48 | val mutualFollowers: List get() = following.filter { it in followers } 49 | 50 | val exp: Int get() { 51 | return if (commitmon.level == CommitmonLevel.ULTIMATE) { 52 | 100 53 | } else { 54 | ((this.totalCommitCount - commitmon.level.exp).toDouble() / (commitmon.level.nextLevel().exp - commitmon.level.exp) * 100) 55 | .toInt() 56 | } 57 | } 58 | 59 | fun update( 60 | totalCommitCount: Long, 61 | followers: List, 62 | following: List, 63 | ) { 64 | this.totalCommitCount = totalCommitCount 65 | updateFollowers(followers) 66 | updateFollowing(following) 67 | 68 | if (this.autoLevelUp) { 69 | autoLevelUpCommitmon(CommitmonLevel.fromExp(totalCommitCount)) 70 | } 71 | } 72 | 73 | fun changeCommitmon(commitmon: Commitmon) { 74 | val currentLevel = CommitmonLevel.fromExp(totalCommitCount) 75 | if (commitmon.level.order > currentLevel.order) { 76 | throw IllegalArgumentException("Cannot change to higher level") 77 | } 78 | 79 | this.commitmon = commitmon 80 | this.autoLevelUp = commitmon.level == currentLevel 81 | } 82 | 83 | private fun autoLevelUpCommitmon(level: CommitmonLevel) { 84 | if (this.commitmon.level != level) { 85 | this.commitmon = Commitmon.randomLevelTreeCommitmon(level, this.commitmon) 86 | } 87 | } 88 | 89 | private fun updateFollowers(followers: List) { 90 | val removals = this.followers - followers.toSet() 91 | val additions = followers - this.followers.toSet() 92 | mutableFollowers.removeAll { it.following in removals } 93 | mutableFollowers.addAll(toFollowers(additions)) 94 | } 95 | 96 | private fun updateFollowing(following: List) { 97 | val removals = this.following - following.toSet() 98 | val additions = following - this.following.toSet() 99 | mutableFollowing.removeAll { it.follower in removals } 100 | mutableFollowing.addAll(toFollowing(additions)) 101 | } 102 | 103 | private fun toFollowers(followers: List) = followers.map { Follow(follower = this, following = it) }.toMutableSet() 104 | 105 | private fun toFollowing(following: List) = following.map { Follow(follower = it, following = this) }.toMutableSet() 106 | } 107 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/domain/UserRepository.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.domain 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | import org.springframework.stereotype.Repository 5 | 6 | @Repository 7 | interface UserRepository : JpaRepository { 8 | fun findByName(name: String): User? 9 | 10 | fun findAllByNameIn(names: List): List 11 | 12 | fun existsByName(name: String): Boolean 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/event/ApplicationEventListener.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.event 2 | 3 | import com.doongjun.commitmon.app.GithubService 4 | import com.doongjun.commitmon.app.UserFetchType 5 | import com.doongjun.commitmon.app.UserService 6 | import com.doongjun.commitmon.app.data.UpdateUserDto 7 | import org.slf4j.LoggerFactory 8 | import org.springframework.context.event.EventListener 9 | import org.springframework.scheduling.annotation.Async 10 | import org.springframework.stereotype.Component 11 | 12 | @Component 13 | class ApplicationEventListener( 14 | private val userService: UserService, 15 | private val githubService: GithubService, 16 | ) { 17 | private val log = LoggerFactory.getLogger(javaClass) 18 | 19 | @Async 20 | @EventListener(UpdateUserInfo::class) 21 | fun handleUpdateUserInfo(event: UpdateUserInfo) { 22 | val user = userService.get(event.userId, UserFetchType.SOLO) 23 | 24 | log.info("Updating user info: ${user.name}") 25 | val (totalCommitCount) = githubService.getUserCommitInfo(user.name) 26 | val (followerNames, followingNames) = githubService.getUserFollowInfo(user.name, 100) 27 | 28 | UpdateUserDto( 29 | totalCommitCount = totalCommitCount, 30 | followerNames = followerNames, 31 | followingNames = followingNames, 32 | ).let { dto -> 33 | userService.update(user.id, dto) 34 | } 35 | log.info("User info updated: ${user.name}") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/event/UpdateUserInfo.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.event 2 | 3 | data class UpdateUserInfo( 4 | val userId: Long, 5 | ) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/extension/EnumExtension.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.extension 2 | 3 | inline infix fun , V> ((E) -> V).findBy(value: V): E? = enumValues().firstOrNull { this(it) == value } 4 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/extension/SecurityExtension.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.extension 2 | 3 | import org.springframework.security.core.context.SecurityContextHolder 4 | 5 | val currentUserId get() = SecurityContextHolder.getContext().authentication?.principal as Long? 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/extension/StringExtension.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.extension 2 | 3 | import com.fasterxml.jackson.core.type.TypeReference 4 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 5 | 6 | fun Any.convertToString(): String = jacksonObjectMapper().writeValueAsString(this) 7 | 8 | fun String.convertToObject(): T { 9 | val typeReference = object : TypeReference() {} 10 | return jacksonObjectMapper().readValue(this, typeReference) 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/infra/GithubGraphqlApi.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.infra 2 | 3 | import com.doongjun.commitmon.infra.data.GraphqlRequest 4 | import com.doongjun.commitmon.infra.data.GraphqlResponse 5 | import com.doongjun.commitmon.infra.data.UserFollowInfoResponse 6 | import com.doongjun.commitmon.infra.data.UserFollowInfoVariables 7 | import com.doongjun.commitmon.infra.data.UserFollowersResponse 8 | import com.doongjun.commitmon.infra.data.UserFollowingResponse 9 | import org.slf4j.LoggerFactory 10 | import org.springframework.core.ParameterizedTypeReference 11 | import org.springframework.core.io.ClassPathResource 12 | import org.springframework.stereotype.Component 13 | import org.springframework.web.reactive.function.client.WebClient 14 | import java.nio.charset.Charset 15 | 16 | @Component 17 | class GithubGraphqlApi( 18 | private val githubGraphqlWebClient: WebClient, 19 | ) { 20 | private val log = LoggerFactory.getLogger(javaClass) 21 | 22 | companion object { 23 | private val userFollowInfoQuery = 24 | ClassPathResource( 25 | "graphql/user-follow-info-query.graphql", 26 | ).getContentAsString(Charset.defaultCharset()) 27 | 28 | private val userFollowersQuery = 29 | ClassPathResource( 30 | "graphql/user-followers-query.graphql", 31 | ).getContentAsString(Charset.defaultCharset()) 32 | 33 | private val userFollowingQuery = 34 | ClassPathResource( 35 | "graphql/user-following-query.graphql", 36 | ).getContentAsString(Charset.defaultCharset()) 37 | } 38 | 39 | fun fetchUserFollowInfo( 40 | username: String, 41 | size: Int, 42 | ): UserFollowInfoResponse.User { 43 | val variables = 44 | UserFollowInfoVariables( 45 | login = username, 46 | first = size, 47 | ) 48 | val requestBody = 49 | GraphqlRequest( 50 | query = userFollowInfoQuery, 51 | variables = variables, 52 | ) 53 | val response = 54 | githubGraphqlWebClient 55 | .post() 56 | .bodyValue(requestBody) 57 | .retrieve() 58 | .bodyToMono(object : ParameterizedTypeReference>() {}) 59 | .onErrorMap { error -> 60 | log.error(error.message) 61 | throw IllegalArgumentException("Failed to fetch user follow info: $error") 62 | }.block()!! 63 | 64 | if (isError(response)) { 65 | log.error("Failed to fetch user follow info: ${response.errors}") 66 | throw IllegalArgumentException("Failed to fetch user follow info: ${response.errors}") 67 | } 68 | 69 | return response.data.user!! 70 | } 71 | 72 | fun fetchUserFollowers( 73 | username: String, 74 | size: Int, 75 | after: String? = null, 76 | ): UserFollowersResponse.User { 77 | val variables = 78 | UserFollowInfoVariables( 79 | login = username, 80 | first = size, 81 | after = after, 82 | ) 83 | val requestBody = 84 | GraphqlRequest( 85 | query = userFollowersQuery, 86 | variables = variables, 87 | ) 88 | val response = 89 | githubGraphqlWebClient 90 | .post() 91 | .bodyValue(requestBody) 92 | .retrieve() 93 | .bodyToMono(object : ParameterizedTypeReference>() {}) 94 | .onErrorMap { error -> 95 | log.error(error.message) 96 | throw IllegalArgumentException("Failed to fetch user followers: $error") 97 | }.block()!! 98 | 99 | if (isError(response)) { 100 | log.error("Failed to fetch user followers: ${response.errors}") 101 | throw IllegalArgumentException("Failed to fetch user followers: ${response.errors}") 102 | } 103 | 104 | return response.data.user!! 105 | } 106 | 107 | fun fetchUserFollowing( 108 | username: String, 109 | size: Int, 110 | after: String? = null, 111 | ): UserFollowingResponse.User { 112 | val variables = 113 | UserFollowInfoVariables( 114 | login = username, 115 | first = size, 116 | after = after, 117 | ) 118 | val requestBody = 119 | GraphqlRequest( 120 | query = userFollowingQuery, 121 | variables = variables, 122 | ) 123 | val response = 124 | githubGraphqlWebClient 125 | .post() 126 | .bodyValue(requestBody) 127 | .retrieve() 128 | .bodyToMono(object : ParameterizedTypeReference>() {}) 129 | .onErrorMap { error -> 130 | log.error(error.message) 131 | throw IllegalArgumentException("Failed to fetch user following: $error") 132 | }.block()!! 133 | 134 | if (isError(response)) { 135 | log.error("Failed to fetch user following: ${response.errors}") 136 | throw IllegalArgumentException("Failed to fetch user following: ${response.errors}") 137 | } 138 | 139 | return response.data.user!! 140 | } 141 | 142 | private fun isError(response: GraphqlResponse<*>): Boolean = response.errors != null 143 | } 144 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/infra/GithubOAuth2Api.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.infra 2 | 3 | import com.doongjun.commitmon.infra.data.OAuthLoginResponse 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.stereotype.Component 6 | import org.springframework.web.reactive.function.client.WebClient 7 | 8 | @Component 9 | class GithubOAuth2Api( 10 | private val githubOAuth2WebClient: WebClient, 11 | ) { 12 | private val log = LoggerFactory.getLogger(javaClass) 13 | 14 | fun fetchAccessToken( 15 | code: String, 16 | clientId: String, 17 | clientSecret: String, 18 | ): OAuthLoginResponse = 19 | githubOAuth2WebClient 20 | .post() 21 | .uri { uriBuilder -> 22 | uriBuilder 23 | .path("/access_token") 24 | .queryParam("client_id", clientId) 25 | .queryParam("client_secret", clientSecret) 26 | .queryParam("code", code) 27 | .build() 28 | }.retrieve() 29 | .bodyToMono(OAuthLoginResponse::class.java) 30 | .onErrorMap { error -> 31 | log.error(error.message) 32 | throw IllegalArgumentException("Failed to fetch access token: $error") 33 | }.block()!! 34 | } 35 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/infra/GithubRestApi.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.infra 2 | 3 | import com.doongjun.commitmon.infra.data.UserCommitSearchResponse 4 | import com.doongjun.commitmon.infra.data.UserInfoResponse 5 | import org.slf4j.LoggerFactory 6 | import org.springframework.beans.factory.annotation.Value 7 | import org.springframework.stereotype.Component 8 | import org.springframework.web.reactive.function.client.WebClient 9 | 10 | @Component 11 | class GithubRestApi( 12 | private val githubRestWebClient: WebClient, 13 | @Value("\${app.github.token}") 14 | private val githubToken: String, 15 | ) { 16 | private val log = LoggerFactory.getLogger(javaClass) 17 | 18 | fun fetchUserInfo(userToken: String): String = 19 | githubRestWebClient 20 | .get() 21 | .uri { uriBuilder -> 22 | uriBuilder 23 | .path("/user") 24 | .build() 25 | }.headers { headers -> 26 | headers.add("Authorization", "Bearer $userToken") 27 | }.retrieve() 28 | .bodyToMono(UserInfoResponse::class.java) 29 | .onErrorMap { error -> 30 | log.error(error.message) 31 | throw IllegalArgumentException("Failed to fetch user: $error") 32 | }.block()!! 33 | .login 34 | 35 | fun fetchUserCommitSearchInfo(username: String): UserCommitSearchResponse = 36 | githubRestWebClient 37 | .get() 38 | .uri { uriBuilder -> 39 | uriBuilder 40 | .path("/search/commits") 41 | .queryParam("q", "author:$username") 42 | .queryParam("per_page", 1) 43 | .build() 44 | }.headers { headers -> 45 | headers.add("Authorization", "Bearer $githubToken") 46 | }.retrieve() 47 | .bodyToMono(UserCommitSearchResponse::class.java) 48 | .onErrorMap { error -> 49 | log.error(error.message) 50 | throw IllegalArgumentException("Failed to fetch user commit count: $error") 51 | }.block()!! 52 | } 53 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/infra/data/GraphqlRequest.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.infra.data 2 | 3 | data class GraphqlRequest( 4 | val query: String, 5 | val variables: T, 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/infra/data/GraphqlResponse.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.infra.data 2 | 3 | data class GraphqlResponse( 4 | val data: T, 5 | val errors: List?, 6 | ) 7 | 8 | data class GraphqlError( 9 | val message: String, 10 | val type: String, 11 | ) 12 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/infra/data/OAuthLoginResponse.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.infra.data 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty 4 | 5 | data class OAuthLoginResponse( 6 | @JsonProperty("access_token") 7 | val accessToken: String, 8 | ) 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/infra/data/UserCommitSearchResponse.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.infra.data 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty 4 | 5 | data class UserCommitSearchResponse( 6 | @JsonProperty("total_count") 7 | val totalCount: Long, 8 | ) 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/infra/data/UserFollowInfoResponse.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.infra.data 2 | 3 | data class UserFollowInfoResponse( 4 | val user: User?, 5 | ) { 6 | data class User( 7 | val followers: FollowInfo, 8 | val following: FollowInfo, 9 | ) 10 | } 11 | 12 | data class FollowInfo( 13 | val totalCount: Int, 14 | val pageInfo: FollowPageInfo, 15 | val nodes: List, 16 | ) 17 | 18 | data class FollowPageInfo( 19 | val hasNextPage: Boolean, 20 | val endCursor: String?, 21 | ) 22 | 23 | data class FollowNode( 24 | val login: String, 25 | ) 26 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/infra/data/UserFollowInfoVariables.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.infra.data 2 | 3 | data class UserFollowInfoVariables( 4 | val login: String, 5 | val first: Int, 6 | val after: String? = null, 7 | ) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/infra/data/UserFollowersResponse.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.infra.data 2 | 3 | data class UserFollowersResponse( 4 | val user: User?, 5 | ) { 6 | data class User( 7 | val followers: FollowInfo, 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/infra/data/UserFollowingResponse.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.infra.data 2 | 3 | data class UserFollowingResponse( 4 | val user: User?, 5 | ) { 6 | data class User( 7 | val following: FollowInfo, 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/doongjun/commitmon/infra/data/UserInfoResponse.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.infra.data 2 | 3 | data class UserInfoResponse( 4 | val login: String, 5 | ) 6 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: ${DB_URL} 4 | username: ${DB_USERNAME} 5 | password: ${DB_PASSWORD} 6 | driver-class-name: org.mariadb.jdbc.Driver 7 | jpa: 8 | hibernate: 9 | ddl-auto: update 10 | properties: 11 | hibernate: 12 | default_batch_fetch_size: 100 13 | dialect: org.hibernate.dialect.MariaDBDialect 14 | data: 15 | redis: 16 | host: ${REDIS_HOST} 17 | port: 6379 18 | 19 | server: 20 | tomcat: 21 | relaxed-query-chars: "{,}" 22 | 23 | app: 24 | auth: 25 | jwt: 26 | base64-secret: ${JWT_SECRET} 27 | expired-ms: 3600000 # 1hour 28 | refresh-token: 29 | expired-ms: 1209600000 # 2weeks 30 | github: 31 | token: ${GITHUB_TOKEN} 32 | base-url: https://api.github.com 33 | oauth2: 34 | base-url: https://github.com/login/oauth 35 | client-id: ${GITHUB_CLIENT_ID} 36 | client-secret: ${GITHUB_CLIENT_SECRET} -------------------------------------------------------------------------------- /src/main/resources/graphql/user-follow-info-query.graphql: -------------------------------------------------------------------------------- 1 | query( 2 | $login: String!, 3 | $first: Int 4 | ) { 5 | user(login: $login) { 6 | followers(first: $first) { 7 | pageInfo { 8 | hasNextPage 9 | endCursor 10 | } 11 | nodes { 12 | login 13 | } 14 | } 15 | following(first: $first) { 16 | pageInfo { 17 | hasNextPage 18 | endCursor 19 | } 20 | nodes { 21 | login 22 | } 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /src/main/resources/graphql/user-followers-query.graphql: -------------------------------------------------------------------------------- 1 | query( 2 | $login: String!, 3 | $first: Int, 4 | $after: String 5 | ) { 6 | user(login: $login) { 7 | followers(first: $first, after: $after) { 8 | pageInfo { 9 | hasNextPage 10 | endCursor 11 | } 12 | nodes { 13 | login 14 | } 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/main/resources/graphql/user-following-query.graphql: -------------------------------------------------------------------------------- 1 | query( 2 | $login: String!, 3 | $first: Int, 4 | $after: String 5 | ) { 6 | user(login: $login) { 7 | following(first: $first, after: $after) { 8 | pageInfo { 9 | hasNextPage 10 | endCursor 11 | } 12 | nodes { 13 | login 14 | } 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/main/resources/static/theme/desert.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/main/resources/static/theme/grassland.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/main/resources/static/theme/transparent.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/main/resources/templates/asset/botamon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 11 | 12 | 14 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 25 | 26 | 27 | [[${#strings.abbreviate(username, 12)}]] 28 | 29 | 30 | 31 | 32 | 33 | 34 | 94 | 95 | -------------------------------------------------------------------------------- /src/main/resources/templates/asset/bubbmon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 11 | 12 | 13 | 14 | 16 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 29 | 30 | 31 | [[${#strings.abbreviate(username, 12)}]] 32 | 33 | 34 | 35 | 36 | 37 | 38 | 98 | 99 | -------------------------------------------------------------------------------- /src/main/resources/templates/asset/koromon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 11 | 12 | 13 | 15 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | [[${#strings.abbreviate(username, 12)}]] 30 | 31 | 32 | 33 | 34 | 35 | 36 | 96 | 97 | -------------------------------------------------------------------------------- /src/main/resources/templates/asset/nyokimon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12 | 14 | 16 | 17 | 18 | 20 | 22 | 24 | 26 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 37 | 38 | 39 | [[${#strings.abbreviate(username, 12)}]] 40 | 41 | 42 | 43 | 44 | 45 | 46 | 106 | 107 | -------------------------------------------------------------------------------- /src/main/resources/templates/asset/patamon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 11 | 13 | 14 | 15 | 16 | 18 | 20 | 21 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 33 | 34 | 35 | [[${#strings.abbreviate(username, 12)}]] 36 | 37 | 38 | 39 | 40 | 41 | 42 | 102 | 103 | -------------------------------------------------------------------------------- /src/main/resources/templates/asset/pichimon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 11 | 12 | 13 | 15 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | [[${#strings.abbreviate(username, 12)}]] 30 | 31 | 32 | 33 | 34 | 35 | 36 | 96 | 97 | -------------------------------------------------------------------------------- /src/main/resources/templates/asset/poyomon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 11 | 12 | 13 | 15 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | [[${#strings.abbreviate(username, 12)}]] 30 | 31 | 32 | 33 | 34 | 35 | 36 | 96 | 97 | -------------------------------------------------------------------------------- /src/main/resources/templates/asset/punimon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 11 | 12 | 13 | 15 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | [[${#strings.abbreviate(username, 12)}]] 30 | 31 | 32 | 33 | 34 | 35 | 36 | 96 | 97 | -------------------------------------------------------------------------------- /src/main/resources/templates/asset/pyokomon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | 12 | 14 | 16 | 17 | 18 | 19 | 21 | 22 | 23 | 25 | 27 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 39 | 40 | 41 | [[${#strings.abbreviate(username, 12)}]] 42 | 43 | 44 | 45 | 46 | 47 | 48 | 108 | 109 | -------------------------------------------------------------------------------- /src/main/resources/templates/asset/snowbotamon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 11 | 12 | 13 | 15 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | [[${#strings.abbreviate(username, 12)}]] 30 | 31 | 32 | 33 | 34 | 35 | 36 | 96 | 97 | -------------------------------------------------------------------------------- /src/main/resources/templates/asset/tsunomon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12 | 14 | 15 | 16 | 17 | 19 | 21 | 23 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | [[${#strings.abbreviate(username, 12)}]] 38 | 39 | 40 | 41 | 42 | 43 | 44 | 105 | 106 | -------------------------------------------------------------------------------- /src/main/resources/templates/asset/yuramon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 11 | 13 | 15 | 16 | 17 | 18 | 19 | 21 | 23 | 24 | 26 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 39 | 40 | 41 | [[${#strings.abbreviate(username, 12)}]] 42 | 43 | 44 | 45 | 46 | 47 | 48 | 108 | 109 | -------------------------------------------------------------------------------- /src/test/kotlin/com/doongjun/commitmon/app/BaseAppTest.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.app 2 | 3 | import com.doongjun.commitmon.container.TestMariaDBContainer 4 | import com.doongjun.commitmon.container.TestRedisContainer 5 | import jakarta.persistence.EntityManager 6 | import jakarta.persistence.PersistenceContext 7 | import org.springframework.boot.test.context.SpringBootTest 8 | import org.springframework.test.context.ActiveProfiles 9 | import org.springframework.transaction.annotation.Transactional 10 | import org.testcontainers.junit.jupiter.Container 11 | import org.testcontainers.junit.jupiter.Testcontainers 12 | 13 | @ActiveProfiles("test") 14 | @Testcontainers 15 | @Transactional 16 | @SpringBootTest 17 | abstract class BaseAppTest { 18 | @PersistenceContext 19 | private lateinit var entityManager: EntityManager 20 | 21 | protected fun clear() { 22 | entityManager.flush() 23 | entityManager.clear() 24 | } 25 | 26 | companion object { 27 | @Container 28 | val mariaDBContainer = TestMariaDBContainer.getInstance() 29 | 30 | @Container 31 | val redisContainer = TestRedisContainer.getInstance() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/kotlin/com/doongjun/commitmon/container/TestMariaDBContainer.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.container 2 | 3 | import org.testcontainers.containers.MariaDBContainer 4 | 5 | class TestMariaDBContainer : MariaDBContainer("mariadb:11.5.2") { 6 | companion object { 7 | private lateinit var container: TestMariaDBContainer 8 | 9 | fun getInstance(): TestMariaDBContainer { 10 | if (!Companion::container.isInitialized) { 11 | container = TestMariaDBContainer() 12 | } 13 | return container 14 | } 15 | } 16 | 17 | override fun start() { 18 | super.start() 19 | System.setProperty("DB_URL", container.jdbcUrl) 20 | System.setProperty("DB_USERNAME", container.username) 21 | System.setProperty("DB_PASSWORD", container.password) 22 | } 23 | 24 | override fun stop() { 25 | // do nothing, JVM handles shut down 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test/kotlin/com/doongjun/commitmon/container/TestRedisContainer.kt: -------------------------------------------------------------------------------- 1 | package com.doongjun.commitmon.container 2 | 3 | import org.testcontainers.containers.GenericContainer 4 | import org.testcontainers.utility.DockerImageName 5 | 6 | class TestRedisContainer : GenericContainer(DockerImageName.parse("redis:7.4.0")) { 7 | companion object { 8 | private lateinit var container: TestRedisContainer 9 | 10 | fun getInstance(): TestRedisContainer { 11 | if (!Companion::container.isInitialized) { 12 | container = TestRedisContainer().withExposedPorts(6379) 13 | } 14 | return container 15 | } 16 | } 17 | 18 | override fun start() { 19 | super.start() 20 | System.setProperty("REDIS_HOST", container.host) 21 | System.setProperty("REDIS_PORT", container.getMappedPort(6379).toString()) 22 | } 23 | 24 | override fun stop() { 25 | // do nothing, JVM handles shut down 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: ${DB_URL} 4 | username: ${DB_USERNAME} 5 | password: ${DB_PASSWORD} 6 | driver-class-name: org.mariadb.jdbc.Driver 7 | jpa: 8 | hibernate: 9 | ddl-auto: create 10 | properties: 11 | hibernate: 12 | default_batch_fetch_size: 100 13 | dialect: org.hibernate.dialect.MariaDBDialect 14 | show_sql: true 15 | format_sql: true 16 | data: 17 | redis: 18 | host: ${REDIS_HOST} 19 | port: ${REDIS_PORT} 20 | 21 | server: 22 | tomcat: 23 | relaxed-query-chars: "{,}" 24 | 25 | logging: 26 | level: 27 | org.hibernate.orm.jdbc.bind: TRACE 28 | 29 | app: 30 | auth: 31 | jwt: 32 | base64-secret: 12345 33 | expired-ms: 1000 34 | refresh-token: 35 | expired-ms: 1000 36 | github: 37 | token: 12345 38 | base-url: https://api.github.com 39 | oauth2: 40 | base-url: https://github.com/login/oauth 41 | client-id: 12345 42 | client-secret: 12345 --------------------------------------------------------------------------------