diff --git a/.github/workflows/docker-image.yaml b/.github/workflows/docker-image.yaml new file mode 100644 index 0000000..6918484 --- /dev/null +++ b/.github/workflows/docker-image.yaml @@ -0,0 +1,106 @@ +name: Build Docker Image (amd64 & arm64) +on: + release: + types: [published] + workflow_dispatch: + +jobs: + ImageBuild: + name: Build Wakatime-to-slack-status Custom Docker Image (amd64 & arm64) + runs-on: ubuntu-latest + steps: + - name: Slack notification of build start + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} + SLACK_USERNAME: GitHub Actions + SLACK_COLOR: "#4381de" + SLACK_ICON: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" + SLACK_TITLE: Start Wakatime-to-slack-status image build + SLACK_MESSAGE: | + Run number : #${{ github.run_number }} + + - name: Check out + uses: actions/checkout@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: walnuts1018 + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v2.5.0 + with: + version: latest + + - name: Cache Docker layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx + restore-keys: | + ${{ runner.os }}-buildx + + - name: Get Tag from Release + run: echo "ImageTag=${GITHUB_REF##*/}" >> $GITHUB_ENV + + - name: Build and push Docker images + uses: docker/build-push-action@v4.0.0 + if: github.event_name == 'release' + with: + push: true + context: . + platforms: linux/amd64,linux/arm64 + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache + tags: | + ghcr.io/walnuts1018/wakatime-to-slack-status:latest + ghcr.io/walnuts1018/wakatime-to-slack-status:${{ env.ImageTag }} + + - name: Build and push Docker images + uses: docker/build-push-action@v4.0.0 + if: github.event_name != 'release' + with: + push: true + context: . + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/walnuts1018/wakatime-to-slack-status:test-latest + ghcr.io/walnuts1018/wakatime-to-slack-status:test-${{ github.sha }}-${{ github.run_number }} + + SucceessNotification: + if: ${{ success() }} + name: Send Success Message + needs: [ImageBuild] + runs-on: ubuntu-latest + steps: + - name: Send Message to Slack + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} + SLACK_USERNAME: GitHub Actions + SLACK_ICON: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" + SLACK_TITLE: Wakatime-to-slack-status id:mage build succeeded + SLACK_MESSAGE: | + Run number : #${{ github.run_number }} + Image tag : ${{ github.sha }}-${{ github.run_number }} + Image URL : + + FailureAlert: + if: ${{ failure() }} + name: Notify failure + needs: [ImageBuild] + runs-on: ubuntu-latest + steps: + - name: Send Failure Alert to Slack + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} + SLACK_USERNAME: GitHub Actions + SLACK_ICON: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" + SLACK_TITLE: Wakatime-to-slack-status image build failed + SLACK_COLOR: danger + SLACK_MESSAGE: 'Run number : #${{ github.run_number }}' diff --git a/.gitignore b/.gitignore index 3b735ec..e346019 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ # Go workspace file go.work +.env diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71b56ec --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/main.go" + } + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..70db2c4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.21 as builder +ENV ROOT=/build +RUN mkdir ${ROOT} +WORKDIR ${ROOT} + +COPY ./ ./ +RUN go get + +RUN CGO_ENABLED=0 GOOS=linux go build -o main $ROOT/main.go && chmod +x ./main + +FROM alpine:3 +WORKDIR /app + +COPY --from=builder /build/main ./ +COPY --from=builder /build/templates/ /app/templates/ +COPY --from=builder /build/assets/ /app/assets/ +COPY --from=builder /usr/share/zoneinfo/Asia/Tokyo /usr/share/zoneinfo/Asia/Tokyo +CMD ["./main"] +LABEL org.opencontainers.image.source = "https://github.com/walnuts1018/wakatime-to-slack-status" diff --git a/assets/result.css b/assets/result.css new file mode 100644 index 0000000..4991233 --- /dev/null +++ b/assets/result.css @@ -0,0 +1,25 @@ +body{ + background-color: #f2f2f2; +} + +.card { + margin: 50px auto; + padding: 10px 30px; + width: 500px; + text-align: center; + background-color: white; + border-radius: 12px; + box-shadow: 0px 5px 15px rgba(0, 0, 0, 0.3); + +} + +.error { + font-family: Consolas, 'Courier New', Courier, Monaco, monospace; + display:inline-block; + padding: 10px 30px; + background-color: #262626; + border-radius: 10px; + color: #dfdfdf; + max-width: 450px; + overflow-wrap: break-word; +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..df0f506 --- /dev/null +++ b/config/config.go @@ -0,0 +1,55 @@ +package config + +import ( + "flag" + "fmt" + "log/slog" + "os" + "reflect" + + "github.com/joho/godotenv" +) + +type Config_t struct { + WakatimeAppID string `env:"WAKATIME_APP_ID"` + WakatimeAppSecret string `env:"WAKATIME_CLIENT_SECRET"` + CookieSecret string `env:"COOKIE_SECRET"` + + PSQLEndpoint string `env:"PSQL_ENDPOINT"` + PSQLPort string `env:"PSQL_PORT"` + PSQLDatabase string `env:"PSQL_DATABASE"` + PSQLUser string `env:"PSQL_USER"` + PSQLPassword string `env:"PSQL_PASSWORD"` + + SlackAccessToken string `env:"SLACK_ACCESS_TOKEN"` + + ServerPort string +} + +var Config = Config_t{} + +func LoadConfig() error { + serverport := flag.String("port", "8080", "server port") + flag.Parse() + Config.ServerPort = *serverport + + err := godotenv.Load(".env") + if err != nil { + slog.Warn("Error loading .env file") + } + + t := reflect.TypeOf(Config) + for i := 0; i < t.NumField(); i++ { + fieldName := t.Field(i).Name + tag, ok := t.Field(i).Tag.Lookup("env") + if !ok { + continue + } + v, ok := os.LookupEnv(tag) + if !ok { + return fmt.Errorf("%s is not set", tag) + } + reflect.ValueOf(&Config).Elem().FieldByName(fieldName).SetString(v) + } + return nil +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..7202852 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,12 @@ +version: '3' +services: + postgres: + image: postgres:16 + container_name: psql + ports: + - "5432:5432" + volumes: + - ./psql:/docker-entrypoint-initdb.d + environment: + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "postgres" diff --git a/domain/slack.go b/domain/slack.go new file mode 100644 index 0000000..3390ed7 --- /dev/null +++ b/domain/slack.go @@ -0,0 +1,4 @@ +package domain + +type SlackClient interface { +} diff --git a/domain/tokenstore.go b/domain/tokenstore.go new file mode 100644 index 0000000..830eca0 --- /dev/null +++ b/domain/tokenstore.go @@ -0,0 +1,8 @@ +package domain + +type TokenStore interface { + SaveOAuth2Token(OAuth2Token) error + GetOAuth2Token() (OAuth2Token, error) + UpdateOAuth2Token(token OAuth2Token) error + Close() error +} diff --git a/domain/wakatime.go b/domain/wakatime.go new file mode 100644 index 0000000..12e84c0 --- /dev/null +++ b/domain/wakatime.go @@ -0,0 +1,30 @@ +package domain + +import ( + "context" + "time" +) + +type OAuth2Token struct { + AccessToken string + RefreshToken string + Expiry time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type WakatimeClient interface { + Auth(state string) string + Callback(ctx context.Context, code string) (OAuth2Token, error) + SetToken(ctx context.Context, tokenStore TokenStore) error + Languages(ctx context.Context) ([]Language, error) +} + +type Language struct { + Id string `json:"id"` //unique id of this language + Name string `json:"name"` //human readable name of this language + Color string `json:"color"` //hex color code, used when displaying this language on WakaTime charts + IsVerified bool `json:"is_verified"` //whether this language is verified, by GitHub’s linguist or manually by WakaTime admins + CreatedAt string `json:"created_at"` //time when this language was created in ISO 8601 format + ModifiedAt string `json:"modified_at"` //time when this language was last modified in ISO 8601 format +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7b0e658 --- /dev/null +++ b/go.mod @@ -0,0 +1,45 @@ +module github.com/walnuts1018/wakatime-to-slack-profile + +go 1.21.1 + +require ( + github.com/gin-contrib/sessions v0.0.5 + github.com/gin-gonic/gin v1.9.1 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 + github.com/slack-go/slack v0.12.3 + golang.org/x/oauth2 v0.12.0 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/gorilla/context v1.1.1 // indirect + github.com/gorilla/securecookie v1.1.1 // indirect + github.com/gorilla/sessions v1.2.1 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.13.0 // indirect + golang.org/x/net v0.15.0 // indirect + golang.org/x/sys v0.12.0 // indirect + golang.org/x/text v0.13.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..350489b --- /dev/null +++ b/go.sum @@ -0,0 +1,120 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE= +github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/slack-go/slack v0.12.3 h1:92/dfFU8Q5XP6Wp5rr5/T5JHLM5c5Smtn53fhToAP88= +github.com/slack-go/slack v0.12.3/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= +golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/handler/handler.go b/handler/handler.go new file mode 100644 index 0000000..deeeb03 --- /dev/null +++ b/handler/handler.go @@ -0,0 +1,86 @@ +package handler + +import ( + "fmt" + "log/slog" + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/walnuts1018/wakatime-to-slack-profile/config" + "github.com/walnuts1018/wakatime-to-slack-profile/usecase" +) + +var ( + uc *usecase.Usecase +) + +func NewHandler(usecase *usecase.Usecase) (*gin.Engine, error) { + uc = usecase + r := gin.Default() + store := cookie.NewStore([]byte(config.Config.CookieSecret)) + r.Use(sessions.Sessions("WakatimeToSlack", store)) + r.Static("/assets", "./assets") + r.LoadHTMLGlob("templates/*") + + r.GET("/signin", signIn) + r.GET("/callback", callback) + r.GET("/languages", func(ctx *gin.Context) { + uc.Languages(ctx) + }) + + return r, nil +} + +func signIn(ctx *gin.Context) { + session := sessions.Default(ctx) + state, redirect, err := uc.SignIn() + if err != nil { + ctx.HTML(http.StatusInternalServerError, "result.html", gin.H{ + "result": "error", + "error": fmt.Sprintf("failed to sign in: %v", err), + }) + return + } + session.Set("state", state) + session.Save() + + ctx.Redirect(http.StatusFound, redirect) +} + +func callback(ctx *gin.Context) { + code := ctx.Query("code") + state := ctx.Query("state") + session := sessions.Default(ctx) + if session.Get("state") != state { + ctx.HTML(http.StatusBadRequest, "result.html", gin.H{ + "result": "error", + "error": "invalid state", + }) + return + } + err := uc.Callback(ctx, code) + if err != nil { + slog.Error("failed to callback", "error", err) + ctx.HTML(http.StatusInternalServerError, "result.html", gin.H{ + "result": "error", + "error": "failed to callback", + }) + return + } + + err = uc.SetToken(ctx) + if err != nil { + slog.Error("failed to set token", "error", err) + ctx.HTML(http.StatusInternalServerError, "result.html", gin.H{ + "result": "error", + "error": "failed to set token", + }) + return + } + + ctx.HTML(http.StatusOK, "result.html", gin.H{ + "result": "success", + }) +} diff --git a/infra/psql/psql.go b/infra/psql/psql.go new file mode 100644 index 0000000..6a48220 --- /dev/null +++ b/infra/psql/psql.go @@ -0,0 +1,69 @@ +package psql + +import ( + "database/sql" + "fmt" + + _ "github.com/lib/pq" + "github.com/walnuts1018/wakatime-to-slack-profile/config" + "github.com/walnuts1018/wakatime-to-slack-profile/domain" +) + +const ( + sslMode = "disable" +) + +type client struct { + db *sql.DB +} + +func NewClient() (domain.TokenStore, error) { + db, err := sql.Open("postgres", fmt.Sprintf("host=%v port=%v user=%v password=%v dbname=%v sslmode=%v", config.Config.PSQLEndpoint, config.Config.PSQLPort, config.Config.PSQLUser, config.Config.PSQLPassword, config.Config.PSQLDatabase, sslMode)) + if err != nil { + return client{}, fmt.Errorf("failed to open db: %v", err) + } + + return client{db: db}, nil +} + +func (c client) Close() error { + return c.db.Close() +} +func (c client) SaveOAuth2Token(token domain.OAuth2Token) error { + _, err := c.db.Exec(`CREATE TABLE IF NOT EXISTS oauth2_config ( + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expiry TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NULL + )`) + if err != nil { + return fmt.Errorf("failed to create oauth2_config table: %v", err) + } + _, err = c.db.Exec("DELETE FROM oauth2_config") + if err != nil { + return fmt.Errorf("failed to delete oauth2_config: %v", err) + } + _, err = c.db.Exec("INSERT INTO oauth2_config (access_token, refresh_token, expiry, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)", token.AccessToken, token.RefreshToken, token.Expiry, token.CreatedAt, token.UpdatedAt) + if err != nil { + return fmt.Errorf("failed to insert oauth2_config: %v", err) + } + return nil +} + +func (c client) GetOAuth2Token() (domain.OAuth2Token, error) { + var token domain.OAuth2Token + err := c.db.QueryRow("SELECT access_token, refresh_token, expiry, created_at, updated_at FROM oauth2_config ORDER BY created_at DESC LIMIT 1;").Scan(&token.AccessToken, &token.RefreshToken, &token.Expiry, &token.CreatedAt, &token.UpdatedAt) + if err != nil { + return domain.OAuth2Token{}, fmt.Errorf("failed to get oauth2_config: %v", err) + } + return token, nil +} + +func (c client) UpdateOAuth2Token(token domain.OAuth2Token) error { + _, err := c.db.Exec("UPDATE oauth2_config SET access_token = $1, refresh_token = $2, expiry = $3, updated_at = $4", token.AccessToken, token.RefreshToken, token.Expiry, token.UpdatedAt) + if err != nil { + return fmt.Errorf("failed to update oauth2_config: %v", err) + } + return nil +} diff --git a/infra/slack/slack.go b/infra/slack/slack.go new file mode 100644 index 0000000..4913e66 --- /dev/null +++ b/infra/slack/slack.go @@ -0,0 +1,26 @@ +package slack + +import ( + "fmt" + + "github.com/slack-go/slack" + "github.com/walnuts1018/wakatime-to-slack-profile/config" +) + +type client struct { + slackClient *slack.Client +} + +func NewClient() *client { + return &client{ + slackClient: slack.New(config.Config.SlackAccessToken), + } +} + +func (c *client) SetUserCustomStatus(emoji string) error { + err := c.slackClient.SetUserCustomStatus("", emoji, 0) + if err != nil { + return fmt.Errorf("error setting status: %w", err) + } + return nil +} diff --git a/infra/timeJST/timeJST.go b/infra/timeJST/timeJST.go new file mode 100644 index 0000000..cd04100 --- /dev/null +++ b/infra/timeJST/timeJST.go @@ -0,0 +1,28 @@ +package timeJST + +import "time" + +var ( + JST *time.Location + mockMode = false + MockTime time.Time +) + +func init() { + jst, err := time.LoadLocation("Asia/Tokyo") + if err != nil { + panic(err) + } + JST = jst +} + +func Now() time.Time { + if mockMode { + return time.Date(2003, 10, 18, 0, 0, 0, 0, JST) + } + return time.Now().In(JST) +} + +func SetMockMode() { + mockMode = true +} diff --git a/infra/wakatime/tokensource.go b/infra/wakatime/tokensource.go new file mode 100644 index 0000000..275e74e --- /dev/null +++ b/infra/wakatime/tokensource.go @@ -0,0 +1,32 @@ +package wakatime + +import ( + "github.com/walnuts1018/wakatime-to-slack-profile/domain" + "github.com/walnuts1018/wakatime-to-slack-profile/infra/timeJST" + "golang.org/x/oauth2" +) + +type tokenSource struct { + src oauth2.TokenSource + tokenStore domain.TokenStore +} + +func (s *tokenSource) Token() (*oauth2.Token, error) { + t, err := s.src.Token() + if err != nil { + return nil, err + } + + token := domain.OAuth2Token{ + AccessToken: t.AccessToken, + RefreshToken: t.RefreshToken, + Expiry: t.Expiry, + UpdatedAt: timeJST.Now(), + } + + err = s.tokenStore.UpdateOAuth2Token(token) + if err != nil { + return t, err + } + return t, nil +} diff --git a/infra/wakatime/wakatime.go b/infra/wakatime/wakatime.go new file mode 100644 index 0000000..78bd03e --- /dev/null +++ b/infra/wakatime/wakatime.go @@ -0,0 +1,113 @@ +package wakatime + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/walnuts1018/wakatime-to-slack-profile/config" + "github.com/walnuts1018/wakatime-to-slack-profile/domain" + "github.com/walnuts1018/wakatime-to-slack-profile/infra/timeJST" + "golang.org/x/oauth2" +) + +const ( + AuthEndpoint = "https://wakatime.com/oauth/authorize" + TokenEndpoint = "https://wakatime.com/oauth/token" +) + +var ( + scopes = []string{ + "read_stats", + } +) + +type client struct { + cfg *oauth2.Config + wclient *http.Client +} + +func NewOauth2Client() domain.WakatimeClient { + return &client{ + cfg: &oauth2.Config{ + ClientID: config.Config.WakatimeAppID, + ClientSecret: config.Config.WakatimeAppSecret, + Endpoint: oauth2.Endpoint{AuthURL: AuthEndpoint, TokenURL: TokenEndpoint}, + Scopes: scopes, + }, + } +} + +func (c *client) Auth(state string) string { + url := c.cfg.AuthCodeURL(state, oauth2.AccessTypeOffline) + return url +} + +func (c *client) Callback(ctx context.Context, code string) (domain.OAuth2Token, error) { + token, err := c.cfg.Exchange(ctx, code) + if err != nil { + return domain.OAuth2Token{}, err + } + + cfg := domain.OAuth2Token{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + Expiry: token.Expiry, + CreatedAt: timeJST.Now(), + UpdatedAt: timeJST.Now(), + } + return cfg, nil +} + +func (c *client) SetToken(ctx context.Context, tokenStore domain.TokenStore) error { + token, err := tokenStore.GetOAuth2Token() + if err != nil { + return fmt.Errorf("failed to get oauth2 token: %w", err) + } + oauthToken := &oauth2.Token{ + AccessToken: token.AccessToken, + TokenType: "bearer", + RefreshToken: token.RefreshToken, + Expiry: token.Expiry, + } + + oldTokenSource := c.cfg.TokenSource(ctx, oauthToken) + mySrc := &tokenSource{ + src: oldTokenSource, + tokenStore: tokenStore, + } + + reuseSrc := oauth2.ReuseTokenSource(oauthToken, mySrc) + c.wclient = oauth2.NewClient(ctx, reuseSrc) + return nil +} + +func (c *client) Languages(ctx context.Context) ([]domain.Language, error) { + if c.wclient == nil { + return nil, fmt.Errorf("client is not set") + } + resp, err := c.wclient.Get("https://wakatime.com/api/v1/program_languages") + if err != nil { + return nil, fmt.Errorf("failed to get languages: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get languages: %v", resp.Status) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var languages []domain.Language + err = json.Unmarshal(raw, &languages) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal response body: %w", err) + } + return languages, nil + +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c71f59f --- /dev/null +++ b/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + + "github.com/walnuts1018/wakatime-to-slack-profile/config" + "github.com/walnuts1018/wakatime-to-slack-profile/handler" + "github.com/walnuts1018/wakatime-to-slack-profile/infra/psql" + "github.com/walnuts1018/wakatime-to-slack-profile/infra/wakatime" + "github.com/walnuts1018/wakatime-to-slack-profile/usecase" +) + +func main() { + err := config.LoadConfig() + if err != nil { + slog.Error("Error loading config", "error", err) + os.Exit(1) + } + + ctx, canecel := context.WithCancel(context.Background()) + defer canecel() + + psqClient, err := psql.NewClient() + if err != nil { + slog.Error("Error creating psql client", "error", err) + os.Exit(1) + } + defer psqClient.Close() + + wakatimeClient := wakatime.NewOauth2Client() + + usecase := usecase.NewUsecase(wakatimeClient, psqClient) + err = usecase.SetToken(ctx) + if err != nil { + slog.Warn("failed to set token", "error", err) + } + + handler, err := handler.NewHandler(usecase) + if err != nil { + slog.Error("Error loading handler: %v", "error", err) + os.Exit(1) + } + err = handler.Run(fmt.Sprintf(":%v", config.Config.ServerPort)) + if err != nil { + slog.Error("failed to run handler", "error", err) + os.Exit(1) + } +} diff --git a/psql/init.sql b/psql/init.sql new file mode 100644 index 0000000..2e3dcd6 --- /dev/null +++ b/psql/init.sql @@ -0,0 +1 @@ +CREATE DATABASE wakatime_to_slack; diff --git a/templates/result.html b/templates/result.html new file mode 100644 index 0000000..188c8ec --- /dev/null +++ b/templates/result.html @@ -0,0 +1,22 @@ + + +
+ +You have successfully authorized this application.
You can close this window now.
An error occurred while authorizing this application.
Please try again.
+ error: {{.error}} +
+ {{end}} +