├── .github ├── depandabot-auto-merge.yml ├── dependabot.yml └── workflows │ ├── auto-merge.yml │ ├── codeql-analysis.yml │ ├── ogp-build-and-lint.yaml │ ├── test-and-lint.yaml │ └── test.yaml ├── .gitignore ├── LICENSE ├── README.md ├── backend ├── .dockerignore ├── .env.example ├── .env.prod ├── Dockerfile ├── Makefile ├── README.md ├── config │ ├── config.go │ ├── datastore.go │ └── twitter.go ├── crypto │ ├── crypto.go │ └── crypto_test.go ├── datastore │ ├── access_token.go │ ├── access_token_test.go │ ├── client.go │ ├── state.go │ └── state_test.go ├── docker-compose.deps.yaml ├── docker-compose.yml ├── docker │ └── datastore │ │ ├── Dockerfile │ │ └── start.sh ├── errors │ └── errors.go ├── go.mod ├── go.sum ├── logging │ ├── logging.go │ └── trace.go ├── main.go ├── period │ ├── period.go │ └── period_test.go ├── twitter │ ├── auth.go │ ├── auth_test.go │ ├── client.go │ ├── client_test.go │ ├── entity.go │ ├── entity_test.go │ ├── fake_twitter.go │ ├── tweets.go │ ├── twitter.go │ └── user.go ├── uploader │ └── uploader.go ├── usecase │ ├── usecase.go │ └── usecase_test.go └── web │ ├── api.go │ ├── cookie.go │ ├── error.go │ ├── handler.go │ ├── middleware.go │ ├── oauth.go │ └── router.go ├── frontend ├── .env.production ├── .eslintrc.js ├── .gitignore ├── README.md ├── next-env.d.ts ├── next.config.js ├── package.json ├── public │ ├── example.png │ ├── favicon.ico │ ├── icon.png │ ├── manifest.json │ ├── ogp.jpg │ └── robots.txt ├── src │ ├── api │ │ ├── client.ts │ │ └── hooks.ts │ ├── atom │ │ ├── Area.ts │ │ ├── ButtonBase.ts │ │ ├── Footer.tsx │ │ ├── Header.tsx │ │ └── Share.tsx │ ├── components │ │ ├── AwakeSchedule.tsx │ │ ├── Borders.tsx │ │ ├── ButtonGitHub.tsx │ │ ├── ButtonSaveImage.tsx │ │ ├── ButtonShareTwitter.tsx │ │ ├── ButtonTwitterLogin.tsx │ │ ├── Calendar.tsx │ │ ├── CalendarContainer.tsx │ │ ├── CalendarUser.tsx │ │ ├── DateHeaders.tsx │ │ ├── Description.tsx │ │ └── Times.tsx │ ├── entity │ │ ├── AwakePeriod.ts │ │ ├── Period.ts │ │ └── User.ts │ ├── index.css │ ├── lib │ │ ├── env.ts │ │ ├── screen.ts │ │ └── time.ts │ ├── pages │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── callback.tsx │ │ ├── index.tsx │ │ ├── ogp.tsx │ │ └── share │ │ │ └── [id].tsx │ └── typed.d.ts ├── tsconfig.json └── yarn.lock ├── images ├── icon.png ├── icon.xcf ├── tae.png └── yabai.png ├── ogp_functions ├── .eslintrc ├── .gcloudignore ├── .gitignore ├── .prettierrc ├── LICENSE ├── README-ja.md ├── README.md ├── cli.js ├── functions │ └── .gitkeep ├── package.json ├── src │ ├── atom │ │ └── Area.ts │ ├── components │ │ ├── AwakeSchedule.tsx │ │ ├── Borders.tsx │ │ ├── Calendar.tsx │ │ ├── DateHeaders.tsx │ │ └── Times.tsx │ ├── entity │ │ ├── AwakePeriod.ts │ │ └── Period.ts │ ├── index.tsx │ └── lib │ │ └── time.ts ├── tsconfig.json └── yarn.lock └── workspace.code-workspace /.github/depandabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | - match: 2 | dependency_type: "development" 3 | update_type: "all" 4 | - match: 5 | dependency_type: "production" 6 | update_type: "semver:patch" 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/backend" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | timezone: Asia/Tokyo 9 | open-pull-requests-limit: 10 10 | ignore: 11 | - dependency-name: cloud.google.com/go 12 | versions: 13 | - 0.77.0 14 | - 0.78.0 15 | - package-ecosystem: npm 16 | directory: "/frontend" 17 | schedule: 18 | interval: daily 19 | time: "10:00" 20 | timezone: Asia/Tokyo 21 | open-pull-requests-limit: 10 22 | ignore: 23 | - dependency-name: "@types/node" 24 | versions: 25 | - 15.0.0 26 | - dependency-name: "@testing-library/user-event" 27 | versions: 28 | - 12.7.0 29 | - 12.7.1 30 | - 12.7.2 31 | - 12.7.3 32 | - 12.8.0 33 | - 12.8.1 34 | - 12.8.3 35 | - 13.0.0 36 | - 13.0.1 37 | - 13.1.1 38 | - 13.1.2 39 | - 13.1.3 40 | - 13.1.5 41 | - dependency-name: html-to-image 42 | versions: 43 | - 1.6.0 44 | - dependency-name: typescript 45 | versions: 46 | - 4.2.2 47 | - dependency-name: eslint-config-prettier 48 | versions: 49 | - 8.0.0 50 | - dependency-name: next 51 | versions: 52 | - 10.1.1 53 | - 10.1.2 54 | - 10.1.3 55 | - dependency-name: styled-components 56 | versions: 57 | - 5.2.2 58 | - dependency-name: y18n 59 | versions: 60 | - 4.0.1 61 | - package-ecosystem: npm 62 | directory: "/ogp_functions" 63 | schedule: 64 | interval: daily 65 | time: "10:00" 66 | timezone: Asia/Tokyo 67 | open-pull-requests-limit: 10 68 | ignore: 69 | - dependency-name: "@types/node" 70 | versions: 71 | - 15.0.0 72 | - dependency-name: puppeteer 73 | versions: 74 | - 6.0.0 75 | - 7.0.0 76 | - 7.0.1 77 | - 7.0.4 78 | - 7.1.0 79 | - 8.0.0 80 | - dependency-name: eslint-config-prettier 81 | versions: 82 | - 8.0.0 83 | - dependency-name: "@google-cloud/storage" 84 | versions: 85 | - 5.8.0 86 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: auto-merge 2 | 3 | on: 4 | pull_request_target: 5 | 6 | jobs: 7 | auto-merge: 8 | runs-on: ubuntu-latest 9 | if: github.actor == 'dependabot[bot]' 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: ahmadnassri/action-dependabot-auto-merge@v2 13 | with: 14 | github-token: ${{ secrets.GH_PAT_DEPENDABOT_AUTO_MERGE }} 15 | config: .github/depandabot-auto-merge.yml 16 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master, ] 6 | schedule: 7 | - cron: '0 19 * * 1' 8 | 9 | jobs: 10 | analyse: 11 | name: Analyse 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | with: 18 | # We must fetch at least the immediate parents so that if this is 19 | # a pull request then we can checkout the head. 20 | fetch-depth: 2 21 | 22 | # If this run was triggered by a pull request event, then checkout 23 | # the head of the pull request instead of the merge commit. 24 | - run: git checkout HEAD^2 25 | if: ${{ github.event_name == 'pull_request' }} 26 | 27 | # Initializes the CodeQL tools for scanning. 28 | - name: Initialize CodeQL 29 | uses: github/codeql-action/init@v1 30 | # Override language selection by uncommenting this and choosing your languages 31 | # with: 32 | # languages: go, javascript, csharp, python, cpp, java 33 | 34 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 35 | # If this step fails, then you should remove it and run the build manually (see below) 36 | - name: Autobuild 37 | uses: github/codeql-action/autobuild@v1 38 | 39 | # ℹ️ Command-line programs to run using the OS shell. 40 | # 📚 https://git.io/JvXDl 41 | 42 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 43 | # and modify them (or add more) to build your code if your project 44 | # uses a compiled language 45 | 46 | #- run: | 47 | # make bootstrap 48 | # make release 49 | 50 | - name: Perform CodeQL Analysis 51 | uses: github/codeql-action/analyze@v1 52 | -------------------------------------------------------------------------------- /.github/workflows/ogp-build-and-lint.yaml: -------------------------------------------------------------------------------- 1 | name: OGP Build and Lint 2 | on: 3 | pull_request: 4 | types: [opened, synchronize] 5 | paths-ignore: 6 | - 'frontend/**' 7 | - 'backend/**' 8 | jobs: 9 | run: 10 | name: Build and Lint 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set Node.js 12.x 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 12.x 18 | - uses: actions/cache@v2 19 | with: 20 | path: '**/node_modules' 21 | key: yarn-${{ hashFiles('**/yarn.lock') }} 22 | restore-keys: | 23 | yarn- 24 | - name: Install deps 25 | run: cd ogp_functions && yarn 26 | - name: Build 27 | run: cd ogp_functions && yarn build 28 | - name: Lint 29 | run: cd ogp_functions && yarn lint && yarn format 30 | -------------------------------------------------------------------------------- /.github/workflows/test-and-lint.yaml: -------------------------------------------------------------------------------- 1 | name: test_and_lint 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | paths-ignore: 7 | - 'frontend/**' 8 | - 'ogp_functions/**' 9 | 10 | jobs: 11 | test: 12 | name: Test 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 10 15 | services: 16 | datastore: 17 | image: singularities/datastore-emulator 18 | env: 19 | DATASTORE_LISTEN_ADDRESS: 0.0.0.0:5000 20 | DATASTORE_PROJECT_ID: midare-ci 21 | ports: 22 | - 5000:5000 23 | env: 24 | DATASTORE_EMULATOR_HOST: 127.0.0.1:5000 25 | DATASTORE_PROJECT_ID: midare-ci 26 | steps: 27 | - name: Set up Go 1.21 28 | uses: actions/setup-go@v1 29 | with: 30 | go-version: 1.21 31 | id: go 32 | 33 | - name: Check out code into the Go module directory 34 | uses: actions/checkout@v2 35 | 36 | - name: Restore cache 37 | uses: actions/cache@v1 38 | with: 39 | path: ~/go/pkg/mod 40 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 41 | restore-keys: | 42 | ${{ runner.os }}-go- 43 | - name: Get dependencies 44 | run: | 45 | cd backend && go mod download 46 | - name: Build 47 | run: cd backend && go build -v . 48 | - name: Get tools 49 | run: | 50 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.25.0 51 | - name: Test 52 | run: cd backend && make test 53 | - name: lint 54 | run: | 55 | cd backend && $(go env GOPATH)/bin/golangci-lint run --disable-all --enable=goimports --enable=govet 56 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'frontend/**' 7 | - 'ogp_functions/**' 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Use Node.js 15 | uses: actions/setup-node@v2-beta 16 | with: 17 | node-version: '14' 18 | - name: Cache node modules 19 | uses: actions/cache@v1 20 | env: 21 | cache-name: cache-node-modules 22 | with: 23 | # npm cache files are stored in `~/.npm` on Linux/macOS 24 | path: ~/.npm 25 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 26 | restore-keys: | 27 | ${{ runner.os }}-build-${{ env.cache-name }}- 28 | ${{ runner.os }}-build- 29 | ${{ runner.os }}- 30 | - run: cd frontend && yarn 31 | - run: cd frontend && yarn build 32 | env: 33 | CI: true 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | 3 | .vscode 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 p1ass 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # p1ass/midare 2 | 3 | 🕒 ツイートを使って生活習慣の乱れを可視化する Web アプリ 4 | https://midare.p1ass.com 5 | 6 | ![Build](https://github.com/p1ass/midare/workflows/Build/badge.svg) 7 | 8 | ## スクリーンショット 9 | 10 | **あまり乱れてない人** 11 | ![tae](images/tae.png) 12 | 13 | **乱れている人** 14 | ![tae](images/yabai.png) 15 | 16 | ## ニュース記事 17 | 18 | - [ツイート時刻から「生活習慣の乱れ」を可視化してくれるアプリが登場 「ツイ廃判定アプリ」「なんて恐ろしいものを」 - ねとらぼ](https://nlab.itmedia.co.jp/nl/articles/2006/03/news042.html) 19 | - [ツイ廃ぶりが一目瞭然! 過去のツイートを分析して「生活の乱れを可視化」するアプリ【やじうま Watch】 - INTERNET Watch](https://internet.watch.impress.co.jp/docs/yajiuma/1257035.html) 20 | 21 | ## 登壇資料 22 | 23 | [うじまる君の生活習慣の乱れを可視化したい! / uzimaru birthday LT - Speaker Deck](https://speakerdeck.com/p1ass/uzimaru-birthday-lt) 24 | 25 | ## ブログ 26 | 27 | [Twitter トレンド1位になった個人開発 Web サービスの負荷対応記録 - ぷらすのブログ](https://blog.p1ass.com/posts/midare/) 28 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.example 3 | .env.prod 4 | .vscode 5 | .idea 6 | docker-compose.yml 7 | docker-compose.deps.yaml 8 | Makefile 9 | docker -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | TWITTER_OAUTH_CALLBACK_URL=http://localhost.local:8080/callback 2 | FRONTEND_CALLBACK_URL=http://localhost.local:3000/callback 3 | CORS_ALLOW_ORIGIN=http://localhost.local:3000 4 | GIN_MODE=release 5 | REDIS_ADDR=localhost 6 | REDIS_PASS=hoge 7 | SESSION_KEY=hoge 8 | SESSION_ENCRYPTION_KEY_BASE64_ENCODED=hoge 9 | TWITTER_CLIENT_ID=aiueo 10 | TWITTER_CLIENT_SECRET=aiueo 11 | CLOUD_FUNCTIONS_URL=http://localhost:8081 12 | DATASTORE_EMULATOR_HOST=127.0.0.1:5000 13 | DATASTORE_PROJECT_ID=midare-local 14 | ENV=LOCAL 15 | GCP_PROJECT=dummy -------------------------------------------------------------------------------- /backend/.env.prod: -------------------------------------------------------------------------------- 1 | TWITTER_OAUTH_CALLBACK_URL=https://midare-api.p1ass.com/callback 2 | FRONTEND_CALLBACK_URL=https://midare.p1ass.com 3 | CORS_ALLOW_ORIGIN=https://midare.p1ass.com 4 | GIN_MODE=release 5 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.17.8 AS build-env 2 | 3 | ENV GOOS=linux 4 | ENV GOARCH=amd64 5 | ENV CGO_ENABLED=0 6 | ENV GO111MODULE=on 7 | 8 | WORKDIR /go/src/github.com/p1ass/midare 9 | 10 | COPY go.mod go.sum ./ 11 | RUN go mod download 12 | 13 | COPY . . 14 | RUN go build . 15 | 16 | FROM alpine:3.11.6 17 | 18 | RUN apk add --no-cache bash ca-certificates curl 19 | 20 | COPY --from=build-env /go/src/github.com/p1ass/midare/midare /midare 21 | RUN chmod a+x /midare 22 | 23 | EXPOSE 8080 24 | CMD ["/midare"] -------------------------------------------------------------------------------- /backend/Makefile: -------------------------------------------------------------------------------- 1 | ENV_FILE := .env 2 | ENV_EXAMPLE_FILE := .env.example 3 | ENV = $(shell cat $(ENV_FILE)) 4 | ENV_EXAMPLE = $(shell cat $(ENV_EXAMPLE_FILE)) 5 | 6 | .PHONY:serve 7 | serve: 8 | $(ENV) go run . 9 | 10 | .PHONY:test 11 | test: 12 | $(ENV_EXAMPLE) go test -v ./... -count=1 13 | 14 | .PHONY:start-deps 15 | start-deps: 16 | docker-compose -f docker-compose.deps.yaml up -d 17 | 18 | .PHONY:stop-deps 19 | stop-deps: 20 | docker-compose -f docker-compose.deps.yaml down -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | # backend 2 | 3 | ## Getting Started 4 | 5 | 1. [Twitter Developer](https://developer.twitter.com/en) でアプリを作成して、Consumer Key と Consumer Secret を取得。 6 | 7 | 2. `.env.example` を参考に `.env` ファイルを作成して適宜環境変数を設定する。 8 | 9 | ```console 10 | $ cp .env.example .env 11 | $ $EDITOR .env 12 | ``` 13 | 14 | 3. ローカルでもクッキーを使えるように `/etc/hosts` を編集 15 | 16 | ```consoel 17 | $ sudo $EDITOR /etc/hosts 18 | 19 | # 次の設定を追加 20 | 127.0.0.1 localhost.local 21 | ::1 localhost.local 22 | ``` 23 | 24 | 4. サーバを起動 25 | 26 | ```console 27 | $ make serve 28 | ``` 29 | 30 | 5. http://localhost.local:8080 にアクセスして起動しているか確認。 31 | -------------------------------------------------------------------------------- /backend/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/base64" 5 | "os" 6 | ) 7 | 8 | func ReadPort() string { 9 | return os.Getenv("PORT") 10 | } 11 | 12 | func ReadCloudFunctionsURL() string { 13 | return os.Getenv("CLOUD_FUNCTIONS_URL") 14 | } 15 | 16 | func ReadAllowCORSOriginURL() string { 17 | return os.Getenv("CORS_ALLOW_ORIGIN") 18 | } 19 | 20 | func ReadCloudRunRevision() string { 21 | return os.Getenv("K_REVISION") 22 | } 23 | 24 | func ReadFrontEndCallbackURL() string { 25 | return os.Getenv("FRONTEND_CALLBACK_URL") 26 | } 27 | 28 | func ReadSessionKey() string { 29 | return os.Getenv("SESSION_KEY") 30 | } 31 | func ReadSessionEncryptionKey() ([]byte, error) { 32 | encoded := os.Getenv("SESSION_ENCRYPTION_KEY_BASE64_ENCODED") 33 | encryptionKey, err := base64.URLEncoding.DecodeString(encoded) 34 | if err != nil { 35 | return nil, err 36 | } 37 | return encryptionKey, nil 38 | } 39 | 40 | func ReadGoogleCloudProjectID() string { 41 | return os.Getenv("GCP_PROJECT") 42 | } 43 | 44 | func IsLocal() bool { 45 | return os.Getenv("ENV") == "LOCAL" 46 | } 47 | -------------------------------------------------------------------------------- /backend/config/datastore.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "os" 4 | 5 | func ReadDatastoreProjectId() string { 6 | return os.Getenv("DATASTORE_PROJECT_ID") 7 | } 8 | -------------------------------------------------------------------------------- /backend/config/twitter.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "os" 4 | 5 | type Twitter struct { 6 | ClientID string 7 | ClientSecret string 8 | OAuthCallBackURL string 9 | } 10 | 11 | func NewTwitter() *Twitter { 12 | return &Twitter{ 13 | ClientID: os.Getenv("TWITTER_CLIENT_ID"), 14 | ClientSecret: os.Getenv("TWITTER_CLIENT_SECRET"), 15 | OAuthCallBackURL: os.Getenv("TWITTER_OAUTH_CALLBACK_URL"), 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/crypto/crypto.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "io" 7 | ) 8 | 9 | // SecureRandomBase64Encoded returns base 64 url encoded secure random string 10 | func SecureRandomBase64Encoded(entropyByte int) string { 11 | b := make([]byte, entropyByte) 12 | if _, err := io.ReadFull(rand.Reader, b); err != nil { 13 | panic(err) 14 | } 15 | return base64.RawURLEncoding.EncodeToString(b) 16 | } 17 | -------------------------------------------------------------------------------- /backend/crypto/crypto_test.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import "testing" 4 | 5 | func TestSecureRandomBase64Encoded(t *testing.T) { 6 | type args struct { 7 | entropyByte int 8 | } 9 | tests := []struct { 10 | name string 11 | args args 12 | wantLen int 13 | }{ 14 | { 15 | name: "When 32byte entropy, should generate 43 length string (PKCE spec)", 16 | args: args{ 17 | entropyByte: 32, 18 | }, 19 | wantLen: 43, 20 | }, 21 | } 22 | for _, tt := range tests { 23 | t.Run(tt.name, func(t *testing.T) { 24 | if got := SecureRandomBase64Encoded(tt.args.entropyByte); len(got) != tt.wantLen { 25 | t.Errorf("SecureRandomBase64Encoded() lenght = %v, want %v", got, tt.wantLen) 26 | } 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /backend/datastore/access_token.go: -------------------------------------------------------------------------------- 1 | package datastore 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "cloud.google.com/go/datastore" 9 | "github.com/p1ass/midare/errors" 10 | "github.com/p1ass/midare/logging" 11 | "go.uber.org/zap" 12 | "golang.org/x/oauth2" 13 | ) 14 | 15 | func (c client) StoreAccessToken(ctx context.Context, userID string, token *oauth2.Token) error { 16 | dto := &accessToken{ 17 | Token: token.AccessToken, 18 | Created: now(), 19 | } 20 | key := datastore.NameKey("OAuth2AccessToken", userID, nil) 21 | _, err := c.cli.Put(ctx, key, dto) 22 | if err != nil { 23 | return errors.Wrap(err, "failed to store access token") 24 | } 25 | return nil 26 | } 27 | 28 | func (c client) FetchAccessToken(ctx context.Context, userID string) (*oauth2.Token, error) { 29 | key := datastore.NameKey("OAuth2AccessToken", userID, nil) 30 | dto := &accessToken{} 31 | err := c.cli.Get(ctx, key, dto) 32 | if err != nil { 33 | if errors.Cause(err) == datastore.ErrNoSuchEntity { 34 | return nil, errors.NewNotFound("access token not found") 35 | } 36 | return nil, errors.Wrap(err, "failed to fetch access token") 37 | } 38 | 39 | // Redis時代と同様にセキュリティ上の理由から30分でタイムアウトするようにする 40 | if now().Sub(dto.Created) >= 30*time.Minute { 41 | logging.Extract(ctx).Info(fmt.Sprintf("access token timeout: %s", userID), zap.String("userID", userID)) 42 | err := c.cli.Delete(ctx, key) 43 | if err != nil { 44 | return nil, errors.Wrap(err, "failed to delete access token") 45 | } 46 | return nil, errors.NewNotFound("state not found") 47 | } 48 | 49 | return &oauth2.Token{ 50 | AccessToken: dto.Token, 51 | }, nil 52 | } 53 | 54 | type accessToken struct { 55 | Token string 56 | Created time.Time 57 | } 58 | -------------------------------------------------------------------------------- /backend/datastore/access_token_test.go: -------------------------------------------------------------------------------- 1 | package datastore 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/google/go-cmp/cmp/cmpopts" 10 | "github.com/google/uuid" 11 | "github.com/p1ass/midare/errors" 12 | "github.com/p1ass/midare/logging" 13 | "golang.org/x/oauth2" 14 | ) 15 | 16 | func Test_client_AccessToken(t *testing.T) { 17 | fixed := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC) 18 | 19 | now = func() time.Time { 20 | return fixed 21 | } 22 | 23 | type args struct { 24 | userID string 25 | token *oauth2.Token 26 | } 27 | tests := []struct { 28 | name string 29 | args args 30 | nowAfterStored time.Time 31 | want *oauth2.Token 32 | wantStoreErr bool 33 | wantFetchErr bool 34 | }{ 35 | { 36 | name: "保存したトークンを正しく取得できる", 37 | args: args{ 38 | userID: uuid.NewString(), 39 | token: &oauth2.Token{ 40 | AccessToken: "accessToken", 41 | }, 42 | }, 43 | nowAfterStored: fixed, 44 | want: &oauth2.Token{ 45 | AccessToken: "accessToken", 46 | }, 47 | wantStoreErr: false, 48 | wantFetchErr: false, 49 | }, 50 | { 51 | name: "30分経過すると保存したトークンを取得できなくなる", 52 | args: args{ 53 | userID: uuid.NewString(), 54 | token: &oauth2.Token{ 55 | AccessToken: "accessToken", 56 | }, 57 | }, 58 | nowAfterStored: fixed.Add(30 * time.Minute), 59 | want: nil, 60 | wantStoreErr: false, 61 | wantFetchErr: true, 62 | }, 63 | } 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | c, err := NewClient() 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | 71 | ctx := context.Background() 72 | ctx = logging.Inject(ctx, logging.New()) 73 | 74 | if err := c.StoreAccessToken(ctx, tt.args.userID, tt.args.token); (err != nil) != tt.wantStoreErr { 75 | t.Errorf("StoreAccessToken() error = %v, wantErr %v", err, tt.wantStoreErr) 76 | } 77 | 78 | tmpNow := now 79 | now = func() time.Time { 80 | return tt.nowAfterStored 81 | } 82 | defer func() { 83 | now = tmpNow 84 | }() 85 | 86 | got, err := c.FetchAccessToken(ctx, tt.args.userID) 87 | if (err != nil) != tt.wantFetchErr { 88 | t.Errorf("FetchAccessToken() error = %v, wantErr %v", err, tt.wantFetchErr) 89 | } 90 | 91 | if !cmp.Equal(got, tt.want, cmpopts.IgnoreUnexported(oauth2.Token{})) { 92 | t.Errorf("FetchAccessToken() got = %v, want %v diff= %v", got, tt.args.token, cmp.Diff(got, tt.want)) 93 | } 94 | }) 95 | } 96 | } 97 | 98 | func Test_client_FetchAccessTokenShouldNotFoundErrorWhenNotFoundId(t *testing.T) { 99 | c, err := NewClient() 100 | if err != nil { 101 | t.Fatal(err) 102 | } 103 | 104 | var notFoundID = "notFoundID" 105 | 106 | _, err = c.FetchAccessToken(context.Background(), notFoundID) 107 | se, ok := errors.Cause(err).(*errors.ServiceError) 108 | if !ok { 109 | t.Errorf("FetchAccessToken() error should ServiceError, but got %v", err) 110 | return 111 | } 112 | 113 | wantCode := errors.NotFound 114 | if se.Code != wantCode { 115 | t.Errorf("FetchAccessToken() errorCode = %v, wantErr %v", se.Code, wantCode) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /backend/datastore/client.go: -------------------------------------------------------------------------------- 1 | package datastore 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "cloud.google.com/go/datastore" 8 | "github.com/p1ass/midare/config" 9 | "github.com/p1ass/midare/errors" 10 | "github.com/p1ass/midare/twitter" 11 | "golang.org/x/oauth2" 12 | ) 13 | 14 | // テストで時間を差し替えられるように、変数としてnowを定義しておく 15 | var now = time.Now 16 | 17 | type Client interface { 18 | StoreAccessToken(ctx context.Context, userID string, token *oauth2.Token) error 19 | FetchAccessToken(ctx context.Context, userID string) (*oauth2.Token, error) 20 | 21 | StoreAuthorizationState(ctx context.Context, stateID string, authState *twitter.AuthorizationState) error 22 | FetchAuthorizationState(ctx context.Context, stateID string) (*twitter.AuthorizationState, error) 23 | } 24 | 25 | func NewClient() (Client, error) { 26 | ctx := context.Background() 27 | 28 | dsClient, err := datastore.NewClient(ctx, config.ReadDatastoreProjectId()) 29 | if err != nil { 30 | return nil, errors.Wrap(err, "failed to create datastore client") 31 | } 32 | 33 | return &client{ 34 | cli: dsClient, 35 | }, nil 36 | } 37 | 38 | type client struct { 39 | cli *datastore.Client 40 | } 41 | 42 | var _ Client = &client{} 43 | -------------------------------------------------------------------------------- /backend/datastore/state.go: -------------------------------------------------------------------------------- 1 | package datastore 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "cloud.google.com/go/datastore" 9 | "github.com/p1ass/midare/errors" 10 | "github.com/p1ass/midare/logging" 11 | "github.com/p1ass/midare/twitter" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func (c client) StoreAuthorizationState(ctx context.Context, stateID string, authState *twitter.AuthorizationState) error { 16 | dto := &authorizationState{ 17 | State: authState.State, 18 | CodeVerifier: authState.CodeVerifier, 19 | Created: now(), 20 | } 21 | key := datastore.NameKey("AuthorizationState", stateID, nil) 22 | _, err := c.cli.Put(ctx, key, dto) 23 | if err != nil { 24 | return errors.Wrap(err, "failed to store access token") 25 | } 26 | return nil 27 | } 28 | 29 | func (c client) FetchAuthorizationState(ctx context.Context, stateID string) (*twitter.AuthorizationState, error) { 30 | key := datastore.NameKey("AuthorizationState", stateID, nil) 31 | dto := &authorizationState{} 32 | err := c.cli.Get(ctx, key, dto) 33 | if err != nil { 34 | if errors.Cause(err) == datastore.ErrNoSuchEntity { 35 | return nil, errors.NewNotFound("state not found") 36 | } 37 | return nil, errors.Wrap(err, "failed to fetch authorization state") 38 | } 39 | 40 | if now().Sub(dto.Created) >= 15*time.Minute { 41 | logging.Extract(ctx).Info(fmt.Sprintf("authorization state timeout: %s", stateID), zap.String("stateID", stateID)) 42 | err := c.cli.Delete(ctx, key) 43 | if err != nil { 44 | return nil, errors.Wrap(err, "failed to delete authorization state") 45 | } 46 | return nil, errors.NewNotFound("state not found") 47 | } 48 | 49 | return &twitter.AuthorizationState{ 50 | State: dto.State, 51 | CodeVerifier: dto.CodeVerifier, 52 | }, nil 53 | } 54 | 55 | type authorizationState struct { 56 | State string 57 | CodeVerifier string 58 | Created time.Time 59 | } 60 | -------------------------------------------------------------------------------- /backend/datastore/state_test.go: -------------------------------------------------------------------------------- 1 | package datastore 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/google/uuid" 10 | "github.com/p1ass/midare/errors" 11 | "github.com/p1ass/midare/logging" 12 | "github.com/p1ass/midare/twitter" 13 | ) 14 | 15 | func Test_client_AuthorizationState(t *testing.T) { 16 | fixed := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC) 17 | 18 | now = func() time.Time { 19 | return fixed 20 | } 21 | 22 | type args struct { 23 | stateID string 24 | state *twitter.AuthorizationState 25 | } 26 | tests := []struct { 27 | name string 28 | args args 29 | nowAfterStored time.Time 30 | want *twitter.AuthorizationState 31 | wantStoreErr bool 32 | wantFetchErr bool 33 | }{ 34 | { 35 | name: "保存したトークンを正しく取得できる", 36 | args: args{ 37 | stateID: uuid.NewString(), 38 | state: &twitter.AuthorizationState{State: "state", CodeVerifier: "codeVerifier"}, 39 | }, 40 | nowAfterStored: fixed, 41 | want: &twitter.AuthorizationState{State: "state", CodeVerifier: "codeVerifier"}, 42 | wantStoreErr: false, 43 | wantFetchErr: false, 44 | }, 45 | { 46 | name: "30分経過すると保存したトークンを取得できなくなる", 47 | args: args{ 48 | stateID: uuid.NewString(), 49 | state: &twitter.AuthorizationState{State: "state", CodeVerifier: "codeVerifier"}, 50 | }, 51 | nowAfterStored: fixed.Add(30 * time.Minute), 52 | want: nil, 53 | wantStoreErr: false, 54 | wantFetchErr: true, 55 | }, 56 | } 57 | for _, tt := range tests { 58 | t.Run(tt.name, func(t *testing.T) { 59 | c, err := NewClient() 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | 64 | ctx := context.Background() 65 | ctx = logging.Inject(ctx, logging.New()) 66 | 67 | if err := c.StoreAuthorizationState(ctx, tt.args.stateID, tt.args.state); (err != nil) != tt.wantStoreErr { 68 | t.Errorf("StoreAccessToken() error = %v, wantErr %v", err, tt.wantStoreErr) 69 | } 70 | 71 | tmpNow := now 72 | now = func() time.Time { 73 | return tt.nowAfterStored 74 | } 75 | defer func() { 76 | now = tmpNow 77 | }() 78 | 79 | got, err := c.FetchAuthorizationState(ctx, tt.args.stateID) 80 | if (err != nil) != tt.wantFetchErr { 81 | t.Errorf("FetchAccessToken() error = %v, wantErr %v", err, tt.wantFetchErr) 82 | } 83 | 84 | if !cmp.Equal(got, tt.want) { 85 | t.Errorf("FetchAuthorizationState() got = %v, want %v diff= %v", got, tt.args.state, cmp.Diff(got, tt.want)) 86 | } 87 | }) 88 | } 89 | } 90 | 91 | func Test_client_FetchAuthorizationStateShouldNotFoundErrorWhenNotFoundId(t *testing.T) { 92 | c, err := NewClient() 93 | if err != nil { 94 | t.Fatal(err) 95 | } 96 | 97 | var notFoundID = "notFoundID" 98 | 99 | _, err = c.FetchAuthorizationState(context.Background(), notFoundID) 100 | se, ok := errors.Cause(err).(*errors.ServiceError) 101 | if !ok { 102 | t.Errorf("FetchAuthorizationState() error should ServiceError, but got %v", err) 103 | return 104 | } 105 | 106 | wantCode := errors.NotFound 107 | if se.Code != wantCode { 108 | t.Errorf("FetchAuthorizationState() errorCode = %v, wantErr %v", se.Code, wantCode) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /backend/docker-compose.deps.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | datastore: 4 | build: 5 | context: ./docker/datastore 6 | dockerfile: Dockerfile 7 | tty: true 8 | environment: 9 | DATASTORE_PROJECT_ID: midare-local 10 | DATASTORE_LISTEN_ADDRESS: 0.0.0.0:5000 11 | ports: 12 | - "127.0.0.1:5000:5000" -------------------------------------------------------------------------------- /backend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | midare: 4 | build: 5 | context: ./ 6 | dockerfile: Dockerfile 7 | container_name: midare 8 | restart: always 9 | env_file: 10 | - .env.prod 11 | environment: 12 | - TWITTER_CONSUMER_KEY=${TWITTER_CONSUMER_KEY} 13 | - TWITTER_CONSUMER_SECRET=${TWITTER_CONSUMER_SECRET} 14 | networks: 15 | - caddy-network 16 | networks: 17 | caddy-network: 18 | external: true -------------------------------------------------------------------------------- /backend/docker/datastore/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM google/cloud-sdk:alpine 2 | 3 | RUN apk add --update --no-cache openjdk8-jre &&\ 4 | gcloud components install cloud-datastore-emulator beta --quiet 5 | 6 | VOLUME /opt/data 7 | 8 | COPY start.sh . 9 | 10 | ENTRYPOINT ["./start.sh"] -------------------------------------------------------------------------------- /backend/docker/datastore/start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Check user environment variable 4 | if [[ -z "${DATASTORE_PROJECT_ID}" ]]; then 5 | echo "Missing DATASTORE_PROJECT_ID environment variable" >&2 6 | exit 1 7 | fi 8 | 9 | if [[ -z "${DATASTORE_LISTEN_ADDRESS}" ]]; then 10 | echo "Missing DATASTORE_LISTEN_ADDRESS environment variable" >&2 11 | exit 1 12 | fi 13 | 14 | options=${options:1} 15 | # Check for datastore options 16 | while [ ! $# -eq 0 ] 17 | do 18 | case "$1" in 19 | --store-on-disk) 20 | options="$options --store-on-disk" 21 | shift 22 | ;; 23 | --no-store-on-disk) 24 | options="$options --no-store-on-disk" 25 | shift 26 | ;; 27 | --consistency=*) 28 | consistency=${1#*=} 29 | options="$options --consistency=$consistency" 30 | shift 31 | ;; 32 | *) 33 | echo "Invalid option: $1. Use: --store-on-disk, --no-store-on-disk, --consistency=[0.0-1.0]" 34 | exit 1 35 | ;; 36 | esac 37 | done 38 | 39 | # Config gcloud project 40 | gcloud config set project "${DATASTORE_PROJECT_ID}" 41 | 42 | # Start emulator 43 | gcloud beta emulators datastore start \ 44 | --data-dir=/opt/data \ 45 | --host-port="${DATASTORE_LISTEN_ADDRESS}" \ 46 | ${options} -------------------------------------------------------------------------------- /backend/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | // ServiceError is a error struct with error code 10 | type ServiceError struct { 11 | Code ErrCode 12 | message string 13 | } 14 | 15 | // ErrCode is a internal error code type 16 | type ErrCode int 17 | 18 | const ( 19 | // Unknown uses when error cause is unknown 20 | Unknown ErrCode = iota 21 | // BadRequest uses when request is incorrect 22 | BadRequest 23 | // NotFound uses when a entity is not found 24 | NotFound 25 | // Unauthorized uses when a user not authorized 26 | Unauthorized 27 | // Forbidden uses when operation is not permitted 28 | Forbidden 29 | // InvalidArgument uses when argument is invalid 30 | InvalidArgument 31 | ) 32 | 33 | func (e *ServiceError) Error() string { 34 | return e.message 35 | } 36 | 37 | // New returns service error 38 | func New(code ErrCode, format string, args ...interface{}) *ServiceError { 39 | return &ServiceError{Code: code, message: fmt.Sprintf(format, args...)} 40 | } 41 | 42 | // Wrap wraps error with stack 43 | func Wrap(err error, format string, args ...interface{}) error { 44 | if len(args) == 0 { 45 | return errors.Wrap(err, format) 46 | } 47 | return errors.Wrap(err, fmt.Sprintf(format, args...)) 48 | } 49 | 50 | // Cause returns the root of error 51 | func Cause(err error) error { 52 | return errors.Cause(err) 53 | } 54 | 55 | // NewNotFound returns a not found service error 56 | func NewNotFound(format string, args ...interface{}) *ServiceError { 57 | return New(NotFound, format, args...) 58 | } 59 | 60 | // NewBadRequest returns a bad request service error 61 | func NewBadRequest(format string, args ...interface{}) *ServiceError { 62 | return New(BadRequest, format, args...) 63 | } 64 | 65 | // NewForbidden returns a forbidden service error 66 | func NewForbidden(format string, args ...interface{}) *ServiceError { 67 | return New(Forbidden, format, args...) 68 | } 69 | 70 | // NewUnknown returns a unknown error 71 | func NewUnknown(format string, args ...interface{}) *ServiceError { 72 | return New(Unknown, format, args...) 73 | } 74 | 75 | // NewInvalidArgument returns a invalid argument error 76 | func NewInvalidArgument(format string, args ...interface{}) *ServiceError { 77 | return New(InvalidArgument, format, args...) 78 | } 79 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/p1ass/midare 2 | 3 | go 1.17 4 | 5 | require ( 6 | cloud.google.com/go/datastore v1.8.0 7 | cloud.google.com/go/profiler v0.3.0 8 | github.com/GoogleCloudPlatform/opentelemetry-operations-go v1.5.2 9 | github.com/g8rswimmer/go-twitter/v2 v2.1.2 10 | github.com/gin-contrib/cors v1.4.0 11 | github.com/gin-contrib/sessions v0.0.5 12 | github.com/gin-contrib/zap v0.0.2 13 | github.com/gin-gonic/gin v1.8.1 14 | github.com/google/go-cmp v0.5.9 15 | github.com/google/uuid v1.3.0 16 | github.com/patrickmn/go-cache v2.1.0+incompatible 17 | github.com/pkg/errors v0.9.1 18 | github.com/tommy351/zap-stackdriver v0.1.4 19 | go.opentelemetry.io/otel v1.7.0 20 | go.opentelemetry.io/otel/trace v1.7.0 21 | go.uber.org/zap v1.21.0 22 | golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb 23 | ) 24 | 25 | require ( 26 | cloud.google.com/go v0.102.1 // indirect 27 | cloud.google.com/go/compute v1.6.1 // indirect 28 | github.com/gin-contrib/sse v0.1.0 // indirect 29 | github.com/go-playground/locales v0.14.0 // indirect 30 | github.com/go-playground/universal-translator v0.18.0 // indirect 31 | github.com/go-playground/validator/v10 v10.10.0 // indirect 32 | github.com/goccy/go-json v0.9.7 // indirect 33 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 34 | github.com/golang/protobuf v1.5.2 // indirect 35 | github.com/google/pprof v0.0.0-20220412212628-83db2b799d1f // indirect 36 | github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa // indirect 37 | github.com/googleapis/gax-go/v2 v2.4.0 // indirect 38 | github.com/gorilla/context v1.1.1 // indirect 39 | github.com/gorilla/securecookie v1.1.1 // indirect 40 | github.com/gorilla/sessions v1.2.1 // indirect 41 | github.com/json-iterator/go v1.1.12 // indirect 42 | github.com/leodido/go-urn v1.2.1 // indirect 43 | github.com/mattn/go-isatty v0.0.14 // indirect 44 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect 45 | github.com/modern-go/reflect2 v1.0.2 // indirect 46 | github.com/pelletier/go-toml/v2 v2.0.1 // indirect 47 | github.com/ugorji/go/codec v1.2.7 // indirect 48 | go.opencensus.io v0.23.0 // indirect 49 | go.uber.org/atomic v1.7.0 // indirect 50 | go.uber.org/multierr v1.6.0 // indirect 51 | golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 // indirect 52 | golang.org/x/net v0.0.0-20220607020251-c690dde0001d // indirect 53 | golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d // indirect 54 | golang.org/x/text v0.3.7 // indirect 55 | golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect 56 | google.golang.org/api v0.84.0 // indirect 57 | google.golang.org/appengine v1.6.7 // indirect 58 | google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad // indirect 59 | google.golang.org/grpc v1.47.0 // indirect 60 | google.golang.org/protobuf v1.28.0 // indirect 61 | gopkg.in/yaml.v2 v2.4.0 // indirect 62 | ) 63 | -------------------------------------------------------------------------------- /backend/logging/logging.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/p1ass/midare/config" 8 | stackdriver "github.com/tommy351/zap-stackdriver" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | type ctxLoggerKey struct{} 13 | 14 | // New returns struct of zap logger 15 | // If you get logger inside request context, use Extract instead of this function 16 | func New() *zap.Logger { 17 | cfg := zap.NewProductionConfig() 18 | cfg.EncoderConfig = stackdriver.EncoderConfig 19 | 20 | logger, _ := cfg.Build() 21 | return logger 22 | } 23 | 24 | // Inject injects logger into context 25 | func Inject(ctx context.Context, logger *zap.Logger) context.Context { 26 | projectID := config.ReadGoogleCloudProjectID() 27 | tracer := ExtractTracer(ctx) 28 | 29 | logger = logger.With( 30 | zap.String("logging.googleapis.com/trace", fmt.Sprintf("projects/%s/traces/%s", projectID, tracer.TraceID)), 31 | zap.String("logging.googleapis.com/spanId", tracer.SpanID), 32 | ) 33 | 34 | return context.WithValue(ctx, ctxLoggerKey{}, logger) 35 | } 36 | 37 | // Extract extracts logger from context 38 | func Extract(ctx context.Context) *zap.Logger { 39 | return ctx.Value(ctxLoggerKey{}).(*zap.Logger) 40 | } 41 | 42 | // Error returns zap field wrapping error 43 | func Error(err error) zap.Field { 44 | return zap.String("error", fmt.Sprintf("%+v", err)) 45 | } 46 | -------------------------------------------------------------------------------- /backend/logging/trace.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "context" 5 | 6 | "go.opentelemetry.io/otel/trace" 7 | ) 8 | 9 | type Tracer struct { 10 | SpanID string 11 | TraceID string 12 | } 13 | 14 | func ExtractTracer(ctx context.Context) Tracer { 15 | sc := trace.SpanFromContext(ctx).SpanContext() 16 | 17 | return Tracer{ 18 | SpanID: sc.SpanID().String(), 19 | TraceID: sc.TraceID().String(), 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | "time" 10 | 11 | "github.com/p1ass/midare/config" 12 | "github.com/p1ass/midare/datastore" 13 | "github.com/p1ass/midare/logging" 14 | "github.com/p1ass/midare/twitter" 15 | "go.uber.org/zap" 16 | 17 | "cloud.google.com/go/profiler" 18 | "github.com/p1ass/midare/web" 19 | ) 20 | 21 | func main() { 22 | revision := config.ReadCloudRunRevision() 23 | if revision != "" { 24 | cfg := profiler.Config{ 25 | Service: "midare", 26 | ServiceVersion: revision, 27 | MutexProfiling: true, 28 | } 29 | if err := profiler.Start(cfg); err != nil { 30 | logging.New().Fatal("Profiler failed to start", zap.Error(err)) 31 | return 32 | } 33 | } 34 | 35 | dsCli, err := datastore.NewClient() 36 | if err != nil { 37 | logging.New().Fatal("Failed to create datastore client", zap.Error(err)) 38 | return 39 | } 40 | 41 | twiAuth := twitter.NewAuth() 42 | 43 | handler, err := web.NewHandler(twiAuth, dsCli, config.ReadFrontEndCallbackURL()) 44 | if err != nil { 45 | logging.New().Fatal("Failed to initialize web handler", zap.Error(err)) 46 | return 47 | } 48 | router, err := web.NewRouter(handler, config.ReadAllowCORSOriginURL()) 49 | if err != nil { 50 | logging.New().Fatal("Failed to initialize web router", zap.Error(err)) 51 | return 52 | } 53 | 54 | port := config.ReadPort() 55 | if port == "" { 56 | port = "8080" 57 | } 58 | 59 | srv := &http.Server{ 60 | Addr: ":" + port, 61 | Handler: router, 62 | } 63 | 64 | go func() { 65 | // service connections 66 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 67 | logging.New().Fatal("Failed to listen and serve", zap.Error(err)) 68 | return 69 | } 70 | }() 71 | 72 | // Wait for interrupt signal to gracefully shut down the server with 73 | // a timeout of 5 seconds. 74 | quit := make(chan os.Signal, 1) 75 | signal.Notify(quit, syscall.SIGTERM, os.Interrupt) 76 | <-quit 77 | logging.New().Info("Graceful Shutdown signal received") 78 | 79 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 80 | defer cancel() 81 | if err := srv.Shutdown(ctx); err != nil { 82 | logging.New().Fatal("Failed to shutdown server", zap.Error(err)) 83 | } 84 | logging.New().Info("Server finished") 85 | 86 | } 87 | -------------------------------------------------------------------------------- /backend/period/period.go: -------------------------------------------------------------------------------- 1 | package period 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/p1ass/midare/twitter" 7 | ) 8 | 9 | const ( 10 | // この時間以内にツイートされていたらその時間は起きていることにする 11 | awakeThreshold = 3*time.Hour + 30*time.Minute 12 | ) 13 | 14 | type Period struct { 15 | OkiTime *twitter.Tweet `json:"okiTime"` 16 | NeTime *twitter.Tweet `json:"neTime"` 17 | } 18 | 19 | // CalcAwakePeriods calculates awake periods. 20 | // IMPORTANT: For Twitter API specification, tweets slice is sorted by created_at desc. 21 | func CalcAwakePeriods(tweets []*twitter.Tweet) []*Period { 22 | var periods []*Period 23 | 24 | neTweet, lastTweet, startIdx := getMostRecentValidTweet(tweets) 25 | 26 | // ツイートが全く無かった場合は空のスライスを返す 27 | if startIdx == 0 { 28 | return []*Period{} 29 | } 30 | 31 | for _, t := range tweets[startIdx:] { 32 | if t.ContainExcludedWord() { 33 | continue 34 | } 35 | 36 | durationBetweenTweets := lastTweet.Created.Sub(t.Created) 37 | if durationBetweenTweets > awakeThreshold { 38 | // しきい値時間を超えていればその時点でPeriodが確定するので、 39 | // lastTweetとneTweetが同じ場合を除きPeriodに追加する 40 | //(Periodが切り替わった後のtがしきい値以上間隔が空いている場合に発生) 41 | if lastTweet != neTweet { 42 | periods = append(periods, &Period{ 43 | OkiTime: lastTweet, 44 | NeTime: neTweet, 45 | }) 46 | } 47 | // Periodが切り替わるので、neTweetを更新する 48 | neTweet = t 49 | } 50 | 51 | lastTweet = t 52 | } 53 | 54 | // ずっとしきい値以内だった場合はPeriodに追加されることなくループを抜けてしまうので、 55 | // ここでPeriodを追加する 56 | if lastTweet != neTweet { 57 | periods = append(periods, &Period{ 58 | OkiTime: lastTweet, 59 | NeTime: neTweet, 60 | }) 61 | } 62 | 63 | // 0件の場合は、nullではなく空のJSONを返したいので 64 | if len(periods) == 0 { 65 | return []*Period{} 66 | } 67 | 68 | return periods 69 | } 70 | 71 | // 変数初期化のために、最も最近の除外されないツイートを探している 72 | func getMostRecentValidTweet(tweets []*twitter.Tweet) (*twitter.Tweet, *twitter.Tweet, int) { 73 | var neTweet *twitter.Tweet 74 | var lastTweet *twitter.Tweet 75 | var startIdx int 76 | for i, t := range tweets { 77 | if !t.ContainExcludedWord() { 78 | neTweet = t 79 | lastTweet = t 80 | startIdx = i + 1 81 | break 82 | } 83 | } 84 | return neTweet, lastTweet, startIdx 85 | } 86 | -------------------------------------------------------------------------------- /backend/period/period_test.go: -------------------------------------------------------------------------------- 1 | package period 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | "github.com/p1ass/midare/twitter" 9 | ) 10 | 11 | var jst = time.FixedZone("Asia/Tokyo", 9*60*60) 12 | 13 | func Test_CalcAwakePeriods(t *testing.T) { 14 | t.Parallel() 15 | 16 | tests := []struct { 17 | name string 18 | ts []*twitter.Tweet 19 | want []*Period 20 | }{ 21 | { 22 | name: "ツイートが一つも存在しない場合はperiodは空", 23 | ts: nil, 24 | want: []*Period{}, 25 | }, 26 | { 27 | name: "1ツイートしか存在しない場合は起きている時間がないのでperiodは空", 28 | ts: []*twitter.Tweet{ 29 | {Created: time.Date(2020, 1, 1, 0, 0, 0, 0, jst)}, 30 | }, 31 | want: []*Period{}, 32 | }, 33 | { 34 | name: "ツイートが2つ存在し、3.5時間以内のツイートであればperiodが1つ", 35 | ts: []*twitter.Tweet{ 36 | {Created: time.Date(2020, 1, 1, 3, 30, 0, 0, jst)}, 37 | {Created: time.Date(2020, 1, 1, 0, 0, 0, 0, jst)}, 38 | }, 39 | want: []*Period{ 40 | { 41 | OkiTime: &twitter.Tweet{ 42 | Created: time.Date(2020, 1, 1, 0, 0, 0, 0, jst), 43 | }, 44 | NeTime: &twitter.Tweet{ 45 | Created: time.Date(2020, 1, 1, 3, 30, 0, 0, jst), 46 | }, 47 | }, 48 | }, 49 | }, 50 | { 51 | name: "ツイートが2つ存在し、間隔が3.5時間より大きいツイートであればperiodが空", 52 | ts: []*twitter.Tweet{ 53 | {Created: time.Date(2020, 1, 1, 3, 31, 0, 0, jst)}, 54 | {Created: time.Date(2020, 1, 1, 0, 0, 0, 0, jst)}, 55 | }, 56 | want: []*Period{}, 57 | }, 58 | { 59 | name: "ツイートが3つ存在し、全ての間隔が3.5時間以内のツイートであればperiodが1つ", 60 | ts: []*twitter.Tweet{ 61 | {Created: time.Date(2020, 1, 1, 7, 0, 0, 0, jst)}, 62 | {Created: time.Date(2020, 1, 1, 3, 30, 0, 0, jst)}, 63 | {Created: time.Date(2020, 1, 1, 0, 0, 0, 0, jst)}, 64 | }, 65 | want: []*Period{ 66 | { 67 | OkiTime: &twitter.Tweet{ 68 | Created: time.Date(2020, 1, 1, 0, 0, 0, 0, jst), 69 | }, 70 | NeTime: &twitter.Tweet{ 71 | Created: time.Date(2020, 1, 1, 7, 0, 0, 0, jst), 72 | }, 73 | }, 74 | }, 75 | }, 76 | { 77 | name: "ツイートが3つ存在し、全ての間隔が3.5時間より大きいのツイートであればperiodが0つ", 78 | ts: []*twitter.Tweet{ 79 | {Created: time.Date(2020, 1, 1, 7, 32, 0, 0, jst)}, 80 | {Created: time.Date(2020, 1, 1, 3, 31, 0, 0, jst)}, 81 | {Created: time.Date(2020, 1, 1, 0, 0, 0, 0, jst)}, 82 | }, 83 | want: []*Period{}, 84 | }, 85 | { 86 | name: "ツイートが3つ存在し、最初の2つの間隔が3.5時間以内のツイートであればperiodが1つ", 87 | ts: []*twitter.Tweet{ 88 | {Created: time.Date(2020, 1, 1, 7, 1, 0, 0, jst)}, 89 | {Created: time.Date(2020, 1, 1, 3, 30, 0, 0, jst)}, 90 | {Created: time.Date(2020, 1, 1, 0, 0, 0, 0, jst)}, 91 | }, 92 | want: []*Period{ 93 | { 94 | OkiTime: &twitter.Tweet{ 95 | Created: time.Date(2020, 1, 1, 0, 0, 0, 0, jst), 96 | }, 97 | NeTime: &twitter.Tweet{ 98 | Created: time.Date(2020, 1, 1, 3, 30, 0, 0, jst), 99 | }, 100 | }, 101 | }, 102 | }, 103 | { 104 | name: "ツイートが3つ存在し、最後の2つの間隔が3.5時間以内のツイートであればperiodが1つ", 105 | ts: []*twitter.Tweet{ 106 | {Created: time.Date(2020, 1, 1, 7, 1, 0, 0, jst)}, 107 | {Created: time.Date(2020, 1, 1, 3, 31, 0, 0, jst)}, 108 | {Created: time.Date(2020, 1, 1, 0, 0, 0, 0, jst)}, 109 | }, 110 | want: []*Period{ 111 | { 112 | OkiTime: &twitter.Tweet{ 113 | Created: time.Date(2020, 1, 1, 3, 31, 0, 0, jst), 114 | }, 115 | NeTime: &twitter.Tweet{ 116 | Created: time.Date(2020, 1, 1, 7, 1, 0, 0, jst), 117 | }, 118 | }, 119 | }, 120 | }, 121 | { 122 | name: "ツイートが4つ存在し、最初の2つと最後の2つがそれぞれ間隔が3.5時間以内のツイートであればperiodが2つ", 123 | ts: []*twitter.Tweet{ 124 | {Created: time.Date(2020, 1, 1, 10, 0, 0, 0, jst)}, 125 | {Created: time.Date(2020, 1, 1, 7, 1, 0, 0, jst)}, 126 | {Created: time.Date(2020, 1, 1, 3, 30, 0, 0, jst)}, 127 | {Created: time.Date(2020, 1, 1, 0, 0, 0, 0, jst)}, 128 | }, 129 | want: []*Period{ 130 | { 131 | OkiTime: &twitter.Tweet{ 132 | Created: time.Date(2020, 1, 1, 7, 1, 0, 0, jst), 133 | }, 134 | NeTime: &twitter.Tweet{ 135 | Created: time.Date(2020, 1, 1, 10, 0, 0, 0, jst), 136 | }, 137 | }, 138 | { 139 | OkiTime: &twitter.Tweet{ 140 | Created: time.Date(2020, 1, 1, 0, 0, 0, 0, jst), 141 | }, 142 | NeTime: &twitter.Tweet{ 143 | Created: time.Date(2020, 1, 1, 3, 30, 0, 0, jst), 144 | }, 145 | }, 146 | }, 147 | }, 148 | } 149 | for _, tt := range tests { 150 | t.Run(tt.name, func(t *testing.T) { 151 | if got := CalcAwakePeriods(tt.ts); !cmp.Equal(got, tt.want) { 152 | t.Errorf("calcAwakePeriods() = diff=%v", cmp.Diff(tt.want, got)) 153 | } 154 | }) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /backend/twitter/auth.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/base64" 7 | 8 | "github.com/p1ass/midare/config" 9 | "github.com/p1ass/midare/crypto" 10 | "github.com/p1ass/midare/errors" 11 | "golang.org/x/oauth2" 12 | ) 13 | 14 | const ( 15 | authorizationURL = "https://twitter.com/i/oauth2/authorize" 16 | tokenURL = "https://api.twitter.com/2/oauth2/token" 17 | ) 18 | 19 | type auth struct { 20 | config oauth2.Config 21 | } 22 | 23 | // AuthorizationState represents the state of the OAuth2 authorization state and PKCE code verifier. 24 | type AuthorizationState struct { 25 | State string 26 | CodeVerifier string 27 | } 28 | 29 | func NewAuth() Auth { 30 | cfg := config.NewTwitter() 31 | return &auth{ 32 | config: oauth2.Config{ 33 | ClientID: cfg.ClientID, 34 | ClientSecret: cfg.ClientSecret, 35 | Endpoint: oauth2.Endpoint{ 36 | AuthURL: authorizationURL, 37 | TokenURL: tokenURL, 38 | AuthStyle: oauth2.AuthStyleInHeader, 39 | }, 40 | RedirectURL: cfg.OAuthCallBackURL, 41 | Scopes: []string{"tweet.read", "users.read"}, 42 | }, 43 | } 44 | } 45 | 46 | func (a *auth) BuildAuthorizationURL() (string, *AuthorizationState) { 47 | // for CSRF attack 48 | // https://datatracker.ietf.org/doc/html/rfc6749#section-10.12 49 | // SHOULD be less than or equal to 2^(-160) means 160bit / 8 = 20 bytes 50 | state := crypto.SecureRandomBase64Encoded(20) 51 | 52 | // Proof Key for Code Exchange (RFC 7636) 53 | // https://datatracker.ietf.org/doc/html/rfc7636 54 | codeVerifier := crypto.SecureRandomBase64Encoded(32) 55 | h := sha256.New() 56 | h.Write([]byte(codeVerifier)) 57 | codeChallenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) 58 | 59 | url := a.config.AuthCodeURL( 60 | state, 61 | oauth2.SetAuthURLParam("code_challenge", codeChallenge), 62 | oauth2.SetAuthURLParam("code_challenge_method", "S256")) 63 | 64 | return url, &AuthorizationState{ 65 | State: state, 66 | CodeVerifier: codeVerifier, 67 | } 68 | } 69 | 70 | func (a *auth) ExchangeCode(ctx context.Context, code, codeVerifier string) (*oauth2.Token, error) { 71 | token, err := a.config.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) 72 | if err != nil { 73 | return nil, errors.NewForbidden("failed to exchange code: %v", err) 74 | } 75 | return token, nil 76 | } 77 | -------------------------------------------------------------------------------- /backend/twitter/auth_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/base64" 6 | "net/url" 7 | "testing" 8 | ) 9 | 10 | func Test_auth_BuildAuthorizationURL(t *testing.T) { 11 | t.Parallel() 12 | 13 | a := NewAuth() 14 | 15 | t.Run("URLにはstateパラメータが含まれているべきである", func(t *testing.T) { 16 | t.Parallel() 17 | 18 | authUrl, state := a.BuildAuthorizationURL() 19 | 20 | parsedUrl, err := url.Parse(authUrl) 21 | if err != nil { 22 | t.Fatalf("authorization url should be valid url, but got url is %s and error: %v", authUrl, err) 23 | } 24 | 25 | gotState := parsedUrl.Query().Get("state") 26 | if gotState != state.State { 27 | t.Errorf("state %s should be included in authorization url, but got state is %s", state.State, gotState) 28 | } 29 | }) 30 | 31 | t.Run("URLにはPKCE用のパラメータが含まれているべきである", func(t *testing.T) { 32 | t.Parallel() 33 | 34 | authUrl, state := a.BuildAuthorizationURL() 35 | 36 | parsedUrl, err := url.Parse(authUrl) 37 | if err != nil { 38 | t.Fatalf("authorization url should be valid url, but got url is %s and error: %v", authUrl, err) 39 | } 40 | 41 | wantCodeChallengeMethod := "S256" 42 | gotCodeChallengeMethod := parsedUrl.Query().Get("code_challenge_method") 43 | if wantCodeChallengeMethod != gotCodeChallengeMethod { 44 | t.Errorf("code_challenge_method %s should be included in authorization url, but got code_challenge_method is %s", wantCodeChallengeMethod, gotCodeChallengeMethod) 45 | } 46 | 47 | h := sha256.New() 48 | h.Write([]byte(state.CodeVerifier)) 49 | wantCodeChallenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) 50 | 51 | gotCodeChallenge := parsedUrl.Query().Get("code_challenge") 52 | if gotCodeChallenge != wantCodeChallenge { 53 | t.Errorf("code_challenge %s included inauthorization url should be hashed by sha256 and be encoded by base64 url encoded,"+ 54 | "but got is %s", wantCodeChallenge, gotCodeChallenge) 55 | } 56 | }) 57 | 58 | } 59 | -------------------------------------------------------------------------------- /backend/twitter/client.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/g8rswimmer/go-twitter/v2" 8 | "golang.org/x/oauth2" 9 | ) 10 | 11 | type client struct { 12 | cli *twitter.Client 13 | } 14 | 15 | func NewClient(token *oauth2.Token) Client { 16 | c := &twitter.Client{ 17 | Authorizer: newAuthorizer(token), 18 | Client: http.DefaultClient, 19 | Host: "https://api.twitter.com", 20 | } 21 | return &client{ 22 | cli: c, 23 | } 24 | } 25 | 26 | type authorizer struct { 27 | token *oauth2.Token 28 | } 29 | 30 | // newAuthorizer creates OAuth2.0 bearer token authorizer 31 | func newAuthorizer(token *oauth2.Token) *authorizer { 32 | return &authorizer{token: token} 33 | } 34 | 35 | func (a *authorizer) Add(req *http.Request) { 36 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", a.token.AccessToken)) 37 | } 38 | -------------------------------------------------------------------------------- /backend/twitter/client_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "golang.org/x/oauth2" 8 | ) 9 | 10 | func Test_authorizer_Add(t *testing.T) { 11 | t.Parallel() 12 | 13 | token := &oauth2.Token{ 14 | AccessToken: "dummy_access_token", 15 | TokenType: "Bearer", 16 | } 17 | 18 | a := &authorizer{ 19 | token: token, 20 | } 21 | 22 | t.Run("Addを呼び出すごとで、HTTPリクエストのヘッダーにBearer Tokenが付与される", func(t *testing.T) { 23 | t.Parallel() 24 | 25 | req, _ := http.NewRequest("GET", "https://example.com", nil) 26 | 27 | a.Add(req) 28 | got := req.Header.Get("Authorization") 29 | 30 | want := "Bearer dummy_access_token" 31 | if want != got { 32 | t.Errorf("authorizer Add should set AUthorization Header, want %v, got %v", want, got) 33 | } 34 | }) 35 | 36 | } 37 | -------------------------------------------------------------------------------- /backend/twitter/entity.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | ) 7 | 8 | // Tweet represents a tweet 9 | type Tweet struct { 10 | ID string `json:"id"` 11 | Text string `json:"text"` 12 | Created time.Time `json:"createdAt"` 13 | } 14 | 15 | func (t *Tweet) ContainExcludedWord() bool { 16 | excludeWords := []string{ 17 | "ぼくへ 生活習慣乱れてませんか?", 18 | "みんなへ 生活習慣乱れてませんか?", 19 | "#contributter_report", 20 | "のポスト数", 21 | } 22 | for _, word := range excludeWords { 23 | if strings.Contains(t.Text, word) { 24 | return true 25 | } 26 | } 27 | return false 28 | } 29 | 30 | // User represents a user info about twitter 31 | type User struct { 32 | ID string `json:"id"` 33 | Name string `json:"name"` 34 | ScreenName string `json:"screenName"` 35 | ImageURL string `json:"imageUrl"` 36 | } 37 | -------------------------------------------------------------------------------- /backend/twitter/entity_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import "testing" 4 | 5 | func TestTweet_ContainExcludeWord(t *testing.T) { 6 | t.Parallel() 7 | 8 | tests := []struct { 9 | name string 10 | text string 11 | want bool 12 | }{ 13 | { 14 | name: "ぼくへ 生活習慣乱れてませんか?", 15 | text: "ぼくへ 生活習慣乱れてませんか?", 16 | want: true, 17 | }, 18 | { 19 | name: "みんなへ 生活習慣乱れてませんか?", 20 | text: "みんなへ 生活習慣乱れてませんか?", 21 | want: true, 22 | }, 23 | { 24 | name: "p1ass さんの 2020/05/23 の contribution 数: 22\n #contributter_report", 25 | text: "p1ass さんの 2020/05/23 の contribution 数: 22\n #contributter_report", 26 | want: true, 27 | }, 28 | { 29 | name: "@uzimaru0000 05-24のポスト数:24 (うちRT:0)", 30 | text: "@uzimaru0000 05-24のポスト数:24 (うちRT:0)", 31 | want: true, 32 | }, 33 | } 34 | for _, tt := range tests { 35 | t.Run(tt.name, func(t *testing.T) { 36 | tw := &Tweet{ 37 | Text: tt.text, 38 | } 39 | if got := tw.ContainExcludedWord(); got != tt.want { 40 | t.Errorf("ContainExcludedWord() = %v, want %v", got, tt.want) 41 | } 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /backend/twitter/fake_twitter.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // FakeTwitterClient is a fake implementation of TwitterClient. 9 | // It is used for ONLY testing. 10 | type FakeTwitterClient struct { 11 | } 12 | 13 | var _ Client = &FakeTwitterClient{} 14 | 15 | func (c *FakeTwitterClient) GetMe(ctx context.Context) (*User, error) { 16 | return &User{ 17 | ID: "1032935958964973568", 18 | Name: "ぷらす", 19 | ScreenName: "p1ass", 20 | ImageURL: "https://pbs.twimg.com/profile_images/1401046091227811842/AOffsP6w_normal.jpg", 21 | }, nil 22 | } 23 | 24 | func (c *FakeTwitterClient) GetTweets(ctx context.Context, userID string) ([]*Tweet, error) { 25 | // 存在しないユーザの場合は空の配列を返す (APIと同様の挙動) 26 | if userID != "1032935958964973568" { 27 | return []*Tweet{}, nil 28 | } 29 | return []*Tweet{ 30 | { 31 | ID: "1512031854010187780", 32 | Text: "dummy text (2022-04-07 20:37:40 +0900 Asia/Tokyo)", 33 | Created: mustToTime("2022-04-07T20:37:40+09:00"), 34 | }, 35 | { 36 | ID: "1512014281222803460", 37 | Text: "dummy text (2022-04-07 19:27:51 +0900 Asia/Tokyo)", 38 | Created: mustToTime("2022-04-07T19:27:51+09:00"), 39 | }, 40 | { 41 | ID: "1512003402171305984", 42 | Text: "dummy text (2022-04-07 18:44:37 +0900 Asia/Tokyo)", 43 | Created: mustToTime("2022-04-07T18:44:37+09:00"), 44 | }, 45 | { 46 | ID: "1511999498473844741", 47 | Text: "dummy text (2022-04-07 18:29:06 +0900 Asia/Tokyo)", 48 | Created: mustToTime("2022-04-07T18:29:06+09:00"), 49 | }, 50 | { 51 | ID: "1511979067486392324", 52 | Text: "dummy text (2022-04-07 17:07:55 +0900 Asia/Tokyo)", 53 | Created: mustToTime("2022-04-07T17:07:55+09:00"), 54 | }, 55 | { 56 | ID: "1511979018752786432", 57 | Text: "dummy text (2022-04-07 17:07:43 +0900 Asia/Tokyo)", 58 | Created: mustToTime("2022-04-07T17:07:43+09:00"), 59 | }, 60 | { 61 | ID: "1511975965827633153", 62 | Text: "dummy text (2022-04-07 16:55:36 +0900 Asia/Tokyo)", 63 | Created: mustToTime("2022-04-07T16:55:36+09:00"), 64 | }, 65 | { 66 | ID: "1511720764197777411", 67 | Text: "dummy text (2022-04-07 00:01:31 +0900 Asia/Tokyo)", 68 | Created: mustToTime("2022-04-07T00:01:31+09:00"), 69 | }, 70 | { 71 | ID: "1511691214776836102", 72 | Text: "dummy text (2022-04-06 22:04:06 +0900 Asia/Tokyo)", 73 | Created: mustToTime("2022-04-06T22:04:06+09:00"), 74 | }, 75 | { 76 | ID: "1511667395525746691", 77 | Text: "dummy text (2022-04-06 20:29:27 +0900 Asia/Tokyo)", 78 | Created: mustToTime("2022-04-06T20:29:27+09:00"), 79 | }, 80 | { 81 | ID: "1511621845090385925", 82 | Text: "dummy text (2022-04-06 17:28:27 +0900 Asia/Tokyo)", 83 | Created: mustToTime("2022-04-06T17:28:27+09:00"), 84 | }, 85 | { 86 | ID: "1511359356839038977", 87 | Text: "dummy text (2022-04-06 00:05:25 +0900 Asia/Tokyo)", 88 | Created: mustToTime("2022-04-06T00:05:25+09:00"), 89 | }, 90 | { 91 | ID: "1511359161984249860", 92 | Text: "dummy text (2022-04-06 00:04:38 +0900 Asia/Tokyo)", 93 | Created: mustToTime("2022-04-06T00:04:38+09:00"), 94 | }, 95 | { 96 | ID: "1511358348905570304", 97 | Text: "dummy text (2022-04-06 00:01:24 +0900 Asia/Tokyo)", 98 | Created: mustToTime("2022-04-06T00:01:24+09:00"), 99 | }, 100 | { 101 | ID: "1511354181906735105", 102 | Text: "dummy text (2022-04-05 23:44:51 +0900 Asia/Tokyo)", 103 | Created: mustToTime("2022-04-05T23:44:51+09:00"), 104 | }, 105 | { 106 | ID: "1511354078491983872", 107 | Text: "dummy text (2022-04-05 23:44:26 +0900 Asia/Tokyo)", 108 | Created: mustToTime("2022-04-05T23:44:26+09:00"), 109 | }, 110 | { 111 | ID: "1511307579040813060", 112 | Text: "dummy text (2022-04-05 20:39:40 +0900 Asia/Tokyo)", 113 | Created: mustToTime("2022-04-05T20:39:40+09:00"), 114 | }, 115 | { 116 | ID: "1511307535126773763", 117 | Text: "dummy text (2022-04-05 20:39:29 +0900 Asia/Tokyo)", 118 | Created: mustToTime("2022-04-05T20:39:29+09:00"), 119 | }, 120 | { 121 | ID: "1511295628953124873", 122 | Text: "dummy text (2022-04-05 19:52:11 +0900 Asia/Tokyo)", 123 | Created: mustToTime("2022-04-05T19:52:11+09:00"), 124 | }, 125 | { 126 | ID: "1511295128178393092", 127 | Text: "dummy text (2022-04-05 19:50:11 +0900 Asia/Tokyo)", 128 | Created: mustToTime("2022-04-05T19:50:11+09:00"), 129 | }, 130 | { 131 | ID: "1511254361401597952", 132 | Text: "dummy text (2022-04-05 17:08:12 +0900 Asia/Tokyo)", 133 | Created: mustToTime("2022-04-05T17:08:12+09:00"), 134 | }, 135 | { 136 | ID: "1511200622002913288", 137 | Text: "dummy text (2022-04-05 13:34:39 +0900 Asia/Tokyo)", 138 | Created: mustToTime("2022-04-05T13:34:39+09:00"), 139 | }, 140 | { 141 | ID: "1511199659913490432", 142 | Text: "dummy text (2022-04-05 13:30:50 +0900 Asia/Tokyo)", 143 | Created: mustToTime("2022-04-05T13:30:50+09:00"), 144 | }, 145 | { 146 | ID: "1510995926931881994", 147 | Text: "dummy text (2022-04-05 00:01:16 +0900 Asia/Tokyo)", 148 | Created: mustToTime("2022-04-05T00:01:16+09:00"), 149 | }, 150 | { 151 | ID: "1510975965144313864", 152 | Text: "dummy text (2022-04-04 22:41:57 +0900 Asia/Tokyo)", 153 | Created: mustToTime("2022-04-04T22:41:57+09:00"), 154 | }, 155 | { 156 | ID: "1510973594201108488", 157 | Text: "dummy text (2022-04-04 22:32:32 +0900 Asia/Tokyo)", 158 | Created: mustToTime("2022-04-04T22:32:32+09:00"), 159 | }, 160 | { 161 | ID: "1510973496381292545", 162 | Text: "dummy text (2022-04-04 22:32:08 +0900 Asia/Tokyo)", 163 | Created: mustToTime("2022-04-04T22:32:08+09:00"), 164 | }, 165 | { 166 | ID: "1510973363149500422", 167 | Text: "dummy text (2022-04-04 22:31:36 +0900 Asia/Tokyo)", 168 | Created: mustToTime("2022-04-04T22:31:36+09:00"), 169 | }, 170 | { 171 | ID: "1510973291326226440", 172 | Text: "dummy text (2022-04-04 22:31:19 +0900 Asia/Tokyo)", 173 | Created: mustToTime("2022-04-04T22:31:19+09:00"), 174 | }, 175 | { 176 | ID: "1510973139815387145", 177 | Text: "dummy text (2022-04-04 22:30:43 +0900 Asia/Tokyo)", 178 | Created: mustToTime("2022-04-04T22:30:43+09:00"), 179 | }, 180 | { 181 | ID: "1510973058634641416", 182 | Text: "dummy text (2022-04-04 22:30:24 +0900 Asia/Tokyo)", 183 | Created: mustToTime("2022-04-04T22:30:24+09:00"), 184 | }, 185 | { 186 | ID: "1510973004632961030", 187 | Text: "dummy text (2022-04-04 22:30:11 +0900 Asia/Tokyo)", 188 | Created: mustToTime("2022-04-04T22:30:11+09:00"), 189 | }, 190 | { 191 | ID: "1510958493721047048", 192 | Text: "dummy text (2022-04-04 21:32:31 +0900 Asia/Tokyo)", 193 | Created: mustToTime("2022-04-04T21:32:31+09:00"), 194 | }, 195 | { 196 | ID: "1510953107483947010", 197 | Text: "dummy text (2022-04-04 21:11:07 +0900 Asia/Tokyo)", 198 | Created: mustToTime("2022-04-04T21:11:07+09:00"), 199 | }, 200 | { 201 | ID: "1510948776319811584", 202 | Text: "dummy text (2022-04-04 20:53:55 +0900 Asia/Tokyo)", 203 | Created: mustToTime("2022-04-04T20:53:55+09:00"), 204 | }, 205 | { 206 | ID: "1510944624655089669", 207 | Text: "dummy text (2022-04-04 20:37:25 +0900 Asia/Tokyo)", 208 | Created: mustToTime("2022-04-04T20:37:25+09:00"), 209 | }, 210 | { 211 | ID: "1510927878141083657", 212 | Text: "dummy text (2022-04-04 19:30:52 +0900 Asia/Tokyo)", 213 | Created: mustToTime("2022-04-04T19:30:52+09:00"), 214 | }, 215 | { 216 | ID: "1510927723643867139", 217 | Text: "dummy text (2022-04-04 19:30:15 +0900 Asia/Tokyo)", 218 | Created: mustToTime("2022-04-04T19:30:15+09:00"), 219 | }, 220 | { 221 | ID: "1510855588758319106", 222 | Text: "dummy text (2022-04-04 14:43:37 +0900 Asia/Tokyo)", 223 | Created: mustToTime("2022-04-04T14:43:37+09:00"), 224 | }, 225 | { 226 | ID: "1510855268837761028", 227 | Text: "dummy text (2022-04-04 14:42:21 +0900 Asia/Tokyo)", 228 | Created: mustToTime("2022-04-04T14:42:21+09:00"), 229 | }, 230 | { 231 | ID: "1510852444787720195", 232 | Text: "dummy text (2022-04-04 14:31:07 +0900 Asia/Tokyo)", 233 | Created: mustToTime("2022-04-04T14:31:07+09:00"), 234 | }, 235 | { 236 | ID: "1510851545960632322", 237 | Text: "dummy text (2022-04-04 14:27:33 +0900 Asia/Tokyo)", 238 | Created: mustToTime("2022-04-04T14:27:33+09:00"), 239 | }, 240 | { 241 | ID: "1510633530111709185", 242 | Text: "dummy text (2022-04-04 00:01:14 +0900 Asia/Tokyo)", 243 | Created: mustToTime("2022-04-04T00:01:14+09:00"), 244 | }, 245 | { 246 | ID: "1510633137423847429", 247 | Text: "dummy text (2022-04-03 23:59:40 +0900 Asia/Tokyo)", 248 | Created: mustToTime("2022-04-03T23:59:40+09:00"), 249 | }, 250 | { 251 | ID: "1510629836595011586", 252 | Text: "dummy text (2022-04-03 23:46:33 +0900 Asia/Tokyo)", 253 | Created: mustToTime("2022-04-03T23:46:33+09:00"), 254 | }, 255 | { 256 | ID: "1510626655458324486", 257 | Text: "dummy text (2022-04-03 23:33:55 +0900 Asia/Tokyo)", 258 | Created: mustToTime("2022-04-03T23:33:55+09:00"), 259 | }, 260 | { 261 | ID: "1510509624725995522", 262 | Text: "dummy text (2022-04-03 15:48:53 +0900 Asia/Tokyo)", 263 | Created: mustToTime("2022-04-03T15:48:53+09:00"), 264 | }, 265 | { 266 | ID: "1510508716038451208", 267 | Text: "dummy text (2022-04-03 15:45:16 +0900 Asia/Tokyo)", 268 | Created: mustToTime("2022-04-03T15:45:16+09:00"), 269 | }, 270 | { 271 | ID: "1510271141935472645", 272 | Text: "dummy text (2022-04-03 00:01:14 +0900 Asia/Tokyo)", 273 | Created: mustToTime("2022-04-03T00:01:14+09:00"), 274 | }, 275 | { 276 | ID: "1510223299892355072", 277 | Text: "dummy text (2022-04-02 20:51:07 +0900 Asia/Tokyo)", 278 | Created: mustToTime("2022-04-02T20:51:07+09:00"), 279 | }, 280 | { 281 | ID: "1510197054165118981", 282 | Text: "dummy text (2022-04-02 19:06:50 +0900 Asia/Tokyo)", 283 | Created: mustToTime("2022-04-02T19:06:50+09:00"), 284 | }, 285 | { 286 | ID: "1510196699150811136", 287 | Text: "dummy text (2022-04-02 19:05:25 +0900 Asia/Tokyo)", 288 | Created: mustToTime("2022-04-02T19:05:25+09:00"), 289 | }, 290 | { 291 | ID: "1510195193219145729", 292 | Text: "dummy text (2022-04-02 18:59:26 +0900 Asia/Tokyo)", 293 | Created: mustToTime("2022-04-02T18:59:26+09:00"), 294 | }, 295 | { 296 | ID: "1510152468654878720", 297 | Text: "dummy text (2022-04-02 16:09:40 +0900 Asia/Tokyo)", 298 | Created: mustToTime("2022-04-02T16:09:40+09:00"), 299 | }, 300 | { 301 | ID: "1510110972995723274", 302 | Text: "dummy text (2022-04-02 13:24:47 +0900 Asia/Tokyo)", 303 | Created: mustToTime("2022-04-02T13:24:47+09:00"), 304 | }, 305 | { 306 | ID: "1510110618480951299", 307 | Text: "dummy text (2022-04-02 13:23:22 +0900 Asia/Tokyo)", 308 | Created: mustToTime("2022-04-02T13:23:22+09:00"), 309 | }, 310 | { 311 | ID: "1509931683566727169", 312 | Text: "dummy text (2022-04-02 01:32:21 +0900 Asia/Tokyo)", 313 | Created: mustToTime("2022-04-02T01:32:21+09:00"), 314 | }, 315 | { 316 | ID: "1509908766199795718", 317 | Text: "dummy text (2022-04-02 00:01:17 +0900 Asia/Tokyo)", 318 | Created: mustToTime("2022-04-02T00:01:17+09:00"), 319 | }, 320 | { 321 | ID: "1509900993776926735", 322 | Text: "dummy text (2022-04-01 23:30:24 +0900 Asia/Tokyo)", 323 | Created: mustToTime("2022-04-01T23:30:24+09:00"), 324 | }, 325 | { 326 | ID: "1509849315610861570", 327 | Text: "dummy text (2022-04-01 20:05:03 +0900 Asia/Tokyo)", 328 | Created: mustToTime("2022-04-01T20:05:03+09:00"), 329 | }, 330 | { 331 | ID: "1509839387735261186", 332 | Text: "dummy text (2022-04-01 19:25:36 +0900 Asia/Tokyo)", 333 | Created: mustToTime("2022-04-01T19:25:36+09:00"), 334 | }, 335 | { 336 | ID: "1509818091089412141", 337 | Text: "dummy text (2022-04-01 18:00:58 +0900 Asia/Tokyo)", 338 | Created: mustToTime("2022-04-01T18:00:58+09:00"), 339 | }, 340 | { 341 | ID: "1509775869018980357", 342 | Text: "dummy text (2022-04-01 15:13:12 +0900 Asia/Tokyo)", 343 | Created: mustToTime("2022-04-01T15:13:12+09:00"), 344 | }, 345 | { 346 | ID: "1509747333202087936", 347 | Text: "dummy text (2022-04-01 13:19:48 +0900 Asia/Tokyo)", 348 | Created: mustToTime("2022-04-01T13:19:48+09:00"), 349 | }, 350 | { 351 | ID: "1509702983092633602", 352 | Text: "dummy text (2022-04-01 10:23:34 +0900 Asia/Tokyo)", 353 | Created: mustToTime("2022-04-01T10:23:34+09:00"), 354 | }, 355 | { 356 | ID: "1509554460724719626", 357 | Text: "dummy text (2022-04-01 00:33:24 +0900 Asia/Tokyo)", 358 | Created: mustToTime("2022-04-01T00:33:24+09:00"), 359 | }, 360 | { 361 | ID: "1509553063698534404", 362 | Text: "dummy text (2022-04-01 00:27:51 +0900 Asia/Tokyo)", 363 | Created: mustToTime("2022-04-01T00:27:51+09:00"), 364 | }, 365 | { 366 | ID: "1509550757233631236", 367 | Text: "dummy text (2022-04-01 00:18:41 +0900 Asia/Tokyo)", 368 | Created: mustToTime("2022-04-01T00:18:41+09:00"), 369 | }, 370 | { 371 | ID: "1509547082822332427", 372 | Text: "dummy text (2022-04-01 00:04:05 +0900 Asia/Tokyo)", 373 | Created: mustToTime("2022-04-01T00:04:05+09:00"), 374 | }, 375 | { 376 | ID: "1509546403508465670", 377 | Text: "dummy text (2022-04-01 00:01:23 +0900 Asia/Tokyo)", 378 | Created: mustToTime("2022-04-01T00:01:23+09:00"), 379 | }, 380 | { 381 | ID: "1509542500692029447", 382 | Text: "dummy text (2022-03-31 23:45:52 +0900 Asia/Tokyo)", 383 | Created: mustToTime("2022-03-31T23:45:52+09:00"), 384 | }, 385 | { 386 | ID: "1509517710077882371", 387 | Text: "dummy text (2022-03-31 22:07:22 +0900 Asia/Tokyo)", 388 | Created: mustToTime("2022-03-31T22:07:22+09:00"), 389 | }, 390 | { 391 | ID: "1509516563401310212", 392 | Text: "dummy text (2022-03-31 22:02:48 +0900 Asia/Tokyo)", 393 | Created: mustToTime("2022-03-31T22:02:48+09:00"), 394 | }, 395 | { 396 | ID: "1509514020529008642", 397 | Text: "dummy text (2022-03-31 21:52:42 +0900 Asia/Tokyo)", 398 | Created: mustToTime("2022-03-31T21:52:42+09:00"), 399 | }, 400 | { 401 | ID: "1509504664651599882", 402 | Text: "dummy text (2022-03-31 21:15:31 +0900 Asia/Tokyo)", 403 | Created: mustToTime("2022-03-31T21:15:31+09:00"), 404 | }, 405 | { 406 | ID: "1509501120586330113", 407 | Text: "dummy text (2022-03-31 21:01:26 +0900 Asia/Tokyo)", 408 | Created: mustToTime("2022-03-31T21:01:26+09:00"), 409 | }, 410 | { 411 | ID: "1509499360354381835", 412 | Text: "dummy text (2022-03-31 20:54:27 +0900 Asia/Tokyo)", 413 | Created: mustToTime("2022-03-31T20:54:27+09:00"), 414 | }, 415 | { 416 | ID: "1509499069018013700", 417 | Text: "dummy text (2022-03-31 20:53:17 +0900 Asia/Tokyo)", 418 | Created: mustToTime("2022-03-31T20:53:17+09:00"), 419 | }, 420 | { 421 | ID: "1509498985186480136", 422 | Text: "dummy text (2022-03-31 20:52:57 +0900 Asia/Tokyo)", 423 | Created: mustToTime("2022-03-31T20:52:57+09:00"), 424 | }, 425 | { 426 | ID: "1509498936197025805", 427 | Text: "dummy text (2022-03-31 20:52:46 +0900 Asia/Tokyo)", 428 | Created: mustToTime("2022-03-31T20:52:46+09:00"), 429 | }, 430 | { 431 | ID: "1509498893033414673", 432 | Text: "dummy text (2022-03-31 20:52:35 +0900 Asia/Tokyo)", 433 | Created: mustToTime("2022-03-31T20:52:35+09:00"), 434 | }, 435 | { 436 | ID: "1509484226739339264", 437 | Text: "dummy text (2022-03-31 19:54:19 +0900 Asia/Tokyo)", 438 | Created: mustToTime("2022-03-31T19:54:19+09:00"), 439 | }, 440 | { 441 | ID: "1509482360622174212", 442 | Text: "dummy text (2022-03-31 19:46:54 +0900 Asia/Tokyo)", 443 | Created: mustToTime("2022-03-31T19:46:54+09:00"), 444 | }, 445 | { 446 | ID: "1509416042317053952", 447 | Text: "dummy text (2022-03-31 15:23:22 +0900 Asia/Tokyo)", 448 | Created: mustToTime("2022-03-31T15:23:22+09:00"), 449 | }, 450 | { 451 | ID: "1509409703969042432", 452 | Text: "dummy text (2022-03-31 14:58:11 +0900 Asia/Tokyo)", 453 | Created: mustToTime("2022-03-31T14:58:11+09:00"), 454 | }, 455 | { 456 | ID: "1509385713774899200", 457 | Text: "dummy text (2022-03-31 13:22:51 +0900 Asia/Tokyo)", 458 | Created: mustToTime("2022-03-31T13:22:51+09:00"), 459 | }, 460 | { 461 | ID: "1509383287936286721", 462 | Text: "dummy text (2022-03-31 13:13:13 +0900 Asia/Tokyo)", 463 | Created: mustToTime("2022-03-31T13:13:13+09:00"), 464 | }, 465 | { 466 | ID: "1509348361518092289", 467 | Text: "dummy text (2022-03-31 10:54:26 +0900 Asia/Tokyo)", 468 | Created: mustToTime("2022-03-31T10:54:26+09:00"), 469 | }, 470 | { 471 | ID: "1509196986201051136", 472 | Text: "dummy text (2022-03-31 00:52:55 +0900 Asia/Tokyo)", 473 | Created: mustToTime("2022-03-31T00:52:55+09:00"), 474 | }, 475 | { 476 | ID: "1509193718523633665", 477 | Text: "dummy text (2022-03-31 00:39:56 +0900 Asia/Tokyo)", 478 | Created: mustToTime("2022-03-31T00:39:56+09:00"), 479 | }, 480 | { 481 | ID: "1509193677549469704", 482 | Text: "dummy text (2022-03-31 00:39:46 +0900 Asia/Tokyo)", 483 | Created: mustToTime("2022-03-31T00:39:46+09:00"), 484 | }, 485 | { 486 | ID: "1509191912733155329", 487 | Text: "dummy text (2022-03-31 00:32:46 +0900 Asia/Tokyo)", 488 | Created: mustToTime("2022-03-31T00:32:46+09:00"), 489 | }, 490 | { 491 | ID: "1509188747791077385", 492 | Text: "dummy text (2022-03-31 00:20:11 +0900 Asia/Tokyo)", 493 | Created: mustToTime("2022-03-31T00:20:11+09:00"), 494 | }, 495 | { 496 | ID: "1509188109149569024", 497 | Text: "dummy text (2022-03-31 00:17:39 +0900 Asia/Tokyo)", 498 | Created: mustToTime("2022-03-31T00:17:39+09:00"), 499 | }, 500 | { 501 | ID: "1509187074620268552", 502 | Text: "dummy text (2022-03-31 00:13:32 +0900 Asia/Tokyo)", 503 | Created: mustToTime("2022-03-31T00:13:32+09:00"), 504 | }, 505 | { 506 | ID: "1509183980196794376", 507 | Text: "dummy text (2022-03-31 00:01:14 +0900 Asia/Tokyo)", 508 | Created: mustToTime("2022-03-31T00:01:14+09:00"), 509 | }, 510 | { 511 | ID: "1509153737574871043", 512 | Text: "dummy text (2022-03-30 22:01:04 +0900 Asia/Tokyo)", 513 | Created: mustToTime("2022-03-30T22:01:04+09:00"), 514 | }, 515 | { 516 | ID: "1509149528179736581", 517 | Text: "dummy text (2022-03-30 21:44:20 +0900 Asia/Tokyo)", 518 | Created: mustToTime("2022-03-30T21:44:20+09:00"), 519 | }, 520 | { 521 | ID: "1509147311615922191", 522 | Text: "dummy text (2022-03-30 21:35:32 +0900 Asia/Tokyo)", 523 | Created: mustToTime("2022-03-30T21:35:32+09:00"), 524 | }, 525 | { 526 | ID: "1509028881378209796", 527 | Text: "dummy text (2022-03-30 13:44:56 +0900 Asia/Tokyo)", 528 | Created: mustToTime("2022-03-30T13:44:56+09:00"), 529 | }, 530 | { 531 | ID: "1508824290405531648", 532 | Text: "dummy text (2022-03-30 00:11:58 +0900 Asia/Tokyo)", 533 | Created: mustToTime("2022-03-30T00:11:58+09:00"), 534 | }, 535 | { 536 | ID: "1508823762715963405", 537 | Text: "dummy text (2022-03-30 00:09:52 +0900 Asia/Tokyo)", 538 | Created: mustToTime("2022-03-30T00:09:52+09:00"), 539 | }, 540 | { 541 | ID: "1508822980633460741", 542 | Text: "dummy text (2022-03-30 00:06:45 +0900 Asia/Tokyo)", 543 | Created: mustToTime("2022-03-30T00:06:45+09:00"), 544 | }, 545 | { 546 | ID: "1508821631933820930", 547 | Text: "dummy text (2022-03-30 00:01:24 +0900 Asia/Tokyo)", 548 | Created: mustToTime("2022-03-30T00:01:24+09:00"), 549 | }, 550 | { 551 | ID: "1508789649862725635", 552 | Text: "dummy text (2022-03-29 21:54:19 +0900 Asia/Tokyo)", 553 | Created: mustToTime("2022-03-29T21:54:19+09:00"), 554 | }, 555 | { 556 | ID: "1508789558078742531", 557 | Text: "dummy text (2022-03-29 21:53:57 +0900 Asia/Tokyo)", 558 | Created: mustToTime("2022-03-29T21:53:57+09:00"), 559 | }, 560 | { 561 | ID: "1508785843271761925", 562 | Text: "dummy text (2022-03-29 21:39:11 +0900 Asia/Tokyo)", 563 | Created: mustToTime("2022-03-29T21:39:11+09:00"), 564 | }, 565 | { 566 | ID: "1508776699298992137", 567 | Text: "dummy text (2022-03-29 21:02:51 +0900 Asia/Tokyo)", 568 | Created: mustToTime("2022-03-29T21:02:51+09:00"), 569 | }, 570 | { 571 | ID: "1508771601470222336", 572 | Text: "dummy text (2022-03-29 20:42:36 +0900 Asia/Tokyo)", 573 | Created: mustToTime("2022-03-29T20:42:36+09:00"), 574 | }, 575 | { 576 | ID: "1508769477709950984", 577 | Text: "dummy text (2022-03-29 20:34:09 +0900 Asia/Tokyo)", 578 | Created: mustToTime("2022-03-29T20:34:09+09:00"), 579 | }, 580 | { 581 | ID: "1508768216084287492", 582 | Text: "dummy text (2022-03-29 20:29:08 +0900 Asia/Tokyo)", 583 | Created: mustToTime("2022-03-29T20:29:08+09:00"), 584 | }, 585 | { 586 | ID: "1508763761272057857", 587 | Text: "dummy text (2022-03-29 20:11:26 +0900 Asia/Tokyo)", 588 | Created: mustToTime("2022-03-29T20:11:26+09:00"), 589 | }, 590 | { 591 | ID: "1508763196504809473", 592 | Text: "dummy text (2022-03-29 20:09:12 +0900 Asia/Tokyo)", 593 | Created: mustToTime("2022-03-29T20:09:12+09:00"), 594 | }, 595 | { 596 | ID: "1508701612218322945", 597 | Text: "dummy text (2022-03-29 16:04:29 +0900 Asia/Tokyo)", 598 | Created: mustToTime("2022-03-29T16:04:29+09:00"), 599 | }, 600 | { 601 | ID: "1508459228083761153", 602 | Text: "dummy text (2022-03-29 00:01:20 +0900 Asia/Tokyo)", 603 | Created: mustToTime("2022-03-29T00:01:20+09:00"), 604 | }, 605 | { 606 | ID: "1508424445228363777", 607 | Text: "dummy text (2022-03-28 21:43:07 +0900 Asia/Tokyo)", 608 | Created: mustToTime("2022-03-28T21:43:07+09:00"), 609 | }, 610 | { 611 | ID: "1508416339769454593", 612 | Text: "dummy text (2022-03-28 21:10:55 +0900 Asia/Tokyo)", 613 | Created: mustToTime("2022-03-28T21:10:55+09:00"), 614 | }, 615 | { 616 | ID: "1508412648488013826", 617 | Text: "dummy text (2022-03-28 20:56:15 +0900 Asia/Tokyo)", 618 | Created: mustToTime("2022-03-28T20:56:15+09:00"), 619 | }, 620 | { 621 | ID: "1508411726387064834", 622 | Text: "dummy text (2022-03-28 20:52:35 +0900 Asia/Tokyo)", 623 | Created: mustToTime("2022-03-28T20:52:35+09:00"), 624 | }, 625 | { 626 | ID: "1508384632638939136", 627 | Text: "dummy text (2022-03-28 19:04:55 +0900 Asia/Tokyo)", 628 | Created: mustToTime("2022-03-28T19:04:55+09:00"), 629 | }, 630 | { 631 | ID: "1508313544135426050", 632 | Text: "dummy text (2022-03-28 14:22:26 +0900 Asia/Tokyo)", 633 | Created: mustToTime("2022-03-28T14:22:26+09:00"), 634 | }, 635 | { 636 | ID: "1508305243624587264", 637 | Text: "dummy text (2022-03-28 13:49:27 +0900 Asia/Tokyo)", 638 | Created: mustToTime("2022-03-28T13:49:27+09:00"), 639 | }, 640 | { 641 | ID: "1508302722231316483", 642 | Text: "dummy text (2022-03-28 13:39:26 +0900 Asia/Tokyo)", 643 | Created: mustToTime("2022-03-28T13:39:26+09:00"), 644 | }, 645 | { 646 | ID: "1508300200061808648", 647 | Text: "dummy text (2022-03-28 13:29:25 +0900 Asia/Tokyo)", 648 | Created: mustToTime("2022-03-28T13:29:25+09:00"), 649 | }, 650 | { 651 | ID: "1508291027211517954", 652 | Text: "dummy text (2022-03-28 12:52:58 +0900 Asia/Tokyo)", 653 | Created: mustToTime("2022-03-28T12:52:58+09:00"), 654 | }, 655 | { 656 | ID: "1508283866829959169", 657 | Text: "dummy text (2022-03-28 12:24:31 +0900 Asia/Tokyo)", 658 | Created: mustToTime("2022-03-28T12:24:31+09:00"), 659 | }, 660 | { 661 | ID: "1508105827068280833", 662 | Text: "dummy text (2022-03-28 00:37:03 +0900 Asia/Tokyo)", 663 | Created: mustToTime("2022-03-28T00:37:03+09:00"), 664 | }, 665 | { 666 | ID: "1508096854118088717", 667 | Text: "dummy text (2022-03-28 00:01:23 +0900 Asia/Tokyo)", 668 | Created: mustToTime("2022-03-28T00:01:23+09:00"), 669 | }, 670 | { 671 | ID: "1508094843096305669", 672 | Text: "dummy text (2022-03-27 23:53:24 +0900 Asia/Tokyo)", 673 | Created: mustToTime("2022-03-27T23:53:24+09:00"), 674 | }, 675 | { 676 | ID: "1508065914872557569", 677 | Text: "dummy text (2022-03-27 21:58:27 +0900 Asia/Tokyo)", 678 | Created: mustToTime("2022-03-27T21:58:27+09:00"), 679 | }, 680 | { 681 | ID: "1508064311218143236", 682 | Text: "dummy text (2022-03-27 21:52:04 +0900 Asia/Tokyo)", 683 | Created: mustToTime("2022-03-27T21:52:04+09:00"), 684 | }, 685 | { 686 | ID: "1508056271852740620", 687 | Text: "dummy text (2022-03-27 21:20:08 +0900 Asia/Tokyo)", 688 | Created: mustToTime("2022-03-27T21:20:08+09:00"), 689 | }, 690 | { 691 | ID: "1508055909150658561", 692 | Text: "dummy text (2022-03-27 21:18:41 +0900 Asia/Tokyo)", 693 | Created: mustToTime("2022-03-27T21:18:41+09:00"), 694 | }, 695 | { 696 | ID: "1508055066112327684", 697 | Text: "dummy text (2022-03-27 21:15:20 +0900 Asia/Tokyo)", 698 | Created: mustToTime("2022-03-27T21:15:20+09:00"), 699 | }, 700 | { 701 | ID: "1508054944741732352", 702 | Text: "dummy text (2022-03-27 21:14:51 +0900 Asia/Tokyo)", 703 | Created: mustToTime("2022-03-27T21:14:51+09:00"), 704 | }, 705 | { 706 | ID: "1508046683435057152", 707 | Text: "dummy text (2022-03-27 20:42:02 +0900 Asia/Tokyo)", 708 | Created: mustToTime("2022-03-27T20:42:02+09:00"), 709 | }, 710 | { 711 | ID: "1508046669421903876", 712 | Text: "dummy text (2022-03-27 20:41:58 +0900 Asia/Tokyo)", 713 | Created: mustToTime("2022-03-27T20:41:58+09:00"), 714 | }, 715 | { 716 | ID: "1507978802839752706", 717 | Text: "dummy text (2022-03-27 16:12:18 +0900 Asia/Tokyo)", 718 | Created: mustToTime("2022-03-27T16:12:18+09:00"), 719 | }, 720 | { 721 | ID: "1507975571774799880", 722 | Text: "dummy text (2022-03-27 15:59:27 +0900 Asia/Tokyo)", 723 | Created: mustToTime("2022-03-27T15:59:27+09:00"), 724 | }, 725 | { 726 | ID: "1507973595636826128", 727 | Text: "dummy text (2022-03-27 15:51:36 +0900 Asia/Tokyo)", 728 | Created: mustToTime("2022-03-27T15:51:36+09:00"), 729 | }, 730 | { 731 | ID: "1507971564511559688", 732 | Text: "dummy text (2022-03-27 15:43:32 +0900 Asia/Tokyo)", 733 | Created: mustToTime("2022-03-27T15:43:32+09:00"), 734 | }, 735 | { 736 | ID: "1507971288891277312", 737 | Text: "dummy text (2022-03-27 15:42:26 +0900 Asia/Tokyo)", 738 | Created: mustToTime("2022-03-27T15:42:26+09:00"), 739 | }, 740 | { 741 | ID: "1507970876431794177", 742 | Text: "dummy text (2022-03-27 15:40:48 +0900 Asia/Tokyo)", 743 | Created: mustToTime("2022-03-27T15:40:48+09:00"), 744 | }, 745 | { 746 | ID: "1507963191820972034", 747 | Text: "dummy text (2022-03-27 15:10:16 +0900 Asia/Tokyo)", 748 | Created: mustToTime("2022-03-27T15:10:16+09:00"), 749 | }, 750 | { 751 | ID: "1507949089123868676", 752 | Text: "dummy text (2022-03-27 14:14:13 +0900 Asia/Tokyo)", 753 | Created: mustToTime("2022-03-27T14:14:13+09:00"), 754 | }, 755 | { 756 | ID: "1507939654653607939", 757 | Text: "dummy text (2022-03-27 13:36:44 +0900 Asia/Tokyo)", 758 | Created: mustToTime("2022-03-27T13:36:44+09:00"), 759 | }, 760 | { 761 | ID: "1507746026224259073", 762 | Text: "dummy text (2022-03-27 00:47:19 +0900 Asia/Tokyo)", 763 | Created: mustToTime("2022-03-27T00:47:19+09:00"), 764 | }, 765 | { 766 | ID: "1507742932253933575", 767 | Text: "dummy text (2022-03-27 00:35:02 +0900 Asia/Tokyo)", 768 | Created: mustToTime("2022-03-27T00:35:02+09:00"), 769 | }, 770 | { 771 | ID: "1507742757959630849", 772 | Text: "dummy text (2022-03-27 00:34:20 +0900 Asia/Tokyo)", 773 | Created: mustToTime("2022-03-27T00:34:20+09:00"), 774 | }, 775 | { 776 | ID: "1507741455372423169", 777 | Text: "dummy text (2022-03-27 00:29:10 +0900 Asia/Tokyo)", 778 | Created: mustToTime("2022-03-27T00:29:10+09:00"), 779 | }, 780 | { 781 | ID: "1507734432110817280", 782 | Text: "dummy text (2022-03-27 00:01:15 +0900 Asia/Tokyo)", 783 | Created: mustToTime("2022-03-27T00:01:15+09:00"), 784 | }, 785 | { 786 | ID: "1507727604115439618", 787 | Text: "dummy text (2022-03-26 23:34:07 +0900 Asia/Tokyo)", 788 | Created: mustToTime("2022-03-26T23:34:07+09:00"), 789 | }, 790 | { 791 | ID: "1507709156588875779", 792 | Text: "dummy text (2022-03-26 22:20:49 +0900 Asia/Tokyo)", 793 | Created: mustToTime("2022-03-26T22:20:49+09:00"), 794 | }, 795 | { 796 | ID: "1507682026182541319", 797 | Text: "dummy text (2022-03-26 20:33:01 +0900 Asia/Tokyo)", 798 | Created: mustToTime("2022-03-26T20:33:01+09:00"), 799 | }, 800 | { 801 | ID: "1507596190892044289", 802 | Text: "dummy text (2022-03-26 14:51:56 +0900 Asia/Tokyo)", 803 | Created: mustToTime("2022-03-26T14:51:56+09:00"), 804 | }, 805 | { 806 | ID: "1507585663977541634", 807 | Text: "dummy text (2022-03-26 14:10:06 +0900 Asia/Tokyo)", 808 | Created: mustToTime("2022-03-26T14:10:06+09:00"), 809 | }, 810 | { 811 | ID: "1507547927765389312", 812 | Text: "dummy text (2022-03-26 11:40:09 +0900 Asia/Tokyo)", 813 | Created: mustToTime("2022-03-26T11:40:09+09:00"), 814 | }, 815 | { 816 | ID: "1507547460243111937", 817 | Text: "dummy text (2022-03-26 11:38:18 +0900 Asia/Tokyo)", 818 | Created: mustToTime("2022-03-26T11:38:18+09:00"), 819 | }, 820 | { 821 | ID: "1507381157134213128", 822 | Text: "dummy text (2022-03-26 00:37:28 +0900 Asia/Tokyo)", 823 | Created: mustToTime("2022-03-26T00:37:28+09:00"), 824 | }, 825 | { 826 | ID: "1507372056912044046", 827 | Text: "dummy text (2022-03-26 00:01:18 +0900 Asia/Tokyo)", 828 | Created: mustToTime("2022-03-26T00:01:18+09:00"), 829 | }, 830 | { 831 | ID: "1507361083123646476", 832 | Text: "dummy text (2022-03-25 23:17:42 +0900 Asia/Tokyo)", 833 | Created: mustToTime("2022-03-25T23:17:42+09:00"), 834 | }, 835 | { 836 | ID: "1507359725117394953", 837 | Text: "dummy text (2022-03-25 23:12:18 +0900 Asia/Tokyo)", 838 | Created: mustToTime("2022-03-25T23:12:18+09:00"), 839 | }, 840 | { 841 | ID: "1507356235485437958", 842 | Text: "dummy text (2022-03-25 22:58:26 +0900 Asia/Tokyo)", 843 | Created: mustToTime("2022-03-25T22:58:26+09:00"), 844 | }, 845 | { 846 | ID: "1507353685646782464", 847 | Text: "dummy text (2022-03-25 22:48:18 +0900 Asia/Tokyo)", 848 | Created: mustToTime("2022-03-25T22:48:18+09:00"), 849 | }, 850 | { 851 | ID: "1507328325253214208", 852 | Text: "dummy text (2022-03-25 21:07:32 +0900 Asia/Tokyo)", 853 | Created: mustToTime("2022-03-25T21:07:32+09:00"), 854 | }, 855 | { 856 | ID: "1507322502049308680", 857 | Text: "dummy text (2022-03-25 20:44:23 +0900 Asia/Tokyo)", 858 | Created: mustToTime("2022-03-25T20:44:23+09:00"), 859 | }, 860 | { 861 | ID: "1507281354794450991", 862 | Text: "dummy text (2022-03-25 18:00:53 +0900 Asia/Tokyo)", 863 | Created: mustToTime("2022-03-25T18:00:53+09:00"), 864 | }, 865 | { 866 | ID: "1507250936045268994", 867 | Text: "dummy text (2022-03-25 16:00:01 +0900 Asia/Tokyo)", 868 | Created: mustToTime("2022-03-25T16:00:01+09:00"), 869 | }, 870 | { 871 | ID: "1507250317255389193", 872 | Text: "dummy text (2022-03-25 15:57:33 +0900 Asia/Tokyo)", 873 | Created: mustToTime("2022-03-25T15:57:33+09:00"), 874 | }, 875 | { 876 | ID: "1507230934957162499", 877 | Text: "dummy text (2022-03-25 14:40:32 +0900 Asia/Tokyo)", 878 | Created: mustToTime("2022-03-25T14:40:32+09:00"), 879 | }, 880 | { 881 | ID: "1507210918626992138", 882 | Text: "dummy text (2022-03-25 13:21:00 +0900 Asia/Tokyo)", 883 | Created: mustToTime("2022-03-25T13:21:00+09:00"), 884 | }, 885 | { 886 | ID: "1507192926123700237", 887 | Text: "dummy text (2022-03-25 12:09:30 +0900 Asia/Tokyo)", 888 | Created: mustToTime("2022-03-25T12:09:30+09:00"), 889 | }, 890 | { 891 | ID: "1507027627068243971", 892 | Text: "dummy text (2022-03-25 01:12:40 +0900 Asia/Tokyo)", 893 | Created: mustToTime("2022-03-25T01:12:40+09:00"), 894 | }, 895 | { 896 | ID: "1507009649626652679", 897 | Text: "dummy text (2022-03-25 00:01:14 +0900 Asia/Tokyo)", 898 | Created: mustToTime("2022-03-25T00:01:14+09:00"), 899 | }, 900 | { 901 | ID: "1506998262552993798", 902 | Text: "dummy text (2022-03-24 23:15:59 +0900 Asia/Tokyo)", 903 | Created: mustToTime("2022-03-24T23:15:59+09:00"), 904 | }, 905 | { 906 | ID: "1506994353486852098", 907 | Text: "dummy text (2022-03-24 23:00:27 +0900 Asia/Tokyo)", 908 | Created: mustToTime("2022-03-24T23:00:27+09:00"), 909 | }, 910 | { 911 | ID: "1506983812932444171", 912 | Text: "dummy text (2022-03-24 22:18:34 +0900 Asia/Tokyo)", 913 | Created: mustToTime("2022-03-24T22:18:34+09:00"), 914 | }, 915 | { 916 | ID: "1506982249694384133", 917 | Text: "dummy text (2022-03-24 22:12:21 +0900 Asia/Tokyo)", 918 | Created: mustToTime("2022-03-24T22:12:21+09:00"), 919 | }, 920 | { 921 | ID: "1506981577469100035", 922 | Text: "dummy text (2022-03-24 22:09:41 +0900 Asia/Tokyo)", 923 | Created: mustToTime("2022-03-24T22:09:41+09:00"), 924 | }, 925 | { 926 | ID: "1506973033663459330", 927 | Text: "dummy text (2022-03-24 21:35:44 +0900 Asia/Tokyo)", 928 | Created: mustToTime("2022-03-24T21:35:44+09:00"), 929 | }, 930 | { 931 | ID: "1506961058778718209", 932 | Text: "dummy text (2022-03-24 20:48:09 +0900 Asia/Tokyo)", 933 | Created: mustToTime("2022-03-24T20:48:09+09:00"), 934 | }, 935 | { 936 | ID: "1506959313407213569", 937 | Text: "dummy text (2022-03-24 20:41:12 +0900 Asia/Tokyo)", 938 | Created: mustToTime("2022-03-24T20:41:12+09:00"), 939 | }, 940 | { 941 | ID: "1506955958148546569", 942 | Text: "dummy text (2022-03-24 20:27:52 +0900 Asia/Tokyo)", 943 | Created: mustToTime("2022-03-24T20:27:52+09:00"), 944 | }, 945 | { 946 | ID: "1506918532952788992", 947 | Text: "dummy text (2022-03-24 17:59:10 +0900 Asia/Tokyo)", 948 | Created: mustToTime("2022-03-24T17:59:10+09:00"), 949 | }, 950 | { 951 | ID: "1506917857749528579", 952 | Text: "dummy text (2022-03-24 17:56:29 +0900 Asia/Tokyo)", 953 | Created: mustToTime("2022-03-24T17:56:29+09:00"), 954 | }, 955 | { 956 | ID: "1506915289446825987", 957 | Text: "dummy text (2022-03-24 17:46:16 +0900 Asia/Tokyo)", 958 | Created: mustToTime("2022-03-24T17:46:16+09:00"), 959 | }, 960 | { 961 | ID: "1506914525203013633", 962 | Text: "dummy text (2022-03-24 17:43:14 +0900 Asia/Tokyo)", 963 | Created: mustToTime("2022-03-24T17:43:14+09:00"), 964 | }, 965 | { 966 | ID: "1506912377996201990", 967 | Text: "dummy text (2022-03-24 17:34:42 +0900 Asia/Tokyo)", 968 | Created: mustToTime("2022-03-24T17:34:42+09:00"), 969 | }, 970 | { 971 | ID: "1506912283624017922", 972 | Text: "dummy text (2022-03-24 17:34:20 +0900 Asia/Tokyo)", 973 | Created: mustToTime("2022-03-24T17:34:20+09:00"), 974 | }, 975 | { 976 | ID: "1506908820945387521", 977 | Text: "dummy text (2022-03-24 17:20:34 +0900 Asia/Tokyo)", 978 | Created: mustToTime("2022-03-24T17:20:34+09:00"), 979 | }, 980 | { 981 | ID: "1506889495978799106", 982 | Text: "dummy text (2022-03-24 16:03:47 +0900 Asia/Tokyo)", 983 | Created: mustToTime("2022-03-24T16:03:47+09:00"), 984 | }, 985 | { 986 | ID: "1506860320203362304", 987 | Text: "dummy text (2022-03-24 14:07:51 +0900 Asia/Tokyo)", 988 | Created: mustToTime("2022-03-24T14:07:51+09:00"), 989 | }, 990 | { 991 | ID: "1506850815826624512", 992 | Text: "dummy text (2022-03-24 13:30:05 +0900 Asia/Tokyo)", 993 | Created: mustToTime("2022-03-24T13:30:05+09:00"), 994 | }, 995 | { 996 | ID: "1506848428755927040", 997 | Text: "dummy text (2022-03-24 13:20:35 +0900 Asia/Tokyo)", 998 | Created: mustToTime("2022-03-24T13:20:35+09:00"), 999 | }, 1000 | { 1001 | ID: "1506844940621725699", 1002 | Text: "dummy text (2022-03-24 13:06:44 +0900 Asia/Tokyo)", 1003 | Created: mustToTime("2022-03-24T13:06:44+09:00"), 1004 | }, 1005 | { 1006 | ID: "1506647267947724803", 1007 | Text: "dummy text (2022-03-24 00:01:15 +0900 Asia/Tokyo)", 1008 | Created: mustToTime("2022-03-24T00:01:15+09:00"), 1009 | }, 1010 | { 1011 | ID: "1506641656971673603", 1012 | Text: "dummy text (2022-03-23 23:38:57 +0900 Asia/Tokyo)", 1013 | Created: mustToTime("2022-03-23T23:38:57+09:00"), 1014 | }, 1015 | { 1016 | ID: "1506622742166409220", 1017 | Text: "dummy text (2022-03-23 22:23:48 +0900 Asia/Tokyo)", 1018 | Created: mustToTime("2022-03-23T22:23:48+09:00"), 1019 | }, 1020 | { 1021 | ID: "1506608413718958093", 1022 | Text: "dummy text (2022-03-23 21:26:51 +0900 Asia/Tokyo)", 1023 | Created: mustToTime("2022-03-23T21:26:51+09:00"), 1024 | }, 1025 | { 1026 | ID: "1506510066324676615", 1027 | Text: "dummy text (2022-03-23 14:56:04 +0900 Asia/Tokyo)", 1028 | Created: mustToTime("2022-03-23T14:56:04+09:00"), 1029 | }, 1030 | { 1031 | ID: "1506489644279427073", 1032 | Text: "dummy text (2022-03-23 13:34:55 +0900 Asia/Tokyo)", 1033 | Created: mustToTime("2022-03-23T13:34:55+09:00"), 1034 | }, 1035 | { 1036 | ID: "1506449152582234113", 1037 | Text: "dummy text (2022-03-23 10:54:01 +0900 Asia/Tokyo)", 1038 | Created: mustToTime("2022-03-23T10:54:01+09:00"), 1039 | }, 1040 | { 1041 | ID: "1506442618024398849", 1042 | Text: "dummy text (2022-03-23 10:28:03 +0900 Asia/Tokyo)", 1043 | Created: mustToTime("2022-03-23T10:28:03+09:00"), 1044 | }, 1045 | { 1046 | ID: "1506442391217389573", 1047 | Text: "dummy text (2022-03-23 10:27:09 +0900 Asia/Tokyo)", 1048 | Created: mustToTime("2022-03-23T10:27:09+09:00"), 1049 | }, 1050 | { 1051 | ID: "1506441852400320513", 1052 | Text: "dummy text (2022-03-23 10:25:00 +0900 Asia/Tokyo)", 1053 | Created: mustToTime("2022-03-23T10:25:00+09:00"), 1054 | }, 1055 | { 1056 | ID: "1506436402707595265", 1057 | Text: "dummy text (2022-03-23 10:03:21 +0900 Asia/Tokyo)", 1058 | Created: mustToTime("2022-03-23T10:03:21+09:00"), 1059 | }, 1060 | { 1061 | ID: "1506302933495074816", 1062 | Text: "dummy text (2022-03-23 01:12:59 +0900 Asia/Tokyo)", 1063 | Created: mustToTime("2022-03-23T01:12:59+09:00"), 1064 | }, 1065 | { 1066 | ID: "1506302889350041600", 1067 | Text: "dummy text (2022-03-23 01:12:49 +0900 Asia/Tokyo)", 1068 | Created: mustToTime("2022-03-23T01:12:49+09:00"), 1069 | }, 1070 | { 1071 | ID: "1506302666615693316", 1072 | Text: "dummy text (2022-03-23 01:11:56 +0900 Asia/Tokyo)", 1073 | Created: mustToTime("2022-03-23T01:11:56+09:00"), 1074 | }, 1075 | { 1076 | ID: "1506302285340897282", 1077 | Text: "dummy text (2022-03-23 01:10:25 +0900 Asia/Tokyo)", 1078 | Created: mustToTime("2022-03-23T01:10:25+09:00"), 1079 | }, 1080 | { 1081 | ID: "1506299383641669634", 1082 | Text: "dummy text (2022-03-23 00:58:53 +0900 Asia/Tokyo)", 1083 | Created: mustToTime("2022-03-23T00:58:53+09:00"), 1084 | }, 1085 | { 1086 | ID: "1506298266191003649", 1087 | Text: "dummy text (2022-03-23 00:54:26 +0900 Asia/Tokyo)", 1088 | Created: mustToTime("2022-03-23T00:54:26+09:00"), 1089 | }, 1090 | { 1091 | ID: "1506298190429290499", 1092 | Text: "dummy text (2022-03-23 00:54:08 +0900 Asia/Tokyo)", 1093 | Created: mustToTime("2022-03-23T00:54:08+09:00"), 1094 | }, 1095 | { 1096 | ID: "1506298139934093320", 1097 | Text: "dummy text (2022-03-23 00:53:56 +0900 Asia/Tokyo)", 1098 | Created: mustToTime("2022-03-23T00:53:56+09:00"), 1099 | }, 1100 | { 1101 | ID: "1506297029332717568", 1102 | Text: "dummy text (2022-03-23 00:49:32 +0900 Asia/Tokyo)", 1103 | Created: mustToTime("2022-03-23T00:49:32+09:00"), 1104 | }, 1105 | { 1106 | ID: "1506296920800894978", 1107 | Text: "dummy text (2022-03-23 00:49:06 +0900 Asia/Tokyo)", 1108 | Created: mustToTime("2022-03-23T00:49:06+09:00"), 1109 | }, 1110 | { 1111 | ID: "1506288647993270279", 1112 | Text: "dummy text (2022-03-23 00:16:13 +0900 Asia/Tokyo)", 1113 | Created: mustToTime("2022-03-23T00:16:13+09:00"), 1114 | }, 1115 | { 1116 | ID: "1506284902655926274", 1117 | Text: "dummy text (2022-03-23 00:01:20 +0900 Asia/Tokyo)", 1118 | Created: mustToTime("2022-03-23T00:01:20+09:00"), 1119 | }, 1120 | { 1121 | ID: "1506252393276588036", 1122 | Text: "dummy text (2022-03-22 21:52:10 +0900 Asia/Tokyo)", 1123 | Created: mustToTime("2022-03-22T21:52:10+09:00"), 1124 | }, 1125 | { 1126 | ID: "1506237820603875328", 1127 | Text: "dummy text (2022-03-22 20:54:15 +0900 Asia/Tokyo)", 1128 | Created: mustToTime("2022-03-22T20:54:15+09:00"), 1129 | }, 1130 | { 1131 | ID: "1506236280450945024", 1132 | Text: "dummy text (2022-03-22 20:48:08 +0900 Asia/Tokyo)", 1133 | Created: mustToTime("2022-03-22T20:48:08+09:00"), 1134 | }, 1135 | { 1136 | ID: "1506236036094967810", 1137 | Text: "dummy text (2022-03-22 20:47:10 +0900 Asia/Tokyo)", 1138 | Created: mustToTime("2022-03-22T20:47:10+09:00"), 1139 | }, 1140 | { 1141 | ID: "1506235978050322434", 1142 | Text: "dummy text (2022-03-22 20:46:56 +0900 Asia/Tokyo)", 1143 | Created: mustToTime("2022-03-22T20:46:56+09:00"), 1144 | }, 1145 | { 1146 | ID: "1506131552337494021", 1147 | Text: "dummy text (2022-03-22 13:51:59 +0900 Asia/Tokyo)", 1148 | Created: mustToTime("2022-03-22T13:51:59+09:00"), 1149 | }, 1150 | { 1151 | ID: "1506121608641277953", 1152 | Text: "dummy text (2022-03-22 13:12:28 +0900 Asia/Tokyo)", 1153 | Created: mustToTime("2022-03-22T13:12:28+09:00"), 1154 | }, 1155 | { 1156 | ID: "1506079342849568769", 1157 | Text: "dummy text (2022-03-22 10:24:31 +0900 Asia/Tokyo)", 1158 | Created: mustToTime("2022-03-22T10:24:31+09:00"), 1159 | }, 1160 | { 1161 | ID: "1506077802176847874", 1162 | Text: "dummy text (2022-03-22 10:18:24 +0900 Asia/Tokyo)", 1163 | Created: mustToTime("2022-03-22T10:18:24+09:00"), 1164 | }, 1165 | { 1166 | ID: "1505930585277370369", 1167 | Text: "dummy text (2022-03-22 00:33:25 +0900 Asia/Tokyo)", 1168 | Created: mustToTime("2022-03-22T00:33:25+09:00"), 1169 | }, 1170 | { 1171 | ID: "1505929950586871816", 1172 | Text: "dummy text (2022-03-22 00:30:53 +0900 Asia/Tokyo)", 1173 | Created: mustToTime("2022-03-22T00:30:53+09:00"), 1174 | }, 1175 | { 1176 | ID: "1505923879864512512", 1177 | Text: "dummy text (2022-03-22 00:06:46 +0900 Asia/Tokyo)", 1178 | Created: mustToTime("2022-03-22T00:06:46+09:00"), 1179 | }, 1180 | { 1181 | ID: "1505922490719211524", 1182 | Text: "dummy text (2022-03-22 00:01:15 +0900 Asia/Tokyo)", 1183 | Created: mustToTime("2022-03-22T00:01:15+09:00"), 1184 | }, 1185 | { 1186 | ID: "1505874962472583173", 1187 | Text: "dummy text (2022-03-21 20:52:23 +0900 Asia/Tokyo)", 1188 | Created: mustToTime("2022-03-21T20:52:23+09:00"), 1189 | }, 1190 | { 1191 | ID: "1505829650286915588", 1192 | Text: "dummy text (2022-03-21 17:52:20 +0900 Asia/Tokyo)", 1193 | Created: mustToTime("2022-03-21T17:52:20+09:00"), 1194 | }, 1195 | { 1196 | ID: "1505805894382075905", 1197 | Text: "dummy text (2022-03-21 16:17:56 +0900 Asia/Tokyo)", 1198 | Created: mustToTime("2022-03-21T16:17:56+09:00"), 1199 | }, 1200 | { 1201 | ID: "1505805537056747528", 1202 | Text: "dummy text (2022-03-21 16:16:31 +0900 Asia/Tokyo)", 1203 | Created: mustToTime("2022-03-21T16:16:31+09:00"), 1204 | }, 1205 | { 1206 | ID: "1505790474648387585", 1207 | Text: "dummy text (2022-03-21 15:16:40 +0900 Asia/Tokyo)", 1208 | Created: mustToTime("2022-03-21T15:16:40+09:00"), 1209 | }, 1210 | { 1211 | ID: "1505786458531004418", 1212 | Text: "dummy text (2022-03-21 15:00:42 +0900 Asia/Tokyo)", 1213 | Created: mustToTime("2022-03-21T15:00:42+09:00"), 1214 | }, 1215 | { 1216 | ID: "1505560100618092549", 1217 | Text: "dummy text (2022-03-21 00:01:14 +0900 Asia/Tokyo)", 1218 | Created: mustToTime("2022-03-21T00:01:14+09:00"), 1219 | }, 1220 | { 1221 | ID: "1505541601552048137", 1222 | Text: "dummy text (2022-03-20 22:47:44 +0900 Asia/Tokyo)", 1223 | Created: mustToTime("2022-03-20T22:47:44+09:00"), 1224 | }, 1225 | { 1226 | ID: "1505534342579179523", 1227 | Text: "dummy text (2022-03-20 22:18:53 +0900 Asia/Tokyo)", 1228 | Created: mustToTime("2022-03-20T22:18:53+09:00"), 1229 | }, 1230 | { 1231 | ID: "1505413688324345856", 1232 | Text: "dummy text (2022-03-20 14:19:27 +0900 Asia/Tokyo)", 1233 | Created: mustToTime("2022-03-20T14:19:27+09:00"), 1234 | }, 1235 | { 1236 | ID: "1505238045146349569", 1237 | Text: "dummy text (2022-03-20 02:41:30 +0900 Asia/Tokyo)", 1238 | Created: mustToTime("2022-03-20T02:41:30+09:00"), 1239 | }, 1240 | { 1241 | ID: "1505235896647680005", 1242 | Text: "dummy text (2022-03-20 02:32:58 +0900 Asia/Tokyo)", 1243 | Created: mustToTime("2022-03-20T02:32:58+09:00"), 1244 | }, 1245 | { 1246 | ID: "1505235186472341504", 1247 | Text: "dummy text (2022-03-20 02:30:09 +0900 Asia/Tokyo)", 1248 | Created: mustToTime("2022-03-20T02:30:09+09:00"), 1249 | }, 1250 | { 1251 | ID: "1505229973916770304", 1252 | Text: "dummy text (2022-03-20 02:09:26 +0900 Asia/Tokyo)", 1253 | Created: mustToTime("2022-03-20T02:09:26+09:00"), 1254 | }, 1255 | { 1256 | ID: "1505229832451289089", 1257 | Text: "dummy text (2022-03-20 02:08:52 +0900 Asia/Tokyo)", 1258 | Created: mustToTime("2022-03-20T02:08:52+09:00"), 1259 | }, 1260 | { 1261 | ID: "1505229509867409410", 1262 | Text: "dummy text (2022-03-20 02:07:35 +0900 Asia/Tokyo)", 1263 | Created: mustToTime("2022-03-20T02:07:35+09:00"), 1264 | }, 1265 | { 1266 | ID: "1505197745321254915", 1267 | Text: "dummy text (2022-03-20 00:01:22 +0900 Asia/Tokyo)", 1268 | Created: mustToTime("2022-03-20T00:01:22+09:00"), 1269 | }, 1270 | { 1271 | ID: "1505162759515770885", 1272 | Text: "dummy text (2022-03-19 21:42:21 +0900 Asia/Tokyo)", 1273 | Created: mustToTime("2022-03-19T21:42:21+09:00"), 1274 | }, 1275 | { 1276 | ID: "1505105335538790400", 1277 | Text: "dummy text (2022-03-19 17:54:10 +0900 Asia/Tokyo)", 1278 | Created: mustToTime("2022-03-19T17:54:10+09:00"), 1279 | }, 1280 | { 1281 | ID: "1504852004853919746", 1282 | Text: "dummy text (2022-03-19 01:07:31 +0900 Asia/Tokyo)", 1283 | Created: mustToTime("2022-03-19T01:07:31+09:00"), 1284 | }, 1285 | { 1286 | ID: "1504835335234244610", 1287 | Text: "dummy text (2022-03-19 00:01:17 +0900 Asia/Tokyo)", 1288 | Created: mustToTime("2022-03-19T00:01:17+09:00"), 1289 | }, 1290 | { 1291 | ID: "1504830183848955908", 1292 | Text: "dummy text (2022-03-18 23:40:48 +0900 Asia/Tokyo)", 1293 | Created: mustToTime("2022-03-18T23:40:48+09:00"), 1294 | }, 1295 | { 1296 | ID: "1504829493206151170", 1297 | Text: "dummy text (2022-03-18 23:38:04 +0900 Asia/Tokyo)", 1298 | Created: mustToTime("2022-03-18T23:38:04+09:00"), 1299 | }, 1300 | { 1301 | ID: "1504821294004842497", 1302 | Text: "dummy text (2022-03-18 23:05:29 +0900 Asia/Tokyo)", 1303 | Created: mustToTime("2022-03-18T23:05:29+09:00"), 1304 | }, 1305 | { 1306 | ID: "1504819566194884609", 1307 | Text: "dummy text (2022-03-18 22:58:37 +0900 Asia/Tokyo)", 1308 | Created: mustToTime("2022-03-18T22:58:37+09:00"), 1309 | }, 1310 | { 1311 | ID: "1504761852324048898", 1312 | Text: "dummy text (2022-03-18 19:09:17 +0900 Asia/Tokyo)", 1313 | Created: mustToTime("2022-03-18T19:09:17+09:00"), 1314 | }, 1315 | { 1316 | ID: "1504744532734365709", 1317 | Text: "dummy text (2022-03-18 18:00:28 +0900 Asia/Tokyo)", 1318 | Created: mustToTime("2022-03-18T18:00:28+09:00"), 1319 | }, 1320 | { 1321 | ID: "1504705584016556032", 1322 | Text: "dummy text (2022-03-18 15:25:41 +0900 Asia/Tokyo)", 1323 | Created: mustToTime("2022-03-18T15:25:41+09:00"), 1324 | }, 1325 | { 1326 | ID: "1504681552378138626", 1327 | Text: "dummy text (2022-03-18 13:50:12 +0900 Asia/Tokyo)", 1328 | Created: mustToTime("2022-03-18T13:50:12+09:00"), 1329 | }, 1330 | { 1331 | ID: "1504673731343691779", 1332 | Text: "dummy text (2022-03-18 13:19:07 +0900 Asia/Tokyo)", 1333 | Created: mustToTime("2022-03-18T13:19:07+09:00"), 1334 | }, 1335 | { 1336 | ID: "1504635431761375239", 1337 | Text: "dummy text (2022-03-18 10:46:56 +0900 Asia/Tokyo)", 1338 | Created: mustToTime("2022-03-18T10:46:56+09:00"), 1339 | }, 1340 | { 1341 | ID: "1504632206463242240", 1342 | Text: "dummy text (2022-03-18 10:34:07 +0900 Asia/Tokyo)", 1343 | Created: mustToTime("2022-03-18T10:34:07+09:00"), 1344 | }, 1345 | { 1346 | ID: "1504472975633903616", 1347 | Text: "dummy text (2022-03-18 00:01:23 +0900 Asia/Tokyo)", 1348 | Created: mustToTime("2022-03-18T00:01:23+09:00"), 1349 | }, 1350 | { 1351 | ID: "1504468790918397953", 1352 | Text: "dummy text (2022-03-17 23:44:46 +0900 Asia/Tokyo)", 1353 | Created: mustToTime("2022-03-17T23:44:46+09:00"), 1354 | }, 1355 | }, nil 1356 | } 1357 | 1358 | func mustToTime(formatted string) time.Time { 1359 | t, err := time.Parse(time.RFC3339, formatted) 1360 | if err != nil { 1361 | panic(err) 1362 | } 1363 | return t 1364 | } 1365 | -------------------------------------------------------------------------------- /backend/twitter/tweets.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/g8rswimmer/go-twitter/v2" 9 | "github.com/p1ass/midare/errors" 10 | "github.com/p1ass/midare/logging" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | const ( 15 | maxElapsedDuration = 21 * 24 * time.Hour 16 | 17 | tweetsCountPerAPI = 100 18 | ) 19 | 20 | var ( 21 | jst = time.FixedZone("Asia/Tokyo", 9*60*60) 22 | ) 23 | 24 | func (c client) GetTweets(ctx context.Context, userID string) ([]*Tweet, error) { 25 | 26 | var mergedTweets []*Tweet 27 | paginationToken := "" 28 | // 一度のAPIで100件取得するので最大2000件になる 29 | for i := 0; i < 2000/tweetsCountPerAPI; i++ { 30 | opts := twitter.UserTweetTimelineOpts{ 31 | TweetFields: []twitter.TweetField{twitter.TweetFieldCreatedAt}, 32 | MaxResults: tweetsCountPerAPI, 33 | } 34 | if paginationToken != "" { 35 | opts.PaginationToken = paginationToken 36 | } 37 | res, err := c.cli.UserTweetTimeline(ctx, userID, opts) 38 | if err != nil { 39 | return nil, errors.Wrap(err, "fetch user tweet timeline") 40 | } 41 | tweets := toTweets(res.Raw.Tweets) 42 | 43 | logging.Extract(ctx).Info(fmt.Sprintf("rate limit: %d", res.RateLimit.Remaining), zap.Any("remaining", res.RateLimit.Remaining)) 44 | 45 | if len(res.Raw.Tweets) == 0 { 46 | return []*Tweet{}, nil 47 | } 48 | extracted := extractWithinMaxElapsedDuration(tweets) 49 | mergedTweets = append(mergedTweets, extracted...) 50 | if doesReachFirstTweet(tweets) || exceededMaxElapsed(extracted, tweets) { 51 | break 52 | } 53 | paginationToken = res.Meta.NextToken 54 | } 55 | 56 | return mergedTweets, nil 57 | } 58 | 59 | func toTweets(tweetObjects []*twitter.TweetObj) []*Tweet { 60 | var ts []*Tweet 61 | 62 | for _, t := range tweetObjects { 63 | created, _ := time.Parse(time.RFC3339, t.CreatedAt) 64 | ts = append(ts, &Tweet{ 65 | ID: t.ID, 66 | Text: t.Text, 67 | Created: created.In(jst), 68 | }) 69 | } 70 | return ts 71 | } 72 | 73 | func exceededMaxElapsed(extracted, tweets []*Tweet) bool { 74 | return len(extracted) < len(tweets) 75 | } 76 | 77 | func doesReachFirstTweet(tweets []*Tweet) bool { 78 | return len(tweets) <= 1 79 | } 80 | 81 | func extractWithinMaxElapsedDuration(tweets []*Tweet) []*Tweet { 82 | var filtered []*Tweet 83 | 84 | for _, t := range tweets { 85 | if time.Since(t.Created) <= maxElapsedDuration { 86 | filtered = append(filtered, t) 87 | } 88 | } 89 | return filtered 90 | } 91 | -------------------------------------------------------------------------------- /backend/twitter/twitter.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "context" 5 | 6 | "golang.org/x/oauth2" 7 | ) 8 | 9 | // Client is a Twitter client. It must be created per bearer token. 10 | type Client interface { 11 | GetMe(ctx context.Context) (*User, error) 12 | GetTweets(ctx context.Context, userID string) ([]*Tweet, error) 13 | } 14 | 15 | // Auth represents the methods of Twitter OAuth2 authorization. 16 | type Auth interface { 17 | BuildAuthorizationURL() (string, *AuthorizationState) 18 | ExchangeCode(ctx context.Context, code, codeVerifier string) (*oauth2.Token, error) 19 | } 20 | -------------------------------------------------------------------------------- /backend/twitter/user.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/g8rswimmer/go-twitter/v2" 7 | "github.com/p1ass/midare/errors" 8 | ) 9 | 10 | func (c client) GetMe(ctx context.Context) (*User, error) { 11 | res, err := c.cli.AuthUserLookup(ctx, twitter.UserLookupOpts{ 12 | UserFields: []twitter.UserField{ 13 | twitter.UserFieldProfileImageURL, 14 | }, 15 | }) 16 | if err != nil { 17 | return nil, errors.Wrap(err, "twitter api: auth user lookup") 18 | } 19 | return &User{ 20 | ID: res.Raw.Users[0].ID, 21 | Name: res.Raw.Users[0].Name, 22 | ScreenName: res.Raw.Users[0].UserName, 23 | ImageURL: res.Raw.Users[0].ProfileImageURL, 24 | }, nil 25 | } 26 | -------------------------------------------------------------------------------- /backend/uploader/uploader.go: -------------------------------------------------------------------------------- 1 | package uploader 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "path" 11 | 12 | "github.com/p1ass/midare/config" 13 | "github.com/p1ass/midare/logging" 14 | "github.com/p1ass/midare/period" 15 | "github.com/p1ass/midare/twitter" 16 | "go.uber.org/zap" 17 | ) 18 | 19 | type ImageUploader struct { 20 | } 21 | 22 | func NewImageUploader() *ImageUploader { 23 | return &ImageUploader{} 24 | } 25 | 26 | // Upload uploads image to cloud storage via Cloud Functions and returns share URL. 27 | func (u *ImageUploader) Upload(ctx context.Context, periods []*period.Period, shareID string, twiCli twitter.Client) *url.URL { 28 | logging.Extract(ctx).Info(fmt.Sprintf("uploadImage: %s", shareID), zap.String("uuid", shareID)) 29 | go u.uploadImageThroughCloudFunctions(ctx, shareID, periods, twiCli) 30 | 31 | parsed, err := url.Parse(config.ReadAllowCORSOriginURL()) 32 | if err != nil { 33 | panic(err) 34 | } 35 | parsed.Path = path.Join(parsed.Path, "share", shareID) 36 | 37 | return parsed 38 | } 39 | 40 | func (u *ImageUploader) uploadImageThroughCloudFunctions(ctx context.Context, uuid string, periods []*period.Period, twiCli twitter.Client) { 41 | type request struct { 42 | Name string `json:"name"` 43 | IconURL string `json:"iconUrl"` 44 | UUID string `json:"uuid"` 45 | Periods []*period.Period `json:"periods"` 46 | } 47 | 48 | logger := logging.Extract(ctx) 49 | 50 | // 本当はここでAPIを叩きたくないが、レイテンシの削減のために非同期でAPIを叩きたいため、ここで叩いている 51 | // リクエストの終了時にキャンセルされないようにcontextは別のものを使っている 52 | user, err := twiCli.GetMe(context.Background()) 53 | if err != nil { 54 | logger.Error("uploadImageThroughCloudFunctions: get account info" + err.Error()) 55 | return 56 | } 57 | 58 | req := &request{ 59 | Name: user.Name, 60 | IconURL: user.ImageURL, 61 | UUID: uuid, 62 | Periods: periods, 63 | } 64 | encoded, _ := json.Marshal(req) 65 | 66 | _, err = http.Post(config.ReadCloudFunctionsURL(), "application/json", bytes.NewBuffer(encoded)) 67 | if err != nil { 68 | logger.Error("post period data to cloud functions" + err.Error()) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /backend/usecase/usecase.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | "github.com/p1ass/midare/datastore" 10 | "github.com/p1ass/midare/errors" 11 | "github.com/p1ass/midare/logging" 12 | "github.com/p1ass/midare/period" 13 | "github.com/p1ass/midare/twitter" 14 | "github.com/p1ass/midare/uploader" 15 | "github.com/patrickmn/go-cache" 16 | "go.uber.org/zap" 17 | "golang.org/x/oauth2" 18 | ) 19 | 20 | // newTwitterClient uses for injecting fake client for unit test. 21 | var newTwitterClient = twitter.NewClient 22 | 23 | type Usecase struct { 24 | twiAuth twitter.Auth 25 | dsCli datastore.Client 26 | responseCache *cache.Cache 27 | imageUploader *uploader.ImageUploader 28 | } 29 | 30 | func NewUsecase(twiAuth twitter.Auth, dsCli datastore.Client) *Usecase { 31 | return &Usecase{ 32 | twiAuth: twiAuth, 33 | dsCli: dsCli, 34 | responseCache: cache.New(5*time.Minute, 5*time.Minute), 35 | imageUploader: uploader.NewImageUploader(), 36 | } 37 | } 38 | 39 | // GetAwakePeriods gets awake periods using Twitter API. 40 | func (u *Usecase) GetAwakePeriods(ctx context.Context, userID string, token *oauth2.Token) ([]*period.Period, string, error) { 41 | type getAwakePeriodsCache struct { 42 | Periods []*period.Period `json:"periods"` 43 | ShareURL string `json:"shareUrl"` 44 | } 45 | 46 | twiCli := newTwitterClient(token) 47 | 48 | cached, ok := u.responseCache.Get(userID) 49 | if ok { 50 | c := cached.(*getAwakePeriodsCache) 51 | return c.Periods, c.ShareURL, nil 52 | } 53 | 54 | tweets, err := twiCli.GetTweets(ctx, userID) 55 | if err != nil { 56 | return nil, "", err 57 | } 58 | 59 | periods := period.CalcAwakePeriods(tweets) 60 | 61 | shareID := uuid.New().String() 62 | 63 | url := u.imageUploader.Upload(ctx, periods, shareID, twiCli) 64 | 65 | res := &getAwakePeriodsCache{Periods: periods, ShareURL: url.String()} 66 | 67 | u.responseCache.SetDefault(userID, res) 68 | 69 | return periods, url.String(), nil 70 | } 71 | 72 | // AuthorizeToken exchanges code with access token. 73 | // It is defined by OAuth2. 74 | func (u *Usecase) AuthorizeToken(ctx context.Context, stateID, code, state string) (*twitter.User, error) { 75 | 76 | authState, err := u.dsCli.FetchAuthorizationState(ctx, stateID) 77 | if err != nil { 78 | if se, ok := errors.Cause(err).(*errors.ServiceError); ok && se.Code == errors.NotFound { 79 | return nil, errors.NewBadRequest("state not found: %s", stateID) 80 | } 81 | return nil, err 82 | } 83 | 84 | if state != authState.State { 85 | logging.Extract(ctx).Info("state not matched", zap.String("state", state), zap.String("expected", authState.State)) 86 | return nil, errors.NewForbidden("state not matched") 87 | } 88 | 89 | token, err := u.twiAuth.ExchangeCode(ctx, code, authState.CodeVerifier) 90 | if err != nil { 91 | return nil, errors.Wrap(err, "exchange code") 92 | } 93 | 94 | user, err := u.GetUser(ctx, token) 95 | if err != nil { 96 | return nil, errors.Wrap(err, "get user") 97 | } 98 | 99 | if err := u.dsCli.StoreAccessToken(ctx, user.ID, token); err != nil { 100 | return nil, errors.Wrap(err, "store access token") 101 | } 102 | return user, nil 103 | } 104 | 105 | // GetLoginUrl gets login url which starts OAuth2 flow. 106 | // It is defined by OAuth2. 107 | func (u *Usecase) GetLoginUrl(ctx context.Context, stateID string) (string, error) { 108 | url, authState := u.twiAuth.BuildAuthorizationURL() 109 | logging.Extract(ctx).Info(fmt.Sprintf("state id: %s", stateID), zap.String("state", authState.State)) 110 | 111 | err := u.dsCli.StoreAuthorizationState(context.Background(), stateID, authState) 112 | if err != nil { 113 | return "", err 114 | } 115 | return url, nil 116 | } 117 | 118 | // GetUser gets user information using Twitter API. 119 | func (u *Usecase) GetUser(ctx context.Context, token *oauth2.Token) (*twitter.User, error) { 120 | twiCli := newTwitterClient(token) 121 | 122 | user, err := twiCli.GetMe(ctx) 123 | if err != nil { 124 | return nil, errors.Wrap(err, "account verify credentials") 125 | } 126 | return user, nil 127 | } 128 | 129 | // GetAccessToken gets access token from datastore. 130 | func (u *Usecase) GetAccessToken(ctx context.Context, userID string) (*oauth2.Token, error) { 131 | token, err := u.dsCli.FetchAccessToken(ctx, userID) 132 | if err != nil { 133 | return nil, errors.Wrap(err, "fetch access token") 134 | } 135 | return token, nil 136 | } 137 | -------------------------------------------------------------------------------- /backend/usecase/usecase_test.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/google/go-cmp/cmp/cmpopts" 10 | "github.com/p1ass/midare/datastore" 11 | "github.com/p1ass/midare/logging" 12 | "github.com/p1ass/midare/twitter" 13 | "golang.org/x/oauth2" 14 | ) 15 | 16 | func TestUsecase_GetAwakePeriods_WhenUserFoundShouldReturnPeriodsWhichLengthIsOverZero(t *testing.T) { 17 | u := newUsecaseForTest(t) 18 | 19 | userID := "1032935958964973568" 20 | periods, _, err := u.GetAwakePeriods(newContextForTest(), userID, nil) 21 | 22 | if (err != nil) != false { 23 | t.Errorf("GetAwakePeriods() error = %v, wantErr %v", err, false) 24 | return 25 | } 26 | if len(periods) == 0 { 27 | t.Errorf("GetAwakePeriods() periods should have length over zero, but %v", len(periods)) 28 | } 29 | 30 | } 31 | 32 | func TestUsecase_GetAwakePeriods_WhenUserFoundShouldReturnShareURL(t *testing.T) { 33 | u := newUsecaseForTest(t) 34 | 35 | userID := "1032935958964973568" 36 | _, url, err := u.GetAwakePeriods(newContextForTest(), userID, nil) 37 | 38 | if (err != nil) != false { 39 | t.Errorf("GetAwakePeriods() error = %v, wantErr %v", err, false) 40 | return 41 | } 42 | 43 | if !strings.Contains(url, "http://localhost.local:3000/share/") { 44 | t.Errorf("GetAwakePeriods() url should contain share path, but not contain: got %v", url) 45 | return 46 | } 47 | 48 | shareID := strings.Replace(url, "http://localhost.local:3000/share/", "", 1) 49 | if shareID == "" { 50 | t.Errorf("GetAwakePeriods() url should contain shareID, but not contain: got %v", url) 51 | return 52 | } 53 | } 54 | 55 | func TestUsecase_AuthorizeToken(t *testing.T) { 56 | // TODO 57 | } 58 | 59 | func TestUsecase_GetLoginUrl(t *testing.T) { 60 | // TODO 61 | } 62 | 63 | func TestUsecase_GetUser_shouldReturnUser(t *testing.T) { 64 | u := newUsecaseForTest(t) 65 | 66 | user, err := u.GetUser(newContextForTest(), nil) 67 | 68 | if (err != nil) != false { 69 | t.Errorf("GetUser() error = %v, wantErr %v", err, false) 70 | return 71 | } 72 | 73 | if user == nil { 74 | t.Errorf("GetUser() user should not be nil but nil") 75 | return 76 | } 77 | if user.ID == "" { 78 | t.Errorf("GetUser() user.ID should not be empty but empty") 79 | return 80 | } 81 | if user.Name == "" { 82 | t.Errorf("GetUser() user.Name should not be empty but empty") 83 | return 84 | } 85 | if user.ScreenName == "" { 86 | t.Errorf("GetUser() user.ScreenName should not be empty but empty") 87 | return 88 | } 89 | if user.ImageURL == "" { 90 | t.Errorf("GetUser() user.ImageURL should not be empty but empty") 91 | return 92 | } 93 | } 94 | 95 | func TestUsecase_GetAccessToken_WhenAccessTokenFoundShouldReturnToken(t *testing.T) { 96 | u := newUsecaseForTest(t) 97 | 98 | ctx := newContextForTest() 99 | userID := "accessTokenFound" 100 | wantToken := &oauth2.Token{ 101 | AccessToken: "dummyAccessToken", 102 | } 103 | 104 | err := u.dsCli.StoreAccessToken(ctx, userID, wantToken) 105 | if err != nil { 106 | t.Fatalf("StoreAccessToken() should return no error: but error = %v", err) 107 | } 108 | 109 | token, err := u.GetAccessToken(ctx, userID) 110 | 111 | if err != nil { 112 | t.Errorf("GetAccessToken() error = %v, wantErr %v", err, false) 113 | return 114 | } 115 | 116 | if !cmp.Equal(token, wantToken, cmpopts.IgnoreUnexported(oauth2.Token{})) { 117 | t.Errorf("GetAccessToken() token diff = %v", cmp.Diff(token, wantToken, cmpopts.IgnoreUnexported(oauth2.Token{}))) 118 | } 119 | } 120 | 121 | func TestUsecase_GetAccessToken_WhenNoAccessTokenShouldReturnError(t *testing.T) { 122 | u := newUsecaseForTest(t) 123 | 124 | userID := "notFoundUserID" 125 | _, err := u.GetAccessToken(newContextForTest(), userID) 126 | 127 | if err == nil { 128 | t.Errorf("GetAccessToken() error should not be nil, but nil") 129 | return 130 | } 131 | } 132 | 133 | func newContextForTest() context.Context { 134 | return logging.Inject(context.Background(), logging.New()) 135 | } 136 | 137 | func newUsecaseForTest(t *testing.T) *Usecase { 138 | t.Helper() 139 | 140 | // Inject fake client 141 | newTwitterClient = func(token *oauth2.Token) twitter.Client { 142 | return &twitter.FakeTwitterClient{} 143 | } 144 | 145 | dsCli, err := datastore.NewClient() 146 | if err != nil { 147 | t.Fatal(err) 148 | } 149 | u := NewUsecase(nil, dsCli) 150 | return u 151 | } 152 | -------------------------------------------------------------------------------- /backend/web/api.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/p1ass/midare/period" 8 | ) 9 | 10 | // GetMe gets my profile. 11 | func (h *Handler) GetMe(c *gin.Context) { 12 | _, token := h.getAccessToken(c) 13 | if token == nil { 14 | return 15 | } 16 | 17 | user, err := h.usecase.GetUser(c.Request.Context(), token) 18 | if err != nil { 19 | sendError(err, c) 20 | return 21 | } 22 | 23 | c.JSON(http.StatusOK, user) 24 | } 25 | 26 | // GetAwakePeriods gets awake periods from tweets. 27 | func (h *Handler) GetAwakePeriods(c *gin.Context) { 28 | type getAwakePeriodsRes struct { 29 | Periods []*period.Period `json:"periods"` 30 | ShareURL string `json:"shareUrl"` 31 | } 32 | 33 | userID, accessToken := h.getAccessToken(c) 34 | if accessToken == nil { 35 | return 36 | } 37 | 38 | periods, shareURL, err := h.usecase.GetAwakePeriods(c.Request.Context(), userID, accessToken) 39 | if err != nil { 40 | sendError(err, c) 41 | return 42 | } 43 | 44 | c.JSON(http.StatusOK, &getAwakePeriodsRes{ 45 | Periods: periods, 46 | ShareURL: shareURL, 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /backend/web/cookie.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-contrib/sessions" 7 | "github.com/gin-gonic/gin" 8 | "github.com/p1ass/midare/config" 9 | "github.com/p1ass/midare/crypto" 10 | "github.com/p1ass/midare/errors" 11 | "github.com/p1ass/midare/logging" 12 | ) 13 | 14 | const ( 15 | sessionIDKey = "sessionID" 16 | oauthStateKey = "oauthStateID" 17 | ) 18 | 19 | // setSessionAndCookie creates login session and saves it to cookie. 20 | func setSessionAndCookie(c *gin.Context, userID string) error { 21 | session := sessions.Default(c) 22 | sessID := crypto.SecureRandomBase64Encoded(64) 23 | session.Set(sessID, userID) 24 | err := session.Save() 25 | if err != nil { 26 | return errors.Wrap(err, "failed to save session") 27 | } 28 | 29 | c.SetCookie(sessionIDKey, sessID, sevenDays, "/", "", !config.IsLocal(), true) 30 | return nil 31 | } 32 | 33 | // getUserIDFromCookie gets logged in userID from login session. 34 | func getUserIDFromCookie(c *gin.Context) (string, error) { 35 | sessID, err := c.Cookie(sessionIDKey) 36 | if err != nil { 37 | return "", errors.New(errors.Unauthorized, "must be logged in") 38 | } 39 | 40 | session := sessions.Default(c) 41 | userID, ok := session.Get(sessID).(string) 42 | if !ok { 43 | return "", errors.New(errors.Unauthorized, http.StatusText(http.StatusUnauthorized)) 44 | } 45 | return userID, nil 46 | } 47 | 48 | // getOAuthStateID returns id associated with User Agent. 49 | // It is used for identifying OAuth2 state. 50 | // State is used before completing OAuth2 flow, so It is independent to login session. 51 | func getOAuthStateID(c *gin.Context) (string, error) { 52 | session := sessions.Default(c) 53 | stateID, ok := session.Get(oauthStateKey).(string) 54 | if ok { 55 | logging.Extract(c.Request.Context()).Info("state id not found in session") 56 | return stateID, nil 57 | } 58 | stateID = crypto.SecureRandomBase64Encoded(64) 59 | session.Set(oauthStateKey, stateID) 60 | err := session.Save() 61 | if err != nil { 62 | return "", errors.Wrap(err, "failed to save oauth state key") 63 | } 64 | 65 | return stateID, nil 66 | } 67 | -------------------------------------------------------------------------------- /backend/web/error.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/p1ass/midare/errors" 8 | "github.com/p1ass/midare/logging" 9 | ) 10 | 11 | func sendError(err error, c *gin.Context) { 12 | logger := logging.Extract(c.Request.Context()) 13 | 14 | switch e := errors.Cause(err).(type) { 15 | case *errors.ServiceError: 16 | // logger.Warn(err.Error(), logging.Error(err)) 17 | sendServiceError(e, c) 18 | default: 19 | logger.Error(err.Error(), logging.Error(err)) 20 | c.String(http.StatusInternalServerError, "internal error") 21 | } 22 | } 23 | 24 | func sendServiceError(err *errors.ServiceError, c *gin.Context) { 25 | switch err.Code { 26 | case errors.NotFound: 27 | c.String(http.StatusNotFound, err.Error()) 28 | case errors.BadRequest: 29 | c.String(http.StatusBadRequest, err.Error()) 30 | case errors.Unauthorized: 31 | c.String(http.StatusUnauthorized, err.Error()) 32 | case errors.Forbidden: 33 | c.String(http.StatusForbidden, err.Error()) 34 | case errors.Unknown: 35 | c.String(http.StatusInternalServerError, err.Error()) 36 | case errors.InvalidArgument: 37 | c.String(http.StatusBadRequest, err.Error()) 38 | default: 39 | c.String(http.StatusInternalServerError, err.Error()) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/web/handler.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "github.com/p1ass/midare/datastore" 5 | "github.com/p1ass/midare/twitter" 6 | "github.com/p1ass/midare/usecase" 7 | ) 8 | 9 | const ( 10 | sevenDays = 60 * 60 * 24 * 7 11 | ) 12 | 13 | // Handler is HTTP handler. 14 | type Handler struct { 15 | frontendCallbackURL string 16 | usecase *usecase.Usecase 17 | } 18 | 19 | // NewHandler returns a new struct of Handler. 20 | func NewHandler(twiAuth twitter.Auth, dsCli datastore.Client, frontendCallbackURL string) (*Handler, error) { 21 | return &Handler{ 22 | frontendCallbackURL: frontendCallbackURL, 23 | usecase: usecase.NewUsecase(twiAuth, dsCli), 24 | }, nil 25 | } 26 | -------------------------------------------------------------------------------- /backend/web/middleware.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | cloudpropagator "github.com/GoogleCloudPlatform/opentelemetry-operations-go/propagator" 5 | "github.com/gin-gonic/gin" 6 | "github.com/p1ass/midare/logging" 7 | "go.opentelemetry.io/otel/propagation" 8 | "go.opentelemetry.io/otel/trace" 9 | ) 10 | 11 | const userIDContextKey = "userID" 12 | 13 | // AuthMiddleware get session id from cookie and set user id to context 14 | func AuthMiddleware() gin.HandlerFunc { 15 | return func(c *gin.Context) { 16 | userID, err := getUserIDFromCookie(c) 17 | if err != nil { 18 | sendError(err, c) 19 | c.Abort() 20 | return 21 | } 22 | c.Set(userIDContextKey, userID) 23 | c.Next() 24 | } 25 | } 26 | 27 | // TracerMiddleware extracts trace information from request and injects it to context 28 | // https://izumisy.work/entry/2022/01/10/225539 29 | func TracerMiddleware() gin.HandlerFunc { 30 | return func(c *gin.Context) { 31 | ctx := c.Request.Context() 32 | 33 | // まずはX-Cloud-Trace-Contextからの読み取りをトライ 34 | // ここでエラーを拾っても何もできないので意図的にエラーは無視する 35 | if sc, _ := cloudpropagator.New().SpanContextFromRequest(c.Request); sc.IsValid() { 36 | ctx = trace.ContextWithRemoteSpanContext(ctx, sc) 37 | } else { 38 | // X-Cloud-Trace-ContextからValidな値が取れない場合には 39 | // traceparentヘッダからのTraceID/SpanIDのパースを試してみる 40 | prop := propagation.TraceContext{} 41 | ctx = prop.Extract(ctx, propagation.HeaderCarrier(c.Request.Header)) 42 | } 43 | 44 | c.Request = c.Request.WithContext(ctx) 45 | c.Next() 46 | } 47 | } 48 | 49 | // LoggerMiddleware injects logger to context 50 | func LoggerMiddleware() gin.HandlerFunc { 51 | return func(c *gin.Context) { 52 | ctx := c.Request.Context() 53 | 54 | logger := logging.New() 55 | newCtx := logging.Inject(ctx, logger) 56 | 57 | c.Request = c.Request.WithContext(newCtx) 58 | c.Next() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /backend/web/oauth.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/p1ass/midare/errors" 7 | "github.com/p1ass/midare/logging" 8 | "golang.org/x/oauth2" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | // StartSignInWithTwitter start twitter OAuth2 authorization code flow 14 | func (h *Handler) StartSignInWithTwitter(c *gin.Context) { 15 | stateID, err := getOAuthStateID(c) 16 | if err != nil { 17 | sendError(errors.Wrap(err, "failed to get oauth state id"), c) 18 | return 19 | } 20 | url, err := h.usecase.GetLoginUrl(c.Request.Context(), stateID) 21 | if err != nil { 22 | sendError(errors.Wrap(err, "failed to get redirect url"), c) 23 | return 24 | } 25 | 26 | c.Header("Cache-Control", "no-cache") 27 | c.Header("Pragma", "no-cache") 28 | c.Redirect(http.StatusFound, url) 29 | } 30 | 31 | // TwitterCallback handles callback function after OAuth2 use authorization 32 | // Redirect to frontend even if callback function fails 33 | func (h *Handler) TwitterCallback(c *gin.Context) { 34 | logger := logging.Extract(c.Request.Context()) 35 | 36 | code := c.DefaultQuery("code", "") 37 | if code == "" { 38 | logger.Error("code should be not empty") 39 | c.Redirect(http.StatusFound, h.frontendCallbackURL) 40 | return 41 | } 42 | 43 | state := c.DefaultQuery("state", "") 44 | if state == "" { 45 | logger.Error("state should be not empty") 46 | c.Redirect(http.StatusFound, h.frontendCallbackURL) 47 | return 48 | } 49 | 50 | stateID, err := getOAuthStateID(c) 51 | if err != nil { 52 | sendError(errors.Wrap(err, "failed to get oauth state id"), c) 53 | return 54 | } 55 | user, err := h.usecase.AuthorizeToken(c.Request.Context(), stateID, code, state) 56 | if err != nil { 57 | logger.Error("failed to authorize", logging.Error(err)) 58 | c.Redirect(http.StatusFound, h.frontendCallbackURL) 59 | return 60 | } 61 | 62 | if err := setSessionAndCookie(c, user.ID); err != nil { 63 | sendError(errors.Wrap(err, "failed to set session"), c) 64 | return 65 | } 66 | c.Redirect(http.StatusFound, h.frontendCallbackURL) 67 | } 68 | 69 | // getAccessToken gets OAuth2 access token from datastore. 70 | // It is expected that context passed AuthMiddleware 71 | func (h *Handler) getAccessToken(c *gin.Context) (string, *oauth2.Token) { 72 | v, ok := c.Get(userIDContextKey) 73 | if !ok { 74 | sendServiceError(errors.NewUnknown("user id must be set with context"), c) 75 | return "", nil 76 | } 77 | userID := v.(string) 78 | 79 | logger := logging.Extract(c.Request.Context()) 80 | 81 | accessToken, err := h.usecase.GetAccessToken(c.Request.Context(), userID) 82 | if err != nil { 83 | if se, ok := errors.Cause(err).(*errors.ServiceError); ok && se.Code == errors.NotFound { 84 | logger.Info("access token not found") 85 | } else { 86 | logger.Error("failed to get access token", logging.Error(errors.Cause(err))) 87 | } 88 | sendServiceError(&errors.ServiceError{Code: errors.Unauthorized}, c) 89 | return "", nil 90 | } 91 | 92 | return userID, accessToken 93 | } 94 | -------------------------------------------------------------------------------- /backend/web/router.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-contrib/sessions/cookie" 7 | "github.com/p1ass/midare/config" 8 | "github.com/p1ass/midare/logging" 9 | 10 | "github.com/gin-contrib/cors" 11 | "github.com/gin-contrib/sessions" 12 | ginzap "github.com/gin-contrib/zap" 13 | "github.com/gin-gonic/gin" 14 | ) 15 | 16 | // NewRouter returns a gin router 17 | func NewRouter(handler *Handler, allowOrigin string) (*gin.Engine, error) { 18 | r := gin.New() 19 | 20 | r.Use(gin.Recovery()) 21 | 22 | logger := logging.New() 23 | r.Use(TracerMiddleware(), LoggerMiddleware()) 24 | r.Use(ginzap.RecoveryWithZap(logger, true)) 25 | 26 | encryptionKey, err := config.ReadSessionEncryptionKey() 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | store := cookie.NewStore([]byte(config.ReadSessionKey()), encryptionKey) 32 | store.Options(sessions.Options{ 33 | MaxAge: 86400 * 7, 34 | Secure: !config.IsLocal(), 35 | HttpOnly: true, 36 | }) 37 | r.Use(sessions.Sessions("session-store", store)) 38 | r.Use(cors.New(cors.Config{ 39 | AllowOrigins: []string{allowOrigin}, 40 | AllowMethods: []string{"POST", "PUT", "PATCH", "DELETE"}, 41 | AllowHeaders: []string{"Origin", "Cookie", "Content-Type", "Content-Length"}, 42 | AllowCredentials: true, 43 | })) 44 | 45 | r.GET("/", func(c *gin.Context) { 46 | c.Status(http.StatusOK) 47 | }) 48 | 49 | r.GET("/login", handler.StartSignInWithTwitter) 50 | r.GET("/callback", handler.TwitterCallback) 51 | 52 | withAuthGrp := r.Group("/") 53 | withAuthGrp.Use(AuthMiddleware()) 54 | withAuthGrp.GET("/me", handler.GetMe) 55 | withAuthGrp.GET("/periods", handler.GetAwakePeriods) 56 | 57 | return r, nil 58 | } 59 | -------------------------------------------------------------------------------- /frontend/.env.production: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_BASE_URL=https://midare-api.p1ass.com 2 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['prettier'], 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:import/errors', 7 | 'plugin:import/warnings', 8 | 'plugin:import/typescript', 9 | 'plugin:@next/next/recommended', 10 | 'prettier', 11 | ], 12 | globals: { 13 | Atomics: 'readonly', 14 | SharedArrayBuffer: 'readonly', 15 | }, 16 | rules: { 17 | '@typescript-eslint/explicit-module-boundary-types': 'off', 18 | 'import/order': [ 19 | 'error', 20 | { 21 | pathGroups: [ 22 | { 23 | pattern: '@/**', 24 | group: 'parent', 25 | position: 'after', 26 | }, 27 | ], 28 | 'newlines-between': 'always', 29 | }, 30 | ], 31 | 'import/no-default-export': 'warn', 32 | 'prettier/prettier': [ 33 | 'error', 34 | { 35 | singleQuote: true, 36 | printWidth: 100, 37 | tabWidth: 2, 38 | semi: false, 39 | }, 40 | ], 41 | semi: ['error', 'never'], 42 | indent: ['error', 2], 43 | }, 44 | // Next.js向けのページコンポーネントはdefault exportしか使えないなので除外 45 | overrides: [ 46 | { 47 | files: ['src/pages/**/*.tsx'], 48 | rules: { 49 | 'import/no-default-export': 'off', 50 | }, 51 | }, 52 | ], 53 | settings: { 54 | react: { 55 | version: 'detect', 56 | }, 57 | 'import/resolver': { 58 | typescript: { 59 | project: '.', 60 | }, 61 | }, 62 | }, 63 | } 64 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .eslintcache 25 | 26 | .next 27 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # frontend 2 | 3 | midare のフロントエンドのコードです。 4 | 5 | 6 | ## Getting Started 7 | 8 | ```bash 9 | $ yarn 10 | $ yarn dev 11 | ``` 12 | 13 | ## Lint 14 | 15 | ```bash 16 | yarn lint 17 | ``` 18 | 19 | ## Deploy 20 | 21 | Vercel にデプロイしています。 22 | -------------------------------------------------------------------------------- /frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | compiler: { 3 | // ssr and displayName are configured by default 4 | styledComponents: true, 5 | }, 6 | images: { 7 | domains: ['pbs.twimg.com'], 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "midare_frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next", 7 | "build": "next build", 8 | "lint": "next lint", 9 | "export": "next export", 10 | "start": "next start" 11 | }, 12 | "dependencies": { 13 | "@fortawesome/fontawesome-svg-core": "^6.1.2", 14 | "@fortawesome/free-brands-svg-icons": "^6.1.2", 15 | "@fortawesome/free-solid-svg-icons": "^6.1.2", 16 | "@fortawesome/react-fontawesome": "^0.2.0", 17 | "axios": "^0.27.2", 18 | "dayjs": "^1.11.5", 19 | "downloadjs": "^1.4.7", 20 | "html-to-image": "^1.9.0", 21 | "next": "^12.2.5", 22 | "react": "^18.2.0", 23 | "react-dom": "^18.2.0", 24 | "react-google-ads": "^1.0.5", 25 | "react-modal": "^3.15.1", 26 | "styled-components": "^5.3.5", 27 | "typescript": "~4.7.4" 28 | }, 29 | "devDependencies": { 30 | "@next/eslint-plugin-next": "^12.2.2", 31 | "@types/downloadjs": "^1.4.3", 32 | "@types/gtag.js": "^0.0.10", 33 | "@types/jest": "^28.1.8", 34 | "@types/node": "^18.0.6", 35 | "@types/react": "^18.0.18", 36 | "@types/react-dom": "^18.0.6", 37 | "@types/react-modal": "^3.13.1", 38 | "@types/styled-components": "^5.1.26", 39 | "@typescript-eslint/eslint-plugin": "^5.30.7", 40 | "@typescript-eslint/parser": "^5.30.7", 41 | "eslint": "^8.19.0", 42 | "eslint-config-prettier": "^8.5.0", 43 | "eslint-import-resolver-typescript": "^3.2.5", 44 | "eslint-plugin-import": "^2.26.0", 45 | "eslint-plugin-prettier": "^4.2.1", 46 | "prettier": "^2.7.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /frontend/public/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p1ass/midare/abfc617965c5339be97d6360f33e1449ec1ab41b/frontend/public/example.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p1ass/midare/abfc617965c5339be97d6360f33e1449ec1ab41b/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p1ass/midare/abfc617965c5339be97d6360f33e1449ec1ab41b/frontend/public/icon.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "生活習慣の乱れを可視化するやつ", 3 | "name": "生活習慣の乱れを可視化するやつ", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /frontend/public/ogp.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p1ass/midare/abfc617965c5339be97d6360f33e1449ec1ab41b/frontend/public/ogp.jpg -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/src/api/client.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | import { Period } from '../entity/Period' 4 | import { User } from '../entity/User' 5 | import { isProd } from '../lib/env' 6 | 7 | const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost.local:8080' 8 | 9 | const instance = axios.create({ 10 | baseURL: baseURL, 11 | withCredentials: true, 12 | }) 13 | 14 | interface GetPeriodsResponse { 15 | periods: Period[] 16 | shareUrl: string 17 | } 18 | 19 | export const getLoginUrl = () => { 20 | return baseURL + '/login' 21 | } 22 | 23 | export const getMe = async () => { 24 | const res = await instance.get('/me') 25 | if (isProd()) { 26 | window.gtag('event', 'login_succeed', { 27 | value: res.data.screenName, 28 | }) 29 | } 30 | return res.data 31 | } 32 | 33 | export const getPeriods = async () => { 34 | const res = await instance.get('/periods') 35 | if (isProd()) { 36 | window.gtag('event', 'periods_got', { 37 | share_url: res.data.shareUrl, 38 | value: res.data.periods.length, 39 | }) 40 | } 41 | return res.data 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/api/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | import { Period } from '../entity/Period' 4 | import { User } from '../entity/User' 5 | 6 | import { getMe, getPeriods } from './client' 7 | 8 | export const useMe = (): [User | undefined, unknown | undefined, boolean] => { 9 | const [user, setUser] = useState(undefined) 10 | const [error, setError] = useState(undefined) 11 | const [isLoading, setIsLoading] = useState(true) 12 | 13 | useEffect(() => { 14 | const getUserAsync = async () => { 15 | try { 16 | setIsLoading(true) 17 | const user = await getMe() 18 | setUser(user) 19 | } catch (e) { 20 | setError(e) 21 | } finally { 22 | setIsLoading(false) 23 | } 24 | } 25 | getUserAsync() 26 | }, []) 27 | return [user, error, isLoading] 28 | } 29 | 30 | export const usePeriods = (): [Period[] | undefined, string, unknown] => { 31 | const [periods, setPeriods] = useState(undefined) 32 | const [shareUrl, setShareUrl] = useState('') 33 | const [error, setError] = useState(undefined) 34 | useEffect(() => { 35 | const getPeriodsAsync = async () => { 36 | try { 37 | const res = await getPeriods() 38 | if (res.periods.length === 0) { 39 | return 40 | } 41 | setPeriods(res.periods) 42 | setShareUrl(res.shareUrl) 43 | } catch (e) { 44 | setError(e) 45 | } 46 | } 47 | getPeriodsAsync() 48 | }, []) 49 | 50 | return [periods, shareUrl, error] 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/atom/Area.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | interface AreaProps { 4 | row: string 5 | colStart: string 6 | colEnd?: string 7 | } 8 | export const Area = styled.div.attrs(({ row, colStart, colEnd }) => ({ 9 | style: { 10 | gridRow: row, 11 | gridColumn: colEnd ? `t-${colStart} / t-${colEnd}` : `t-${colStart}`, 12 | }, 13 | }))`` 14 | -------------------------------------------------------------------------------- /frontend/src/atom/ButtonBase.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const ButtonBase = styled.a` 4 | padding: 1rem; 5 | border-radius: 0.5rem; 6 | text-decoration: none; 7 | font-weight: bold; 8 | cursor: pointer; 9 | &:visited { 10 | color: white; 11 | } 12 | ` 13 | -------------------------------------------------------------------------------- /frontend/src/atom/Footer.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const FooterWrapper = styled.footer` 4 | width: 100%; 5 | height: 2rem; 6 | line-height: 2rem; 7 | background-color: rgb(88, 149, 98); 8 | color: white; 9 | text-align: center; 10 | ` 11 | 12 | export const Footer = () => { 13 | return © 2020 - p1ass All Rights Reserved. 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/atom/Header.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const HeaderWrapper = styled.header` 4 | width: 100%; 5 | height: 2rem; 6 | line-height: 2rem; 7 | background-color: rgb(88, 149, 98); 8 | color: white; 9 | font-weight: bold; 10 | ` 11 | 12 | const Title = styled.span` 13 | padding-left: 1rem; 14 | ` 15 | 16 | export const Header = () => { 17 | return ( 18 | 19 | 生活習慣の乱れを可視化するやつ 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/atom/Share.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import { faTwitter, faFacebook } from '@fortawesome/free-brands-svg-icons' 4 | 5 | const ShareWrapper = styled.section` 6 | display: inline-flex; 7 | flex-direction: row; 8 | margin-bottom: 1rem; 9 | justify-content: flex-end; 10 | align-items: center; 11 | ` 12 | 13 | const ShareButton = styled.a` 14 | margin: 0 0.5rem; 15 | text-decoration: none; 16 | ` 17 | 18 | const Hatena = styled.i` 19 | color: #4BA3D9; 20 | font-style: normal; 21 | font-variant: normal; 22 | text-rendering: auto; 23 | display: block; 24 | margin:0; 25 | &:before { 26 | content: "B!"; 27 | font-family: Verdana; 28 | font-weight: bold; 29 | font-size: 28px; 30 | } 31 | } 32 | ` 33 | 34 | export const Share = () => { 35 | return ( 36 | 37 | 43 | 44 | 45 | 46 | 52 | 53 | 54 | 60 | 61 | 62 | 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /frontend/src/components/AwakeSchedule.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import styled from 'styled-components' 3 | import dayjs from 'dayjs' 4 | import Modal from 'react-modal' 5 | 6 | import { Area } from '../atom/Area' 7 | import { AwakePeriod } from '../entity/AwakePeriod' 8 | 9 | const ScheduleBlock = styled(Area)` 10 | background: rgb(88, 149, 98); 11 | border-radius: 4px; 12 | font-weight: bold; 13 | margin: 0.1rem 0; 14 | color: #eee; 15 | font-size: 0.5rem; 16 | cursor: pointer; 17 | ` 18 | 19 | Modal.setAppElement('#__next') 20 | const customModalStyles = { 21 | content: { 22 | top: '50%', 23 | left: '50%', 24 | right: 'auto', 25 | bottom: 'auto', 26 | marginRight: '-40%', 27 | transform: 'translate(-50%, -50%)', 28 | }, 29 | } 30 | 31 | interface AwakeScheduleProps { 32 | awakePeriod: AwakePeriod 33 | } 34 | 35 | const AwakeSchedule = ({ awakePeriod }: AwakeScheduleProps) => { 36 | const [isOpen, setIsOpen] = useState(false) 37 | 38 | const okiTime = awakePeriod.okiTime.splitDate 39 | ? awakePeriod.okiTime.splitDate 40 | : awakePeriod.okiTime.createdAt 41 | const neTime = awakePeriod.neTime.splitDate 42 | ? awakePeriod.neTime.splitDate 43 | : awakePeriod.neTime.createdAt 44 | 45 | const okiTimeTruncate = truncateDate(okiTime) 46 | const neTimeTruncate = truncateDate(neTime) 47 | return ( 48 | <> 49 | { 58 | setIsOpen(true) 59 | }} 60 | > 61 | setIsOpen(false)} 65 | contentLabel="ツイート詳細" 66 | style={customModalStyles} 67 | > 68 |

起床直後のツイート

69 | {awakePeriod.okiTime.createdAt.format('MM/DD HH:mm')} 70 |

{awakePeriod.okiTime.text}

71 |

就寝直前のツイート

72 | {awakePeriod.neTime.createdAt.format('MM/DD HH:mm')} 73 |

{awakePeriod.neTime.text}

74 |
75 | 76 | ) 77 | } 78 | 79 | interface AwakeSchedulesProps { 80 | awakePeriods: AwakePeriod[] 81 | } 82 | 83 | const truncateDate = (date: dayjs.Dayjs) => { 84 | if (date.minute() < 15) { 85 | return date.startOf('hour') 86 | } 87 | if (date.minute() >= 15 && date.minute() < 45) { 88 | return date.startOf('hour').add(30, 'minute') 89 | } 90 | return date.add(1, 'hour').startOf('hour') 91 | } 92 | 93 | export const AwakeSchedules = ({ awakePeriods }: AwakeSchedulesProps) => { 94 | return ( 95 | <> 96 | {awakePeriods.map((awakePeriod, idx) => { 97 | return 98 | })} 99 | 100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /frontend/src/components/Borders.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import dayjs from 'dayjs' 3 | 4 | import { Area } from '../atom/Area' 5 | 6 | const Border = styled(Area)<{ time: dayjs.Dayjs }>` 7 | border-top: solid 1px #ccc; 8 | border-bottom: solid 1px #ccc; 9 | min-height: 0.2rem; 10 | border-left: ${({ time }) => { 11 | return time.minute() === 0 ? `1px solid #ccc` : `none` 12 | }}; 13 | margin-top: -1px; 14 | ` 15 | 16 | interface BordersProps { 17 | dateLabels: string[] 18 | timesPerHalfHour: dayjs.Dayjs[] 19 | } 20 | 21 | export const Borders = ({ dateLabels, timesPerHalfHour }: BordersProps) => { 22 | return ( 23 | <> 24 | {dateLabels.map((dateText) => { 25 | return timesPerHalfHour.map((time, i) => ( 26 | 32 | )) 33 | })} 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/components/ButtonGitHub.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import { faGithub } from '@fortawesome/free-brands-svg-icons' 4 | 5 | import { ButtonBase } from '../atom/ButtonBase' 6 | 7 | const GitHubButton = styled(ButtonBase)` 8 | background-color: #171515; 9 | color: white; 10 | margin: 1rem; 11 | ` 12 | 13 | export const ButtonGitHub = () => { 14 | return ( 15 | 16 | 17 | GitHubを開く 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/components/ButtonSaveImage.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | import { isProd } from '../lib/env' 4 | import { ButtonBase } from '../atom/ButtonBase' 5 | 6 | const Button = styled(ButtonBase)` 7 | background-color: #7f8c8d; 8 | color: white; 9 | margin: 1rem; 10 | border: none; 11 | ` 12 | 13 | export const ButtonSaveImage = ({ onClick }: { onClick: () => Promise }) => { 14 | return ( 15 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/components/ButtonShareTwitter.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import { faTwitter } from '@fortawesome/free-brands-svg-icons' 4 | 5 | import { ButtonBase } from '../atom/ButtonBase' 6 | import { isProd } from '../lib/env' 7 | 8 | const Button = styled(ButtonBase)` 9 | background-color: #1b95e0; 10 | color: white; 11 | margin: 1rem; 12 | border: none; 13 | width: 20rem; 14 | @media (max-width: 40rem) { 15 | width: 90%; 16 | } 17 | text-align: center; 18 | ` 19 | 20 | export const ButtonShareTwitter = ({ shareUrl }: { shareUrl: string }) => { 21 | return ( 22 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/components/ButtonTwitterLogin.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import { faTwitter } from '@fortawesome/free-brands-svg-icons' 4 | 5 | import { getLoginUrl } from '../api/client' 6 | import { isProd } from '../lib/env' 7 | import { ButtonBase } from '../atom/ButtonBase' 8 | 9 | const TwitterButton = styled(ButtonBase)` 10 | background-color: rgb(27, 149, 224); 11 | color: white; 12 | margin-bottom: 1rem; 13 | ` 14 | 15 | export const ButtonTwitterLogin = () => { 16 | return ( 17 | { 20 | if (isProd()) { 21 | window.gtag('event', 'login', { 22 | event_category: 'login', 23 | event_label: 'twitter', 24 | value: 1, 25 | }) 26 | } 27 | }} 28 | > 29 | 30 | 乱れを可視化する 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/components/Calendar.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useState, useEffect } from 'react' 2 | import styled from 'styled-components' 3 | 4 | import { rangeTimes } from '../lib/time' 5 | import { 6 | convertPeriodsToAwakePeriods, 7 | getDatesBetweenLatestAndOldest, 8 | AwakePeriod, 9 | } from '../entity/AwakePeriod' 10 | import { Period } from '../entity/Period' 11 | 12 | import { Times } from './Times' 13 | import { Borders } from './Borders' 14 | import { AwakeSchedules } from './AwakeSchedule' 15 | import { DateHeaders } from './DateHeaders' 16 | 17 | const timesPerHalfHour = rangeTimes() 18 | const columnTemplate = 19 | '[t-header] 5fr ' + 20 | timesPerHalfHour.map((time) => `[t-${time.format('HHmm')}]`).join(' 0.5fr ') + 21 | ' 0.5fr ' 22 | 23 | const Grid = styled.div<{ rowTemplate: string[]; generatingImage: boolean }>` 24 | display: grid; 25 | background: white; 26 | box-sizing: border-box; 27 | padding: 0.5rem; 28 | margin-bottom: 1rem; 29 | grid-template-rows: ${({ rowTemplate }) => rowTemplate}; 30 | grid-template-columns: ${columnTemplate}; 31 | border: 1px solid #ccc; 32 | 33 | @media (max-width: 40rem) { 34 | padding: ${({ generatingImage }) => (generatingImage ? '1rem' : '0rem')}; 35 | } 36 | ` 37 | 38 | interface CalendarProps { 39 | periods: Period[] 40 | generatingImage: boolean 41 | } 42 | 43 | export const Calendar = forwardRef(function _Calendar( 44 | { periods, generatingImage }: CalendarProps, 45 | ref: React.Ref 46 | ) { 47 | const [awakePeriods, setAwakePeriods] = useState(new Array()) 48 | const [dateTexts, setDateTexts] = useState(new Array()) 49 | const [dateLabels, setDateLabels] = useState(new Array()) 50 | const [rowTemplate, setRowTemplate] = useState(new Array()) 51 | 52 | useEffect(() => { 53 | const awakePeriods = convertPeriodsToAwakePeriods(periods) 54 | setAwakePeriods(awakePeriods) 55 | 56 | const dates = getDatesBetweenLatestAndOldest( 57 | awakePeriods[awakePeriods.length - 1].okiTime.createdAt, 58 | awakePeriods[0].neTime.createdAt 59 | ) 60 | 61 | const dateLabels = dates.map((date) => { 62 | return date.format('MMMMDD') 63 | }) 64 | setDateLabels(dateLabels) 65 | 66 | const daysOfTheWeek = ['日', '月', '火', '水', '木', '金', '土'] 67 | const dateTexts = dates.map((date) => { 68 | return date.format(`MM/DD (${daysOfTheWeek[date.day()]})`) 69 | }) 70 | setDateTexts(dateTexts) 71 | 72 | const rowTemplate = ['time-header'] 73 | .concat(dateLabels) 74 | .concat('time-footer') 75 | .map((dateLabel) => `[${dateLabel}] 0.5fr `) 76 | setRowTemplate(rowTemplate) 77 | }, [periods]) 78 | 79 | return ( 80 | <> 81 | {rowTemplate.length !== 0 ? ( 82 | <> 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | ) : null} 92 | 93 | ) 94 | }) 95 | -------------------------------------------------------------------------------- /frontend/src/components/CalendarContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { createRef, useState, useEffect } from 'react' 2 | import { toJpeg } from 'html-to-image' 3 | import download from 'downloadjs' 4 | 5 | import { usePeriods } from '../api/hooks' 6 | import { User } from '../entity/User' 7 | 8 | import { Calendar } from './Calendar' 9 | import { ButtonSaveImage } from './ButtonSaveImage' 10 | import { ButtonShareTwitter } from './ButtonShareTwitter' 11 | import { CalendarUser } from './CalendarUser' 12 | 13 | const handleSave = async (dom: HTMLDivElement | null) => { 14 | if (!dom) { 15 | return 16 | } 17 | const dataUrl = await toJpeg(dom, { quality: 0.95 }) 18 | download(dataUrl, 'calendar.jpeg', 'image/jpeg') 19 | } 20 | 21 | const sleep = (msec: number) => new Promise((resolve) => setTimeout(resolve, msec)) 22 | 23 | type Props = { 24 | user: User 25 | } 26 | 27 | export const CalendarContainer = ({ user }: Props) => { 28 | const [infoMsg, setInfoMsg] = useState('Now Loading...') 29 | 30 | const [generatingImage, setGeneratingImage] = useState(false) 31 | 32 | const ref = createRef() 33 | 34 | useEffect(() => { 35 | const handleSaveAsync = async () => { 36 | await sleep(1000) 37 | await handleSave(ref.current) 38 | setGeneratingImage(false) 39 | } 40 | handleSaveAsync() 41 | }, [ref]) 42 | 43 | const [periods, shareUrl, error] = usePeriods() 44 | useEffect(() => { 45 | if (periods && periods.length === 0) { 46 | setInfoMsg('直近のツイートが存在しません') 47 | } 48 | if (error) { 49 | console.error(error) 50 | setInfoMsg('ツイートの取得に失敗しました。時間を空けてもう一度お試しください。') 51 | } 52 | }, [periods, error]) 53 | 54 | return ( 55 | <> 56 | {periods && periods.length !== 0 ? ( 57 | <> 58 | 59 | 60 | 61 | { 63 | setGeneratingImage(true) 64 | }} 65 | /> 66 | {generatingImage ? ( 67 | <> 68 |

画像生成中...

69 | 70 | 71 | ) : null} 72 | 73 | ) : ( 74 |

{infoMsg}

75 | )} 76 | 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /frontend/src/components/CalendarUser.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import styled from 'styled-components' 3 | 4 | import { User } from '../entity/User' 5 | 6 | const UserInfoWrapper = styled.div` 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | margin-bottom: 0.5rem; 11 | font-weight: 600; 12 | ` 13 | 14 | const UserDescription = styled.p` 15 | margin-left: 8px; 16 | ` 17 | 18 | const UserIcon = styled(Image)` 19 | border-radius: 48px; 20 | ` 21 | 22 | type Props = { 23 | user: User 24 | } 25 | 26 | export const CalendarUser = ({ user }: Props) => { 27 | return ( 28 | 29 | 30 | {user.name}さんの生活習慣はこちら! 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/components/DateHeaders.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | import { Area } from '../atom/Area' 4 | 5 | const HeaderCell = styled(Area)` 6 | padding: 0px; 7 | border-bottom: solid 1px #ccc; 8 | margin-top: -1px; 9 | ` 10 | 11 | const DateText = styled.p<{ generatingImage: boolean }>` 12 | margin: 0.2rem; 13 | font-size: 0.9rem; 14 | @media (max-width: 60rem) { 15 | font-size: 0.7rem; 16 | } 17 | @media (max-width: 40rem) { 18 | font-size: ${({ generatingImage }) => (generatingImage ? '0.1rem' : '0.4rem')}; 19 | width: 3.4rem; 20 | } 21 | ` 22 | 23 | interface DateHeadersProps { 24 | dateTexts: string[] 25 | generatingImage: boolean 26 | } 27 | 28 | export const DateHeaders = ({ dateTexts, generatingImage }: DateHeadersProps) => { 29 | return ( 30 | <> 31 | {[''].concat(dateTexts).map((dateText) => { 32 | return ( 33 | 34 | {dateText} 35 | 36 | ) 37 | })} 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/components/Description.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | import { Share } from '../atom/Share' 4 | 5 | import { ButtonGitHub } from './ButtonGitHub' 6 | 7 | const WhatIsThis = styled.p` 8 | text-align: center; 9 | ` 10 | 11 | const Ul = styled.ul` 12 | margin: 0 0 2rem 0; 13 | ` 14 | 15 | const Li = styled.li` 16 | padding: 0.3rem 0; 17 | ` 18 | 19 | const ExampleImage = styled.img` 20 | width: 100%; 21 | max-width: 50rem; 22 | margin: 0 auto; 23 | ` 24 | 25 | export const Description = () => { 26 | return ( 27 | <> 28 |

これは何

29 | 30 | ツイートを使って生活習慣の乱れを可視化するWebアプリです。 31 |
32 | カレンダーUIで直感的に起床・就寝時間の変化を見ることができます。 33 |
34 | ツイート数が多ければ多いほど精度が高くなります。 35 |
36 | 37 |
生成されるカレンダーの例
38 |

仕組み

39 |
    40 |
  1. Twitter APIを使って直近のツイートを収集します。
  2. 41 |
  3. ツイートの間隔が3.5時間以内であれば、その時間帯は起きているとみなします。
  4. 42 |
43 |

シェア

44 | 45 |

ソースコードはこちら↓

46 | 47 |

作成者

48 |
    49 |
  • 50 | Twitter : @p1ass 51 |
  • 52 |
  • 53 | GitHub : @p1ass 54 |
  • 55 |
56 |

利用状況の計測のためにGoogle Analyticsを利用しています。

57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /frontend/src/components/Times.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | import { rangeTimes } from '../lib/time' 4 | import { Area } from '../atom/Area' 5 | 6 | const Hour = styled(Area)<{ generatingImage: boolean }>` 7 | margin: 4px 0; 8 | font-size: 1rem; 9 | min-width: 1rem; 10 | @media (max-width: 60rem) { 11 | font-size: 0.7rem; 12 | min-width: 0.7rem; 13 | } 14 | @media (max-width: 40rem) { 15 | font-size: ${({ generatingImage }) => (generatingImage ? '1rem' : '0.2rem')}; 16 | min-width: ${({ generatingImage }) => (generatingImage ? '1.5rem' : '0.2rem')}; 17 | } 18 | ` 19 | 20 | export const Times = ({ row, generatingImage }: { row: string; generatingImage: boolean }) => { 21 | return ( 22 | <> 23 | {rangeTimes().map((time, i) => ( 24 | 30 | {time.minute() === 0 ? time.hour() : ''} 31 | 32 | ))} 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/entity/AwakePeriod.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | 3 | import { Period } from './Period' 4 | 5 | type TweetWithTime = { 6 | id: string 7 | text: string 8 | createdAt: dayjs.Dayjs 9 | splitDate: dayjs.Dayjs | null 10 | } 11 | 12 | export type AwakePeriod = { 13 | okiTime: TweetWithTime 14 | neTime: TweetWithTime 15 | } 16 | 17 | const splitPeriodAtMidnight = (period: Period, okiDate: dayjs.Dayjs, netaDate: dayjs.Dayjs) => { 18 | const awakePeriods: AwakePeriod[] = [] 19 | let slideOkiDate = okiDate 20 | let slideNetaDate = okiDate.add(1, 'day').startOf('date') 21 | while (!netaDate.isSame(slideNetaDate, 'date')) { 22 | awakePeriods.push({ 23 | okiTime: { 24 | id: period.okiTime.id, 25 | text: period.okiTime.text, 26 | createdAt: okiDate, 27 | splitDate: slideOkiDate, 28 | }, 29 | neTime: { 30 | id: period.neTime.id, 31 | text: period.neTime.text, 32 | createdAt: netaDate, 33 | splitDate: slideNetaDate, 34 | }, 35 | }) 36 | slideOkiDate = slideNetaDate 37 | slideNetaDate = slideOkiDate.add(1, 'day') 38 | } 39 | if (!slideOkiDate.isSame(slideNetaDate, 'date')) { 40 | awakePeriods.push({ 41 | okiTime: { 42 | id: period.okiTime.id, 43 | text: period.okiTime.text, 44 | createdAt: okiDate, 45 | splitDate: slideOkiDate, 46 | }, 47 | neTime: { 48 | id: period.neTime.id, 49 | text: period.neTime.text, 50 | createdAt: netaDate, 51 | splitDate: slideNetaDate, 52 | }, 53 | }) 54 | } 55 | awakePeriods.push({ 56 | okiTime: { 57 | id: period.okiTime.id, 58 | text: period.okiTime.text, 59 | createdAt: okiDate, 60 | splitDate: slideNetaDate, 61 | }, 62 | neTime: { 63 | id: period.neTime.id, 64 | text: period.neTime.text, 65 | createdAt: netaDate, 66 | splitDate: null, 67 | }, 68 | }) 69 | return awakePeriods 70 | } 71 | 72 | export const convertPeriodsToAwakePeriods = (periods: Period[]) => { 73 | let awakePeriods: AwakePeriod[] = [] 74 | for (const period of periods) { 75 | const okiDate = dayjs(period.okiTime.createdAt) 76 | const netaDate = dayjs(period.neTime.createdAt) 77 | if (okiDate.isSame(netaDate, 'day')) { 78 | awakePeriods.push({ 79 | okiTime: { 80 | id: period.okiTime.id, 81 | text: period.okiTime.text, 82 | createdAt: okiDate, 83 | splitDate: null, 84 | }, 85 | neTime: { 86 | id: period.neTime.id, 87 | text: period.neTime.text, 88 | createdAt: netaDate, 89 | splitDate: null, 90 | }, 91 | }) 92 | } else { 93 | const divided = splitPeriodAtMidnight(period, okiDate, netaDate) 94 | awakePeriods = awakePeriods.concat(divided.reverse()) 95 | } 96 | } 97 | 98 | return awakePeriods 99 | } 100 | 101 | export const getDatesBetweenLatestAndOldest = ( 102 | oldestDate: dayjs.Dayjs, 103 | latestDate: dayjs.Dayjs 104 | ) => { 105 | const truncateOldestDate = oldestDate.startOf('date') 106 | const daysBetweenLatestAndOldest: dayjs.Dayjs[] = [truncateOldestDate] 107 | 108 | let truncateDate = truncateOldestDate 109 | while (!truncateDate.isSame(latestDate, 'date')) { 110 | truncateDate = truncateDate.add(1, 'day') 111 | 112 | daysBetweenLatestAndOldest.push(truncateDate) 113 | } 114 | 115 | return daysBetweenLatestAndOldest 116 | } 117 | -------------------------------------------------------------------------------- /frontend/src/entity/Period.ts: -------------------------------------------------------------------------------- 1 | interface Tweet { 2 | id: string 3 | text: string 4 | createdAt: string 5 | } 6 | 7 | export interface Period { 8 | okiTime: Tweet 9 | neTime: Tweet 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/entity/User.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: string 3 | name: string 4 | screenName: string 5 | imageUrl: string 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: "Helvetica Neue",Arial,"Hiragino Kaku Gothic ProN W4","Hiragino Sans",Meiryo,sans-serif; 4 | -webkit-font-smoothing: antialiased; 5 | -moz-osx-font-smoothing: grayscale; 6 | } 7 | 8 | code { 9 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 10 | monospace; 11 | } 12 | 13 | ins { 14 | min-width: 800px; 15 | min-height: 100px; 16 | } 17 | @media (max-width: 60rem) { 18 | ins { 19 | min-width: 100vw; 20 | height: 90vw; 21 | } 22 | } 23 | 24 | .ww{ 25 | display: inline-block; 26 | } 27 | 28 | -------------------------------------------------------------------------------- /frontend/src/lib/env.ts: -------------------------------------------------------------------------------- 1 | export const isProd = () => { 2 | return process.env.NODE_ENV === 'production' 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/lib/screen.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | export const useHasTouchScreen = () => { 4 | const [state, setState] = useState(false) 5 | 6 | useEffect(() => { 7 | setState(hasTouchScreen()) 8 | }, []) 9 | 10 | return { 11 | hasTouchScreen: state, 12 | } as const 13 | } 14 | 15 | const hasTouchScreen = () => { 16 | if (navigator.maxTouchPoints > 0) { 17 | return true 18 | } 19 | if (window.matchMedia('(pointer:coarse)').matches) { 20 | return true 21 | } 22 | if ('orientation' in window) { 23 | return true 24 | } 25 | 26 | return false 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/lib/time.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | 3 | // 時間を30分単位で出力 4 | export const rangeTimes = (start = 0, hours = 24): dayjs.Dayjs[] => { 5 | return Array.from({ length: hours * 2 }, (_, i) => { 6 | const hr = Math.floor(i / 2) + start 7 | const min = (i % 2) * 30 8 | return dayjs().set('hour', hr).set('minute', min) 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Head from 'next/head' 3 | import { AppContext, AppInitialProps } from 'next/app' 4 | import Script from 'next/script' 5 | 6 | import { isProd } from '../lib/env' 7 | 8 | import '../index.css' 9 | import '@fortawesome/fontawesome-svg-core/styles.css' 10 | 11 | function MyApp({ Component, pageProps }: AppContext & AppInitialProps) { 12 | const description = 13 | 'ツイートを使って生活習慣の乱れを可視化するWebアプリです。カレンダーUIで直感的に起床・就寝時間の変化を見ることが出来ます。' 14 | return ( 15 | <> 16 | {isProd() ? ( 17 | <> 18 | 32 | 33 | ) : null} 34 |