├── .codeclimate.yml ├── .github └── workflows │ ├── _build.yml │ ├── _e2e.yml │ ├── _release.yml │ ├── release_dev.yml │ └── release_prod.yml ├── LICENSE ├── README.md ├── backend ├── .air.toml ├── .dockerignore ├── .env.template ├── .envrc ├── .gitignore ├── containers │ └── app │ │ └── Dockerfile ├── docker-compose.yml ├── go.mod ├── go.sum ├── package.json ├── pkg │ ├── controllers │ │ ├── cors_middleware.go │ │ ├── errcode.go │ │ ├── error_response_logger_middleware.go │ │ ├── health_controller.go │ │ ├── images_controller.go │ │ ├── lgtms_controller.go │ │ ├── logger_middleware.go │ │ ├── renderer.go │ │ └── reports_controller.go │ ├── entities │ │ ├── image.go │ │ ├── lgtm.go │ │ └── report.go │ ├── handlers │ │ ├── api │ │ │ ├── lambda │ │ │ │ └── main.go │ │ │ └── local │ │ │ │ └── main.go │ │ └── deletelgtm │ │ │ └── main.go │ ├── infrastructures │ │ ├── dynamodb │ │ │ └── dynamodb.go │ │ ├── imagesearch │ │ │ └── imagesearch.go │ │ ├── lgtmgen │ │ │ └── lgtmgen.go │ │ ├── router │ │ │ └── router.go │ │ └── s3 │ │ │ └── s3.go │ ├── repositories │ │ ├── lgtms_repository.go │ │ └── reports_repository.go │ ├── static │ │ └── fonts │ │ │ └── Archivo_Black │ │ │ ├── ArchivoBlack-Regular.ttf │ │ │ └── OFL.txt │ └── utils │ │ ├── base64.go │ │ ├── slice.go │ │ ├── url.go │ │ └── uuid.go ├── serverless.yml └── yarn.lock ├── docs ├── architecture.png └── development.md ├── e2e ├── .gitignore ├── cypress.dev.config.ts ├── cypress.local.config.ts ├── cypress │ ├── e2e │ │ └── frontend │ │ │ └── pages │ │ │ └── home.cy.ts │ ├── fixtures │ │ └── images │ │ │ └── gray.png │ └── support │ │ ├── commands.ts │ │ └── e2e.ts ├── package.json ├── tsconfig.json └── yarn.lock ├── frontend ├── .env.template ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc ├── next-env.d.ts ├── next-sitemap.config.js ├── next.config.js ├── package.json ├── pages │ ├── 404.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── index.tsx │ ├── precautions.tsx │ └── privacy.tsx ├── public │ ├── card.png │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ └── manifest.json ├── src │ ├── components │ │ ├── App │ │ │ ├── App.tsx │ │ │ └── index.tsx │ │ ├── Layout │ │ │ ├── Footer.tsx │ │ │ ├── Header.tsx │ │ │ ├── Layout.tsx │ │ │ ├── index.tsx │ │ │ └── theme.ts │ │ ├── model │ │ │ ├── image │ │ │ │ ├── ImageCard.tsx │ │ │ │ └── ImageCardList.tsx │ │ │ ├── lgtm │ │ │ │ ├── LgtmCard.tsx │ │ │ │ ├── LgtmCardButtonGroup.tsx │ │ │ │ ├── LgtmCardList.tsx │ │ │ │ └── LgtmForm.tsx │ │ │ └── report │ │ │ │ └── ReportForm.tsx │ │ ├── pages │ │ │ ├── Home │ │ │ │ ├── FavoritesPanel.tsx │ │ │ │ ├── Home.tsx │ │ │ │ ├── LgtmsPanel.tsx │ │ │ │ ├── SearchImagesPanel.tsx │ │ │ │ ├── Tabs.tsx │ │ │ │ ├── UploadButton.tsx │ │ │ │ └── index.tsx │ │ │ ├── NotFound │ │ │ │ ├── NotFound.tsx │ │ │ │ └── index.tsx │ │ │ ├── Precautions │ │ │ │ ├── Precautions.tsx │ │ │ │ └── index.tsx │ │ │ └── PrivacyPolicy │ │ │ │ ├── PrivacyPolicy.tsx │ │ │ │ └── index.tsx │ │ ├── providers │ │ │ └── ToastProvider.tsx │ │ └── utils │ │ │ ├── Field.tsx │ │ │ ├── Form.tsx │ │ │ ├── Link.tsx │ │ │ ├── LoadableButton.tsx │ │ │ ├── Loading.tsx │ │ │ ├── Meta.tsx │ │ │ ├── Modal.tsx │ │ │ └── ModalCard.tsx │ ├── global.d.ts │ ├── hooks │ │ ├── i18n.ts │ │ ├── imageHooks.ts │ │ ├── lgtmHooks.ts │ │ ├── reportHooks.ts │ │ └── translateHooks.ts │ ├── lib │ │ ├── apiClient.ts │ │ ├── dataStorage.ts │ │ ├── dataUrl.ts │ │ ├── emotion.ts │ │ ├── errors.ts │ │ └── imageFileReader.ts │ ├── locales │ │ ├── en.tsx │ │ ├── ja.tsx │ │ └── translate.ts │ ├── models │ │ ├── image.ts │ │ ├── lgtm.ts │ │ └── report.ts │ ├── recoil │ │ └── atoms.ts │ └── routes.ts ├── tsconfig.json └── yarn.lock ├── renovate.json └── terraform ├── .gitignore ├── app ├── .terraform.lock.hcl ├── .tflint.hcl ├── main.tf ├── modules │ └── aws │ │ ├── acm.tf │ │ ├── apigateway.tf │ │ ├── cloudfront.tf │ │ ├── ecr.tf │ │ ├── locals.tf │ │ ├── route53.tf │ │ ├── s3.tf │ │ ├── terraform.tf │ │ └── variables.tf ├── provider.tf ├── terraform.tf └── tfsec.yml └── cicd ├── .terraform.lock.hcl ├── iam.tf ├── locals.tf ├── provider.tf └── terraform.tf /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | exclude_patterns: 2 | - "backend/mocks/" 3 | -------------------------------------------------------------------------------- /.github/workflows/_build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | AWS_IAM_ROLE_ARN: 7 | required: true 8 | 9 | jobs: 10 | backend: 11 | runs-on: ubuntu-latest 12 | defaults: 13 | run: 14 | working-directory: backend 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: actions/setup-go@v3 18 | with: 19 | go-version-file: backend/go.mod 20 | cache: true 21 | cache-dependency-path: backend/go.sum 22 | - run: go test ./... 23 | 24 | frontend: 25 | runs-on: ubuntu-latest 26 | defaults: 27 | run: 28 | working-directory: frontend 29 | steps: 30 | - uses: actions/checkout@v3 31 | - uses: actions/setup-node@v3 32 | with: 33 | node-version-file: frontend/package.json 34 | cache: yarn 35 | cache-dependency-path: frontend/yarn.lock 36 | - run: yarn install --frozen-lockfile 37 | - run: yarn lint 38 | - name: next cache 39 | uses: actions/cache@v3 40 | with: 41 | path: ${{ github.workspace }}/frontend/.next/cache 42 | key: ${{ runner.os }}-nextjs-${{ hashFiles('frontend/yarn.lock') }}-${{ hashFiles('frontend/**.[jt]s', hashFiles('frontend/**.[jt]sx')) }} 43 | restore-keys: | 44 | ${{ runner.os }}-nextjs-${{ hashFiles('frontend/yarn.lock') }}- 45 | - run: yarn run build 46 | 47 | terraform: 48 | permissions: 49 | id-token: write 50 | contents: read 51 | runs-on: ubuntu-latest 52 | defaults: 53 | run: 54 | working-directory: terraform/app 55 | steps: 56 | - uses: actions/checkout@v3 57 | - uses: hashicorp/setup-terraform@v2 58 | with: 59 | terraform_version: 1.3.7 60 | - uses: aws-actions/configure-aws-credentials@v1 61 | with: 62 | aws-region: us-east-1 63 | role-to-assume: ${{ secrets.AWS_IAM_ROLE_ARN }} 64 | # TODO: tfsec, tflint も実行したい 65 | - run: terraform init 66 | - name: terraform plan (dev) 67 | run: | 68 | terraform workspace select dev 69 | terraform plan -no-color -input=false 70 | - name: terraform plan (prod) 71 | run: | 72 | terraform workspace select prod 73 | terraform plan -no-color -input=false 74 | -------------------------------------------------------------------------------- /.github/workflows/_e2e.yml: -------------------------------------------------------------------------------- 1 | name: E2E 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | CYPRESS_PROJECT_ID: 7 | required: true 8 | CYPRESS_RECORD_KEY: 9 | required: true 10 | 11 | jobs: 12 | e2e: 13 | runs-on: ubuntu-latest 14 | defaults: 15 | run: 16 | working-directory: e2e 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: cypress-io/github-action@v5.8.1 20 | with: 21 | config-file: cypress.dev.config.ts 22 | record: true 23 | working-directory: e2e 24 | env: 25 | CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }} 26 | CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/_release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | stage: 7 | required: true 8 | type: string 9 | secrets: 10 | AWS_IAM_ROLE_ARN: 11 | required: true 12 | SLACK_API_TOKEN: 13 | required: true 14 | GOOGLE_API_KEY: 15 | required: true 16 | GOOGLE_CUSTOM_SEARCH_ENGINE_ID: 17 | required: true 18 | 19 | jobs: 20 | backend: 21 | permissions: 22 | id-token: write 23 | contents: read 24 | runs-on: ubuntu-latest 25 | environment: ${{ inputs.stage }} 26 | defaults: 27 | run: 28 | working-directory: backend 29 | steps: 30 | - uses: actions/checkout@v3 31 | - uses: actions/setup-node@v3 32 | with: 33 | node-version-file: backend/package.json 34 | cache: yarn 35 | cache-dependency-path: backend/yarn.lock 36 | - run: yarn install --frozen-lockfile 37 | - uses: aws-actions/configure-aws-credentials@v1 38 | with: 39 | aws-region: us-east-1 40 | role-to-assume: ${{ secrets.AWS_IAM_ROLE_ARN }} 41 | - name: deploy 42 | run: yarn run deploy --stage ${{ inputs.stage }} 43 | env: 44 | SLACK_API_TOKEN: ${{ secrets.SLACK_API_TOKEN }} 45 | GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} 46 | GOOGLE_CUSTOM_SEARCH_ENGINE_ID: ${{ secrets.GOOGLE_CUSTOM_SEARCH_ENGINE_ID }} 47 | 48 | terraform: 49 | permissions: 50 | id-token: write 51 | contents: read 52 | runs-on: ubuntu-latest 53 | environment: ${{ inputs.stage }} 54 | defaults: 55 | run: 56 | working-directory: terraform/app 57 | steps: 58 | - uses: actions/checkout@v3 59 | - uses: hashicorp/setup-terraform@v2 60 | with: 61 | terraform_version: 1.3.7 62 | - uses: aws-actions/configure-aws-credentials@v1 63 | with: 64 | aws-region: us-east-1 65 | role-to-assume: ${{ secrets.AWS_IAM_ROLE_ARN }} 66 | - run: terraform init 67 | - name: terraform apply 68 | run: | 69 | terraform workspace select ${{ inputs.stage }} 70 | terraform apply -auto-approve -no-color -input=false 71 | -------------------------------------------------------------------------------- /.github/workflows/release_dev.yml: -------------------------------------------------------------------------------- 1 | name: Release (dev) 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - main 7 | 8 | jobs: 9 | build: 10 | uses: ./.github/workflows/_build.yml 11 | secrets: inherit 12 | 13 | release: 14 | needs: [build] 15 | uses: ./.github/workflows/_release.yml 16 | with: 17 | stage: dev 18 | secrets: inherit 19 | 20 | e2e: 21 | needs: [release] 22 | uses: ./.github/workflows/_e2e.yml 23 | secrets: inherit 24 | -------------------------------------------------------------------------------- /.github/workflows/release_prod.yml: -------------------------------------------------------------------------------- 1 | name: Release (prod) 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | uses: ./.github/workflows/_build.yml 11 | secrets: inherit 12 | 13 | release: 14 | needs: [build] 15 | uses: ./.github/workflows/_release.yml 16 | with: 17 | stage: prod 18 | secrets: inherit 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 koki sato 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 | Moved to https://github.com/koki-develop/lgtmgen 2 | -------------------------------------------------------------------------------- /backend/.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | tmp_dir = "tmp" 3 | 4 | [build] 5 | bin = "./build/api" 6 | cmd = "go build -o ./build/api ./pkg/handlers/api/local/" 7 | delay = 1000 8 | exclude_dir = ["assets", "tmp", "vendor", "node_modules"] 9 | exclude_file = [] 10 | exclude_regex = [] 11 | exclude_unchanged = false 12 | follow_symlink = false 13 | full_bin = "" 14 | include_dir = [] 15 | include_ext = ["go", "tpl", "tmpl", "html"] 16 | kill_delay = "0s" 17 | log = "build-errors.log" 18 | send_interrupt = false 19 | stop_on_error = true 20 | 21 | [color] 22 | app = "" 23 | build = "yellow" 24 | main = "magenta" 25 | runner = "green" 26 | watcher = "cyan" 27 | 28 | [log] 29 | time = false 30 | 31 | [misc] 32 | clean_on_exit = false 33 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | /.serverless/ 2 | /node_modules/ 3 | /secrets.yml 4 | -------------------------------------------------------------------------------- /backend/.env.template: -------------------------------------------------------------------------------- 1 | STAGE=local 2 | ALLOW_ORIGIN=http://localhost:3000 3 | IMAGES_BASE_URL=http://localhost:9000/lgtm-generator-backend-local-images 4 | SLACK_API_TOKEN= 5 | GOOGLE_API_KEY= 6 | GOOGLE_CUSTOM_SEARCH_ENGINE_ID= 7 | -------------------------------------------------------------------------------- /backend/.envrc: -------------------------------------------------------------------------------- 1 | dotenv 2 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Serverless directories 2 | .serverless 3 | 4 | # golang output binary directory 5 | /build/ 6 | 7 | # golang vendor (dependencies) directory 8 | vendor 9 | 10 | # Binaries for programs and plugins 11 | *.exe 12 | *.exe~ 13 | *.dll 14 | *.so 15 | *.dylib 16 | 17 | # Test binary, build with `go test -c` 18 | *.test 19 | 20 | # Output of the go coverage tool, specifically when used with LiteIDE 21 | cover.* 22 | 23 | /node_modules/ 24 | /.env 25 | /.dynamodb 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /backend/containers/app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19 as build 2 | WORKDIR /var/task 3 | 4 | RUN apt update \ 5 | && apt install -y \ 6 | imagemagick \ 7 | libmagickwand-dev 8 | 9 | COPY go.mod go.sum ./ 10 | RUN go mod download -x 11 | COPY . . 12 | RUN GO111MODULE=on GOOS=linux go build -ldflags="-s -w" -o build/api pkg/handlers/api/lambda/main.go \ 13 | && GO111MODULE=on GOOS=linux go build -ldflags="-s -w" -o build/deletelgtm pkg/handlers/deletelgtm/main.go 14 | 15 | ENTRYPOINT [] 16 | 17 | # --------------------------- 18 | 19 | FROM golang:1.19 20 | WORKDIR /var/task 21 | 22 | RUN apt update \ 23 | && apt install -y \ 24 | imagemagick \ 25 | libmagickwand-dev 26 | COPY --from=build /var/task/pkg/static/ /var/task/pkg/static/ 27 | COPY --from=build /var/task/build/api /var/task/build/api 28 | COPY --from=build /var/task/build/deletelgtm /var/task/build/deletelgtm 29 | 30 | ENTRYPOINT [] 31 | -------------------------------------------------------------------------------- /backend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | bucket: 5 | image: minio/minio:latest 6 | ports: 7 | - 9000:9000 8 | - 9001:9001 9 | volumes: 10 | - bucket_data:/export 11 | environment: 12 | MINIO_ROOT_USER: DUMMY_AWS_ACCESS_KEY_ID 13 | MINIO_ROOT_PASSWORD: DUMMY_AWS_SECRET_ACCESS_KEY 14 | entrypoint: sh 15 | command: | 16 | -c " 17 | mkdir -p /export/lgtm-generator-backend-local-images 18 | /usr/bin/docker-entrypoint.sh server /export --console-address ':9001' 19 | " 20 | 21 | dynamodb: 22 | image: amazon/dynamodb-local:1.22.0 23 | user: root 24 | command: -jar DynamoDBLocal.jar -sharedDb -dbPath /data 25 | ports: 26 | - 8000:8000 27 | volumes: 28 | - dynamodb_data:/data 29 | 30 | volumes: 31 | bucket_data: 32 | dynamodb_data: 33 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/koki-develop/lgtm-generator/backend 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/aws/aws-lambda-go v1.41.0 7 | github.com/aws/aws-sdk-go v1.44.288 8 | github.com/awslabs/aws-lambda-go-api-proxy v0.14.0 9 | github.com/gin-gonic/gin v1.9.1 10 | github.com/google/uuid v1.3.0 11 | github.com/guregu/dynamo v1.17.0 12 | github.com/pkg/errors v0.9.1 13 | github.com/slack-go/slack v0.12.2 14 | google.golang.org/api v0.128.0 15 | gopkg.in/gographics/imagick.v2 v2.6.2 16 | ) 17 | 18 | require ( 19 | cloud.google.com/go/compute v1.19.3 // indirect 20 | cloud.google.com/go/compute/metadata v0.2.3 // indirect 21 | github.com/bytedance/sonic v1.9.1 // indirect 22 | github.com/cenkalti/backoff/v4 v4.1.2 // indirect 23 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect 24 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 25 | github.com/gin-contrib/sse v0.1.0 // indirect 26 | github.com/go-playground/locales v0.14.1 // indirect 27 | github.com/go-playground/universal-translator v0.18.1 // indirect 28 | github.com/go-playground/validator/v10 v10.14.0 // indirect 29 | github.com/goccy/go-json v0.10.2 // indirect 30 | github.com/gofrs/uuid v4.2.0+incompatible // indirect 31 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 32 | github.com/golang/protobuf v1.5.3 // indirect 33 | github.com/google/s2a-go v0.1.4 // indirect 34 | github.com/googleapis/enterprise-certificate-proxy v0.2.4 // indirect 35 | github.com/googleapis/gax-go/v2 v2.10.0 // indirect 36 | github.com/gorilla/websocket v1.5.0 // indirect 37 | github.com/jmespath/go-jmespath v0.4.0 // indirect 38 | github.com/json-iterator/go v1.1.12 // indirect 39 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect 40 | github.com/leodido/go-urn v1.2.4 // indirect 41 | github.com/mattn/go-isatty v0.0.19 // indirect 42 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 43 | github.com/modern-go/reflect2 v1.0.2 // indirect 44 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 45 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 46 | github.com/ugorji/go/codec v1.2.11 // indirect 47 | go.opencensus.io v0.24.0 // indirect 48 | golang.org/x/arch v0.3.0 // indirect 49 | golang.org/x/crypto v0.9.0 // indirect 50 | golang.org/x/net v0.10.0 // indirect 51 | golang.org/x/oauth2 v0.8.0 // indirect 52 | golang.org/x/sys v0.8.0 // indirect 53 | golang.org/x/text v0.9.0 // indirect 54 | google.golang.org/appengine v1.6.7 // indirect 55 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc // indirect 56 | google.golang.org/grpc v1.55.0 // indirect 57 | google.golang.org/protobuf v1.30.0 // indirect 58 | gopkg.in/yaml.v3 v3.0.1 // indirect 59 | ) 60 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lgtm-generator-backend", 3 | "license": "MIT", 4 | "private": true, 5 | "engines": { 6 | "node": "18.x" 7 | }, 8 | "scripts": { 9 | "deploy": "serverless deploy --verbose", 10 | "remove": "serverless remove --verbose" 11 | }, 12 | "devDependencies": { 13 | "serverless": "3.32.2" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /backend/pkg/controllers/cors_middleware.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | type CORSMiddleware struct { 11 | Renderer *Renderer 12 | AllowOrigin string 13 | } 14 | 15 | func NewCORSMiddleware(origin string) *CORSMiddleware { 16 | return &CORSMiddleware{AllowOrigin: origin} 17 | } 18 | 19 | func (m *CORSMiddleware) Apply(ctx *gin.Context) { 20 | org := ctx.Request.Header.Get("origin") 21 | if org == "" { 22 | ctx.Next() 23 | return 24 | } 25 | 26 | if !m.validateOrigin(org) { 27 | m.Renderer.Forbidden(ctx) 28 | ctx.Abort() 29 | return 30 | } 31 | 32 | ctx.Header("Access-Control-Allow-Origin", org) 33 | ctx.Header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") 34 | ctx.Header("Access-Control-Allow-Headers", "Origin,Content-Length,Content-Type,Accept-Encoding") 35 | 36 | if ctx.Request.Method == http.MethodOptions { 37 | m.Renderer.NoContent(ctx) 38 | ctx.Abort() 39 | return 40 | } 41 | } 42 | 43 | func (m *CORSMiddleware) validateOrigin(org string) bool { 44 | if strings.Contains(m.AllowOrigin, "*") { 45 | pref, suff := func() (string, string) { 46 | s := strings.Split(m.AllowOrigin, "*") 47 | return s[0], s[1] 48 | }() 49 | if strings.HasPrefix(org, pref) && strings.HasSuffix(org, suff) { 50 | return true 51 | } 52 | } else { 53 | if org == m.AllowOrigin { 54 | return true 55 | } 56 | } 57 | 58 | return false 59 | } 60 | -------------------------------------------------------------------------------- /backend/pkg/controllers/errcode.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | type ErrCode string 4 | 5 | const ( 6 | ErrCodeInvalidJSON ErrCode = "INVALID_JSON" 7 | ErrCodeInvalidInput ErrCode = "INVALID_INPUT" 8 | ErrCodeInvalidQuery ErrCode = "INVALID_QUERY" 9 | ErrCodeUnsupportedImageFormat ErrCode = "UNSUPPORTED_IMAGE_FORMAT" 10 | 11 | ErrCodeForbidden ErrCode = "FORBIDDEN" 12 | ErrCodeNotFound ErrCode = "NOT_FOUND" 13 | 14 | ErrCodeInternalServerError ErrCode = "INTERNAL_SERVER_ERROR" 15 | ) 16 | -------------------------------------------------------------------------------- /backend/pkg/controllers/error_response_logger_middleware.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "strconv" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/pkg/errors" 12 | "github.com/slack-go/slack" 13 | ) 14 | 15 | type ErrorResponseLoggerMiddleware struct { 16 | slackAPI *slack.Client 17 | channel string 18 | } 19 | 20 | func NewErrorResponseLoggerMiddleware(slackAPI *slack.Client, channel string) *ErrorResponseLoggerMiddleware { 21 | return &ErrorResponseLoggerMiddleware{slackAPI: slackAPI, channel: channel} 22 | } 23 | 24 | type responseBodyWriter struct { 25 | gin.ResponseWriter 26 | body *bytes.Buffer 27 | } 28 | 29 | func (w responseBodyWriter) Write(b []byte) (int, error) { 30 | w.body.Write(b) 31 | return w.ResponseWriter.Write(b) 32 | } 33 | 34 | func (m *ErrorResponseLoggerMiddleware) Apply(ctx *gin.Context) { 35 | w := &responseBodyWriter{body: bytes.NewBufferString(""), ResponseWriter: ctx.Writer} 36 | ctx.Writer = w 37 | 38 | url := ctx.Request.URL.String() 39 | method := ctx.Request.Method 40 | body, err := io.ReadAll(ctx.Request.Body) 41 | if err == nil { 42 | ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body)) 43 | } else { 44 | body = []byte("failed to read body") 45 | fmt.Printf("failed to read body: %+v\n", errors.WithStack(err)) 46 | } 47 | 48 | ctx.Next() 49 | 50 | status := ctx.Writer.Status() 51 | if status < 400 || status == 404 { 52 | return 53 | } 54 | 55 | reqbody := string(body) 56 | respbody := w.body.String() 57 | 58 | fmt.Printf("url: %s\nmethod: %s\nbody: %s\nstatus: %d\nresponse: %s\n", url, method, body, status, respbody) 59 | l, err := json.Marshal(map[string]interface{}{ 60 | "url": url, 61 | "method": method, 62 | "request body": reqbody, 63 | "response status": status, 64 | "response body": respbody, 65 | }) 66 | if err == nil { 67 | fmt.Println(string(l)) 68 | } else { 69 | fmt.Printf("failed to marshal: %+v\n", err) 70 | } 71 | 72 | color := "#ff8c00" 73 | if status >= 500 { 74 | color = "#ff0000" 75 | } 76 | 77 | if _, _, err := m.slackAPI.PostMessage(m.channel, slack.MsgOptionAttachments( 78 | slack.Attachment{ 79 | Title: "returned error response.", 80 | Color: color, 81 | Fields: []slack.AttachmentField{ 82 | {Title: "url", Value: url, Short: true}, 83 | {Title: "method", Value: method, Short: true}, 84 | {Title: "request body", Value: reqbody, Short: false}, 85 | {Title: "response status", Value: strconv.Itoa(status), Short: true}, 86 | {Title: "response body", Value: respbody, Short: false}, 87 | }, 88 | }, 89 | )); err != nil { 90 | fmt.Printf("failed to post message: %+v\n", err) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /backend/pkg/controllers/health_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | type HealthController struct { 8 | Renderer *Renderer 9 | } 10 | 11 | func NewHealthController() *HealthController { 12 | return &HealthController{ 13 | Renderer: NewRenderer(), 14 | } 15 | } 16 | 17 | func (ctrl *HealthController) Standard(ctx *gin.Context) { 18 | ctrl.Renderer.OK(ctx, map[string]string{"status": "ok"}) 19 | } 20 | -------------------------------------------------------------------------------- /backend/pkg/controllers/images_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/koki-develop/lgtm-generator/backend/pkg/entities" 6 | "github.com/koki-develop/lgtm-generator/backend/pkg/infrastructures/imagesearch" 7 | ) 8 | 9 | type ImagesController struct { 10 | Renderer *Renderer 11 | ImageSearchEngine imagesearch.Engine 12 | } 13 | 14 | func NewImagesController(engine imagesearch.Engine) *ImagesController { 15 | return &ImagesController{ 16 | Renderer: NewRenderer(), 17 | ImageSearchEngine: engine, 18 | } 19 | } 20 | 21 | func (ctrl *ImagesController) Search(ctx *gin.Context) { 22 | var ipt entities.ImagesSearchInput 23 | if err := ctx.ShouldBindQuery(&ipt); err != nil { 24 | ctrl.Renderer.BadRequest(ctx, ErrCodeInvalidQuery) 25 | return 26 | } 27 | if !ipt.Valid() { 28 | ctrl.Renderer.BadRequest(ctx, ErrCodeInvalidQuery) 29 | return 30 | } 31 | 32 | imgs, err := ctrl.ImageSearchEngine.Search(ipt.Query) 33 | if err != nil { 34 | ctrl.Renderer.InternalServerError(ctx, err) 35 | return 36 | } 37 | 38 | ctrl.Renderer.OK(ctx, imgs) 39 | } 40 | -------------------------------------------------------------------------------- /backend/pkg/controllers/lgtms_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/pkg/errors" 8 | "github.com/slack-go/slack" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/koki-develop/lgtm-generator/backend/pkg/entities" 12 | "github.com/koki-develop/lgtm-generator/backend/pkg/infrastructures/lgtmgen" 13 | "github.com/koki-develop/lgtm-generator/backend/pkg/repositories" 14 | ) 15 | 16 | type LGTMsController struct { 17 | Renderer *Renderer 18 | LGTMGenerator *lgtmgen.LGTMGenerator 19 | LGTMsRepository *repositories.LGTMsRepository 20 | SlackAPI *slack.Client 21 | SlackChannel string 22 | } 23 | 24 | func NewLGTMsController(g *lgtmgen.LGTMGenerator, slackAPI *slack.Client, slackChannel string, repo *repositories.LGTMsRepository) *LGTMsController { 25 | return &LGTMsController{ 26 | Renderer: NewRenderer(), 27 | LGTMGenerator: g, 28 | LGTMsRepository: repo, 29 | SlackAPI: slackAPI, 30 | SlackChannel: slackChannel, 31 | } 32 | } 33 | 34 | func (ctrl *LGTMsController) FindAll(ctx *gin.Context) { 35 | var ipt entities.LGTMFindAllInput 36 | if err := ctx.ShouldBindQuery(&ipt); err != nil { 37 | ctrl.Renderer.BadRequest(ctx, ErrCodeInvalidQuery) 38 | return 39 | } 40 | 41 | if ipt.After == nil || *ipt.After == "" { 42 | if ipt.Random { 43 | lgtms, err := ctrl.LGTMsRepository.FindRandomly() 44 | if err != nil { 45 | ctrl.Renderer.InternalServerError(ctx, err) 46 | return 47 | } 48 | 49 | ctrl.Renderer.OK(ctx, lgtms) 50 | return 51 | } else { 52 | lgtms, err := ctrl.LGTMsRepository.FindAll() 53 | if err != nil { 54 | ctrl.Renderer.InternalServerError(ctx, err) 55 | return 56 | } 57 | 58 | ctrl.Renderer.OK(ctx, lgtms) 59 | return 60 | } 61 | } 62 | 63 | lgtm, ok, err := ctrl.LGTMsRepository.Find(*ipt.After) 64 | if err != nil { 65 | ctrl.Renderer.InternalServerError(ctx, err) 66 | return 67 | } 68 | if !ok { 69 | ctrl.Renderer.BadRequest(ctx, ErrCodeInvalidQuery) 70 | return 71 | } 72 | 73 | lgtms, err := ctrl.LGTMsRepository.FindAllAfter(lgtm) 74 | if err != nil { 75 | ctrl.Renderer.InternalServerError(ctx, err) 76 | return 77 | } 78 | 79 | ctrl.Renderer.OK(ctx, lgtms) 80 | } 81 | 82 | func (ctrl *LGTMsController) Create(ctx *gin.Context) { 83 | var ipt entities.LGTMCreateInput 84 | if err := ctx.ShouldBindJSON(&ipt); err != nil { 85 | ctrl.Renderer.BadRequest(ctx, ErrCodeInvalidJSON) 86 | return 87 | } 88 | if !ipt.Valid() { 89 | ctrl.Renderer.BadRequest(ctx, ErrCodeInvalidInput) 90 | return 91 | } 92 | 93 | img, ok, err := ctrl.generateFromInput(ipt) 94 | if err != nil { 95 | ctrl.Renderer.InternalServerError(ctx, err) 96 | return 97 | } 98 | if !ok { 99 | ctrl.Renderer.BadRequest(ctx, ErrCodeUnsupportedImageFormat) 100 | return 101 | } 102 | 103 | lgtm, err := ctrl.LGTMsRepository.Create(img) 104 | if err != nil { 105 | ctrl.Renderer.InternalServerError(ctx, err) 106 | return 107 | } 108 | 109 | ctrl.Renderer.Created(ctx, lgtm) 110 | 111 | // FIXME: Slack 通知する分、レスポンスタイムが長くなる 112 | // SNS 等を使って非同期的に行うようにする 113 | if _, _, err := ctrl.SlackAPI.PostMessage(ctrl.SlackChannel, slack.MsgOptionAttachments( 114 | slack.Attachment{ 115 | Color: "#00bfff", 116 | Title: "LGTM 画像が生成されました", 117 | ThumbURL: fmt.Sprintf("%s/%s", os.Getenv("IMAGES_BASE_URL"), lgtm.ID), 118 | Fields: []slack.AttachmentField{ 119 | {Title: "LGTM ID", Value: lgtm.ID, Short: true}, 120 | {Title: "Source", Value: ctrl.renderSource(ipt)}, 121 | }, 122 | }, 123 | )); err != nil { 124 | fmt.Printf("failed to post message: %+v\n", err) 125 | } 126 | } 127 | 128 | func (ctrl *LGTMsController) renderSource(ipt entities.LGTMCreateInput) string { 129 | switch ipt.From { 130 | case entities.LGTMCreateFromBase64: 131 | return "base64" 132 | case entities.LGTMCreateFromURL: 133 | return ipt.URL 134 | default: 135 | return "" 136 | } 137 | } 138 | 139 | func (ctrl *LGTMsController) generateFromInput(ipt entities.LGTMCreateInput) (*entities.LGTMImage, bool, error) { 140 | switch ipt.From { 141 | case entities.LGTMCreateFromURL: 142 | return ctrl.LGTMGenerator.GenerateFromURL(ipt.URL) 143 | case entities.LGTMCreateFromBase64: 144 | return ctrl.LGTMGenerator.GenerateFromBase64(ipt.Base64, ipt.ContentType) 145 | default: 146 | return nil, false, errors.Errorf("unknown from: %s", ipt.From) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /backend/pkg/controllers/logger_middleware.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/awslabs/aws-lambda-go-api-proxy/core" 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | type LoggerMiddleware struct{} 12 | 13 | func NewLoggerMiddleware() *LoggerMiddleware { 14 | return &LoggerMiddleware{} 15 | } 16 | 17 | type logParams struct { 18 | Timestamp time.Time 19 | APIGatewayRequestID string 20 | ClientIP string 21 | ResponseStatusCode int 22 | RequestPath string 23 | RequestMethod string 24 | } 25 | 26 | func (m *LoggerMiddleware) Apply() gin.HandlerFunc { 27 | return func(ctx *gin.Context) { 28 | p := ctx.Request.URL.Path 29 | q := ctx.Request.URL.RawQuery 30 | if q != "" { 31 | p = p + "?" + q 32 | } 33 | 34 | params := logParams{ 35 | Timestamp: time.Now(), 36 | APIGatewayRequestID: "local", 37 | ClientIP: ctx.ClientIP(), 38 | RequestPath: p, 39 | RequestMethod: ctx.Request.Method, 40 | } 41 | 42 | apigwctx, ok := core.GetAPIGatewayContextFromContext(ctx.Request.Context()) 43 | if ok { 44 | params.APIGatewayRequestID = apigwctx.RequestID 45 | params.ClientIP = apigwctx.Identity.SourceIP 46 | } 47 | 48 | ctx.Next() 49 | 50 | params.ResponseStatusCode = ctx.Writer.Status() 51 | 52 | fmt.Printf( 53 | "%s | %s | %s | %d | %s %s | \n", 54 | params.Timestamp.Format(time.RFC3339), 55 | params.APIGatewayRequestID, 56 | params.ClientIP, 57 | params.ResponseStatusCode, 58 | params.RequestMethod, params.RequestPath, 59 | ) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /backend/pkg/controllers/renderer.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | type ErrorResponse struct { 11 | Code ErrCode `json:"code"` 12 | } 13 | 14 | type Renderer struct{} 15 | 16 | func NewRenderer() *Renderer { 17 | return &Renderer{} 18 | } 19 | 20 | func (r *Renderer) OK(ctx *gin.Context, obj interface{}) { 21 | ctx.JSON(http.StatusOK, obj) 22 | } 23 | 24 | func (r *Renderer) Created(ctx *gin.Context, obj interface{}) { 25 | ctx.JSON(http.StatusCreated, obj) 26 | } 27 | 28 | func (r *Renderer) NoContent(ctx *gin.Context) { 29 | ctx.Status(http.StatusNoContent) 30 | } 31 | 32 | func (r *Renderer) BadRequest(ctx *gin.Context, code ErrCode) { 33 | r.renderError(ctx, http.StatusBadRequest, code) 34 | } 35 | 36 | func (r *Renderer) Forbidden(ctx *gin.Context) { 37 | r.renderError(ctx, http.StatusForbidden, ErrCodeForbidden) 38 | } 39 | 40 | func (r *Renderer) NotFound(ctx *gin.Context) { 41 | r.renderError(ctx, http.StatusNotFound, ErrCodeNotFound) 42 | } 43 | 44 | func (r *Renderer) InternalServerError(ctx *gin.Context, err error) { 45 | fmt.Printf("error: %+v\n", err) 46 | r.renderError(ctx, http.StatusInternalServerError, ErrCodeInternalServerError) 47 | } 48 | 49 | func (r *Renderer) renderError(ctx *gin.Context, status int, code ErrCode) { 50 | ctx.JSON(status, &ErrorResponse{Code: code}) 51 | } 52 | -------------------------------------------------------------------------------- /backend/pkg/controllers/reports_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/koki-develop/lgtm-generator/backend/pkg/entities" 9 | "github.com/koki-develop/lgtm-generator/backend/pkg/repositories" 10 | "github.com/slack-go/slack" 11 | ) 12 | 13 | type ReportsController struct { 14 | Renderer *Renderer 15 | SlackAPI *slack.Client 16 | SlackChannel string 17 | ReportsRepository *repositories.ReportsRepository 18 | } 19 | 20 | func NewReportsController(slackAPI *slack.Client, slackChannel string, repo *repositories.ReportsRepository) *ReportsController { 21 | return &ReportsController{ 22 | Renderer: NewRenderer(), 23 | SlackAPI: slackAPI, 24 | SlackChannel: slackChannel, 25 | ReportsRepository: repo, 26 | } 27 | } 28 | 29 | func (ctrl *ReportsController) Create(ctx *gin.Context) { 30 | var ipt entities.ReportCreateInput 31 | if err := ctx.ShouldBindJSON(&ipt); err != nil { 32 | ctrl.Renderer.BadRequest(ctx, ErrCodeInvalidJSON) 33 | return 34 | } 35 | if !ipt.Valid() { 36 | ctrl.Renderer.BadRequest(ctx, ErrCodeInvalidInput) 37 | return 38 | } 39 | 40 | rpt, err := ctrl.ReportsRepository.Create(ipt.LGTMID, ipt.Type, ipt.Text) 41 | if err != nil { 42 | ctrl.Renderer.InternalServerError(ctx, err) 43 | return 44 | } 45 | 46 | ctrl.Renderer.Created(ctx, rpt) 47 | 48 | if _, _, err := ctrl.SlackAPI.PostMessage(ctrl.SlackChannel, slack.MsgOptionAttachments( 49 | slack.Attachment{ 50 | Color: "#ff8c00", 51 | Title: rpt.Text, 52 | ThumbURL: fmt.Sprintf("%s/%s", os.Getenv("IMAGES_BASE_URL"), rpt.LGTMID), 53 | Fields: []slack.AttachmentField{ 54 | {Title: "LGTM ID", Value: rpt.LGTMID, Short: true}, 55 | {Title: "Report Type", Value: string(rpt.Type), Short: true}, 56 | }, 57 | }, 58 | )); err != nil { 59 | fmt.Printf("failed to post message: %+v\n", err) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /backend/pkg/entities/image.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import "strings" 4 | 5 | type Image struct { 6 | Title string `json:"title"` 7 | URL string `json:"url"` 8 | } 9 | 10 | type Images []*Image 11 | 12 | type ImagesSearchInput struct { 13 | Query string `form:"q"` 14 | } 15 | 16 | func (ipt *ImagesSearchInput) Valid() bool { 17 | if strings.TrimSpace(ipt.Query) == "" { 18 | return false 19 | } 20 | return true 21 | } 22 | -------------------------------------------------------------------------------- /backend/pkg/entities/lgtm.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/koki-develop/lgtm-generator/backend/pkg/utils" 8 | ) 9 | 10 | type LGTMStatus string 11 | 12 | const ( 13 | LGTMStatusOK LGTMStatus = "ok" 14 | LGTMStatusPending LGTMStatus = "pending" 15 | LGTMStatusDeleting LGTMStatus = "deleting" 16 | ) 17 | 18 | type LGTMImage struct { 19 | Data []byte 20 | ContentType string 21 | } 22 | 23 | type LGTM struct { 24 | ID string `json:"id" dynamo:"id" dynamodbav:"id"` 25 | Status LGTMStatus `json:"-" dynamo:"status" dynamodbav:"status"` 26 | CreatedAt time.Time `json:"-" dynamo:"created_at" dynamodbav:"created_at"` 27 | } 28 | 29 | type LGTMs []*LGTM 30 | 31 | type LGTMCreateFrom string 32 | 33 | const ( 34 | LGTMCreateFromURL LGTMCreateFrom = "URL" 35 | LGTMCreateFromBase64 LGTMCreateFrom = "BASE64" 36 | ) 37 | 38 | type LGTMCreateInput struct { 39 | URL string `json:"url"` 40 | Base64 string `json:"base64"` 41 | ContentType string `json:"content_type"` 42 | From LGTMCreateFrom 43 | } 44 | 45 | func (ipt *LGTMCreateInput) Valid() bool { 46 | if strings.TrimSpace(ipt.URL) != "" { 47 | if !utils.IsURL(ipt.URL) { 48 | return false 49 | } 50 | ipt.From = LGTMCreateFromURL 51 | return true 52 | } 53 | if strings.TrimSpace(ipt.Base64) != "" { 54 | if !utils.IsBase64(ipt.Base64) { 55 | return false 56 | } 57 | if strings.TrimSpace(ipt.ContentType) == "" { 58 | return false 59 | } 60 | ipt.From = LGTMCreateFromBase64 61 | return true 62 | } 63 | return false 64 | } 65 | 66 | type LGTMFindAllInput struct { 67 | After *string `form:"after"` 68 | Random bool `form:"random"` 69 | } 70 | -------------------------------------------------------------------------------- /backend/pkg/entities/report.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | "unicode/utf8" 7 | ) 8 | 9 | type ReportType string 10 | 11 | const ( 12 | ReportTypeIllegal ReportType = "illegal" 13 | ReportTypeInappropriate ReportType = "inappropriate" 14 | ReportTypeOther ReportType = "other" 15 | ) 16 | 17 | func (t ReportType) Valid() bool { 18 | switch t { 19 | case ReportTypeIllegal, ReportTypeInappropriate, ReportTypeOther: 20 | return true 21 | default: 22 | return false 23 | } 24 | } 25 | 26 | type Report struct { 27 | ID string `json:"id" dynamo:"id"` 28 | LGTMID string `json:"-" dynamo:"lgtm_id"` 29 | Type ReportType `json:"-" dynamo:"type"` 30 | Text string `json:"-" dynamo:"text"` 31 | CreatedAt time.Time `json:"-" dynamo:"created_at"` 32 | } 33 | 34 | type ReportCreateInput struct { 35 | LGTMID string `json:"lgtm_id"` 36 | Type ReportType `json:"type"` 37 | Text string `json:"text"` 38 | } 39 | 40 | func (ipt *ReportCreateInput) Valid() bool { 41 | // lgtm id 42 | if strings.TrimSpace(ipt.LGTMID) == "" { 43 | return false 44 | } 45 | 46 | // type 47 | if strings.TrimSpace(string(ipt.Type)) == "" { 48 | return false 49 | } 50 | if !ipt.Type.Valid() { 51 | return false 52 | } 53 | 54 | // text 55 | if utf8.RuneCountInString(ipt.Text) > 1000 { 56 | return false 57 | } 58 | 59 | return true 60 | } 61 | -------------------------------------------------------------------------------- /backend/pkg/handlers/api/lambda/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aws/aws-lambda-go/events" 7 | "github.com/aws/aws-lambda-go/lambda" 8 | ginadapter "github.com/awslabs/aws-lambda-go-api-proxy/gin" 9 | "github.com/koki-develop/lgtm-generator/backend/pkg/infrastructures/router" 10 | ) 11 | 12 | var ginLambda *ginadapter.GinLambda 13 | 14 | func main() { 15 | lambda.Start(handler) 16 | } 17 | 18 | func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 19 | return ginLambda.ProxyWithContext(ctx, req) 20 | } 21 | 22 | func init() { 23 | r := router.New() 24 | 25 | ginLambda = ginadapter.New(r) 26 | } 27 | -------------------------------------------------------------------------------- /backend/pkg/handlers/api/local/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/koki-develop/lgtm-generator/backend/pkg/infrastructures/router" 4 | 5 | func main() { 6 | r := router.New() 7 | if err := r.Run(); err != nil { 8 | panic(err) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /backend/pkg/handlers/deletelgtm/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/aws/aws-lambda-go/lambda" 8 | "github.com/koki-develop/lgtm-generator/backend/pkg/infrastructures/dynamodb" 9 | "github.com/koki-develop/lgtm-generator/backend/pkg/infrastructures/s3" 10 | "github.com/koki-develop/lgtm-generator/backend/pkg/repositories" 11 | ) 12 | 13 | type Event struct { 14 | LGTMID string `json:"lgtm_id"` 15 | } 16 | 17 | func handler(e Event) error { 18 | db := dynamodb.New() 19 | bucket := fmt.Sprintf("lgtm-generator-backend-%s-images", os.Getenv("STAGE")) 20 | s3client := s3.New(bucket) 21 | repo := repositories.NewLGTMsRepository(s3client, db) 22 | if err := repo.Delete(e.LGTMID); err != nil { 23 | return err 24 | } 25 | return nil 26 | } 27 | 28 | func main() { 29 | lambda.Start(handler) 30 | } 31 | -------------------------------------------------------------------------------- /backend/pkg/infrastructures/dynamodb/dynamodb.go: -------------------------------------------------------------------------------- 1 | package dynamodb 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/aws/session" 8 | "github.com/guregu/dynamo" 9 | ) 10 | 11 | func New() *dynamo.DB { 12 | awscfg := &aws.Config{Region: aws.String("us-east-1")} 13 | if os.Getenv("STAGE") == "local" { 14 | awscfg.Endpoint = aws.String("http://localhost:8000") 15 | } 16 | sess := session.Must(session.NewSession(awscfg)) 17 | 18 | return dynamo.New(sess) 19 | } 20 | -------------------------------------------------------------------------------- /backend/pkg/infrastructures/imagesearch/imagesearch.go: -------------------------------------------------------------------------------- 1 | package imagesearch 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/koki-develop/lgtm-generator/backend/pkg/entities" 7 | "github.com/koki-develop/lgtm-generator/backend/pkg/utils" 8 | "github.com/pkg/errors" 9 | "google.golang.org/api/customsearch/v1" 10 | "google.golang.org/api/option" 11 | ) 12 | 13 | type Engine interface { 14 | Search(q string) (entities.Images, error) 15 | } 16 | 17 | type GoogleImageSearchEngine struct { 18 | APIKey string 19 | EngineID string 20 | } 21 | 22 | func New(apiKey, engineID string) *GoogleImageSearchEngine { 23 | return &GoogleImageSearchEngine{APIKey: apiKey, EngineID: engineID} 24 | } 25 | 26 | func (e *GoogleImageSearchEngine) Search(q string) (entities.Images, error) { 27 | svc, err := customsearch.NewService(context.Background(), option.WithAPIKey(e.APIKey)) 28 | if err != nil { 29 | return nil, errors.WithStack(err) 30 | } 31 | search := svc.Cse.List() 32 | search.Cx(e.EngineID) 33 | search.Q(q) 34 | search.Num(10) 35 | search.SearchType("image") 36 | search.Safe("active") 37 | search.Start(1) 38 | 39 | resp, err := search.Do() 40 | if err != nil { 41 | return nil, errors.WithStack(err) 42 | } 43 | 44 | imgs := entities.Images{} 45 | for _, item := range resp.Items { 46 | if !utils.IsHTTPSURL(item.Link) { 47 | continue 48 | } 49 | 50 | // TODO: svg 画像はエラーが出ずに失敗してることが多いので、一旦省く 51 | svgm := "image/svg+xml" 52 | if item.Mime == svgm || item.FileFormat == svgm { 53 | continue 54 | } 55 | 56 | imgs = append(imgs, &entities.Image{ 57 | Title: item.Title, 58 | URL: item.Link, 59 | }) 60 | } 61 | 62 | return imgs, nil 63 | } 64 | -------------------------------------------------------------------------------- /backend/pkg/infrastructures/lgtmgen/lgtmgen.go: -------------------------------------------------------------------------------- 1 | package lgtmgen 2 | 3 | import ( 4 | "io" 5 | "math" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/koki-develop/lgtm-generator/backend/pkg/entities" 10 | "github.com/koki-develop/lgtm-generator/backend/pkg/utils" 11 | "github.com/pkg/errors" 12 | "gopkg.in/gographics/imagick.v2/imagick" 13 | ) 14 | 15 | const maxSideLength float64 = 425 16 | 17 | type httpAPI interface { 18 | Do(req *http.Request) (*http.Response, error) 19 | } 20 | 21 | type LGTMGenerator struct { 22 | httpAPI httpAPI 23 | } 24 | 25 | func New() *LGTMGenerator { 26 | return &LGTMGenerator{ 27 | httpAPI: new(http.Client), 28 | } 29 | } 30 | 31 | func (g *LGTMGenerator) GenerateFromBase64(base64, contentType string) (*entities.LGTMImage, bool, error) { 32 | src, err := utils.Base64Decode(base64) 33 | if err != nil { 34 | return nil, false, err 35 | } 36 | data, ok, err := g.generate(src) 37 | if err != nil { 38 | return nil, false, err 39 | } 40 | if !ok { 41 | return nil, false, nil 42 | } 43 | 44 | return &entities.LGTMImage{ 45 | Data: data, 46 | ContentType: contentType, 47 | }, true, nil 48 | } 49 | 50 | func (g *LGTMGenerator) GenerateFromURL(u string) (*entities.LGTMImage, bool, error) { 51 | req, err := http.NewRequest(http.MethodGet, u, nil) 52 | if err != nil { 53 | return nil, false, errors.WithStack(err) 54 | } 55 | 56 | resp, err := g.httpAPI.Do(req) 57 | if err != nil { 58 | return nil, false, errors.WithStack(err) 59 | } 60 | defer resp.Body.Close() 61 | 62 | src, err := io.ReadAll(resp.Body) 63 | if err != nil { 64 | return nil, false, errors.WithStack(err) 65 | } 66 | 67 | data, ok, err := g.generate(src) 68 | if err != nil { 69 | return nil, false, err 70 | } 71 | if !ok { 72 | return nil, false, nil 73 | } 74 | 75 | return &entities.LGTMImage{ 76 | Data: data, 77 | ContentType: resp.Header.Get("content-type"), 78 | }, true, nil 79 | } 80 | 81 | func (g *LGTMGenerator) generate(src []byte) ([]byte, bool, error) { 82 | imagick.Initialize() 83 | defer imagick.Terminate() 84 | 85 | tmp := imagick.NewMagickWand() 86 | defer tmp.Destroy() 87 | if err := tmp.ReadImageBlob(src); err != nil { 88 | if strings.HasPrefix(err.Error(), "ERROR_MISSING_DELEGATE") { 89 | return nil, false, nil 90 | } 91 | return nil, false, errors.WithStack(err) 92 | } 93 | w := tmp.GetImageWidth() 94 | h := tmp.GetImageHeight() 95 | dw, dh := g.calcImageSize(float64(w), float64(h)) 96 | ttlfs, txtfs := g.calcFontSize(dw, dh) 97 | 98 | ttl := imagick.NewDrawingWand() 99 | txt := imagick.NewDrawingWand() 100 | if err := ttl.SetFont("pkg/static/fonts/Archivo_Black/ArchivoBlack-Regular.ttf"); err != nil { 101 | return nil, false, errors.WithStack(err) 102 | } 103 | if err := txt.SetFont("pkg/static/fonts/Archivo_Black/ArchivoBlack-Regular.ttf"); err != nil { 104 | return nil, false, errors.WithStack(err) 105 | } 106 | pw := imagick.NewPixelWand() 107 | if ok := pw.SetColor("#ffffff"); !ok { 108 | return nil, false, errors.New("invalid color") 109 | } 110 | bw := imagick.NewPixelWand() 111 | if ok := bw.SetColor("#000000"); !ok { 112 | return nil, false, errors.New("invalid color") 113 | } 114 | ttl.SetStrokeColor(bw) 115 | txt.SetStrokeColor(bw) 116 | ttl.SetStrokeWidth(1) 117 | txt.SetStrokeWidth(0.8) 118 | ttl.SetFillColor(pw) 119 | txt.SetFillColor(pw) 120 | ttl.SetFontSize(ttlfs) 121 | txt.SetFontSize(txtfs) 122 | ttl.SetGravity(imagick.GRAVITY_CENTER) 123 | txt.SetGravity(imagick.GRAVITY_CENTER) 124 | ttl.Annotation(0, 0, "L G T M") 125 | txt.Annotation(0, ttlfs/1.5, "L o o k s G o o d T o M e") 126 | 127 | aw := tmp.CoalesceImages() 128 | defer aw.Destroy() 129 | 130 | mw := imagick.NewMagickWand() 131 | mw.SetImageDelay(tmp.GetImageDelay()) 132 | defer mw.Destroy() 133 | 134 | for i := 0; i < int(aw.GetNumberImages()); i++ { 135 | aw.SetIteratorIndex(i) 136 | img := aw.GetImage() 137 | img.AdaptiveResizeImage(uint(dw), uint(dh)) 138 | if err := img.DrawImage(ttl); err != nil { 139 | return nil, false, errors.WithStack(err) 140 | } 141 | if err := img.DrawImage(txt); err != nil { 142 | return nil, false, errors.WithStack(err) 143 | } 144 | if err := mw.AddImage(img); err != nil { 145 | return nil, false, errors.WithStack(err) 146 | } 147 | img.Destroy() 148 | } 149 | 150 | return mw.GetImagesBlob(), true, nil 151 | } 152 | 153 | func (g *LGTMGenerator) calcImageSize(w, h float64) (float64, float64) { 154 | if w > h { 155 | return maxSideLength, maxSideLength / w * h 156 | } 157 | return maxSideLength / h * w, maxSideLength 158 | } 159 | 160 | func (g *LGTMGenerator) calcFontSize(w, h float64) (float64, float64) { 161 | return math.Min(h/2, w/6), math.Min(h/9, w/27) 162 | } 163 | -------------------------------------------------------------------------------- /backend/pkg/infrastructures/router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/koki-develop/lgtm-generator/backend/pkg/controllers" 9 | "github.com/koki-develop/lgtm-generator/backend/pkg/infrastructures/dynamodb" 10 | "github.com/koki-develop/lgtm-generator/backend/pkg/infrastructures/imagesearch" 11 | "github.com/koki-develop/lgtm-generator/backend/pkg/infrastructures/lgtmgen" 12 | "github.com/koki-develop/lgtm-generator/backend/pkg/infrastructures/s3" 13 | "github.com/koki-develop/lgtm-generator/backend/pkg/repositories" 14 | "github.com/slack-go/slack" 15 | ) 16 | 17 | func New() *gin.Engine { 18 | r := gin.New() 19 | 20 | engine := imagesearch.New(os.Getenv("GOOGLE_API_KEY"), os.Getenv("GOOGLE_CUSTOM_SEARCH_ENGINE_ID")) 21 | slackClient := slack.New(os.Getenv("SLACK_API_TOKEN")) 22 | db := dynamodb.New() 23 | g := lgtmgen.New() 24 | bucket := fmt.Sprintf("lgtm-generator-backend-%s-images", os.Getenv("STAGE")) 25 | s3client := s3.New(bucket) 26 | 27 | // middleware 28 | { 29 | logger := controllers.NewLoggerMiddleware() 30 | errresp := controllers.NewErrorResponseLoggerMiddleware(slackClient, fmt.Sprintf("lgtm-generator-backend-%s-errors", os.Getenv("STAGE"))) 31 | cors := controllers.NewCORSMiddleware(os.Getenv("ALLOW_ORIGIN")) 32 | 33 | r.Use(gin.Recovery()) 34 | r.Use(logger.Apply()) 35 | r.Use(errresp.Apply) 36 | r.Use(cors.Apply) 37 | } 38 | 39 | // health 40 | { 41 | ctrl := controllers.NewHealthController() 42 | r.GET("/h", ctrl.Standard) 43 | r.GET("/v1/h", ctrl.Standard) 44 | } 45 | 46 | v1 := r.Group("/v1") 47 | 48 | // images 49 | { 50 | ctrl := controllers.NewImagesController(engine) 51 | v1.GET("/images", ctrl.Search) 52 | } 53 | 54 | // lgtms 55 | { 56 | repo := repositories.NewLGTMsRepository(s3client, db) 57 | ctrl := controllers.NewLGTMsController(g, slackClient, fmt.Sprintf("lgtm-generator-backend-%s-lgtms", os.Getenv("STAGE")), repo) 58 | v1.GET("/lgtms", ctrl.FindAll) 59 | v1.POST("/lgtms", ctrl.Create) 60 | } 61 | 62 | // reports 63 | { 64 | repo := repositories.NewReportsRepository(db, fmt.Sprintf("lgtm-generator-backend-%s", os.Getenv("STAGE"))) 65 | ctrl := controllers.NewReportsController(slackClient, fmt.Sprintf("lgtm-generator-backend-%s-reports", os.Getenv("STAGE")), repo) 66 | v1.POST("/reports", ctrl.Create) 67 | } 68 | 69 | { 70 | rdr := controllers.NewRenderer() 71 | r.NoRoute(rdr.NotFound) 72 | } 73 | 74 | return r 75 | } 76 | -------------------------------------------------------------------------------- /backend/pkg/infrastructures/s3/s3.go: -------------------------------------------------------------------------------- 1 | package s3 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | 7 | "github.com/aws/aws-sdk-go/aws" 8 | "github.com/aws/aws-sdk-go/aws/credentials" 9 | "github.com/aws/aws-sdk-go/aws/session" 10 | "github.com/aws/aws-sdk-go/service/s3" 11 | "github.com/aws/aws-sdk-go/service/s3/s3iface" 12 | "github.com/aws/aws-sdk-go/service/s3/s3manager" 13 | "github.com/aws/aws-sdk-go/service/s3/s3manager/s3manageriface" 14 | "github.com/pkg/errors" 15 | ) 16 | 17 | type ClientAPI interface { 18 | List() ([]string, error) 19 | Put(key, contentType string, data []byte) error 20 | Delete(key string) error 21 | } 22 | 23 | type Client struct { 24 | api s3iface.S3API 25 | uploader s3manageriface.UploaderAPI 26 | bucket string 27 | } 28 | 29 | func New(bucket string) *Client { 30 | awscfg := &aws.Config{Region: aws.String("us-east-1")} 31 | if os.Getenv("STAGE") == "local" { 32 | awscfg.Endpoint = aws.String("http://localhost:9000") 33 | awscfg.Credentials = credentials.NewStaticCredentials("DUMMY_AWS_ACCESS_KEY_ID", "DUMMY_AWS_SECRET_ACCESS_KEY", "") 34 | awscfg.S3ForcePathStyle = aws.Bool(true) 35 | } 36 | sess := session.Must(session.NewSession(awscfg)) 37 | 38 | return &Client{ 39 | api: s3.New(sess), 40 | uploader: s3manager.NewUploader(sess), 41 | bucket: bucket, 42 | } 43 | } 44 | 45 | func (cl *Client) List() ([]string, error) { 46 | resp, err := cl.api.ListObjectsV2(&s3.ListObjectsV2Input{ 47 | Bucket: aws.String(cl.bucket), 48 | }) 49 | if err != nil { 50 | return nil, errors.WithStack(err) 51 | } 52 | 53 | var keys []string 54 | 55 | for _, c := range resp.Contents { 56 | keys = append(keys, *c.Key) 57 | } 58 | 59 | return keys, nil 60 | } 61 | 62 | func (cl *Client) Put(key, contentType string, data []byte) error { 63 | _, err := cl.uploader.Upload(&s3manager.UploadInput{ 64 | Bucket: aws.String(cl.bucket), 65 | Key: aws.String(key), 66 | ContentType: aws.String(contentType), 67 | Body: bytes.NewReader(data), 68 | }) 69 | if err != nil { 70 | return errors.WithStack(err) 71 | } 72 | return nil 73 | } 74 | 75 | func (cl *Client) Delete(key string) error { 76 | _, err := cl.api.DeleteObject(&s3.DeleteObjectInput{ 77 | Bucket: aws.String(cl.bucket), 78 | Key: aws.String(key), 79 | }) 80 | if err != nil { 81 | return errors.WithStack(err) 82 | } 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /backend/pkg/repositories/lgtms_repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" 9 | "github.com/guregu/dynamo" 10 | "github.com/koki-develop/lgtm-generator/backend/pkg/entities" 11 | "github.com/koki-develop/lgtm-generator/backend/pkg/infrastructures/s3" 12 | "github.com/koki-develop/lgtm-generator/backend/pkg/utils" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | type LGTMsRepository struct { 17 | S3API s3.ClientAPI 18 | DynamoDB *dynamo.DB 19 | DBPrefix string 20 | } 21 | 22 | func NewLGTMsRepository(s3api s3.ClientAPI, db *dynamo.DB) *LGTMsRepository { 23 | return &LGTMsRepository{ 24 | S3API: s3api, 25 | DynamoDB: db, 26 | DBPrefix: fmt.Sprintf("lgtm-generator-backend-%s", os.Getenv("STAGE")), 27 | } 28 | } 29 | 30 | func (repo *LGTMsRepository) Find(id string) (*entities.LGTM, bool, error) { 31 | var lgtms entities.LGTMs 32 | 33 | tbl := repo.getTable() 34 | if err := tbl.Get("id", id).All(&lgtms); err != nil { 35 | return nil, false, errors.WithStack(err) 36 | } 37 | if len(lgtms) == 0 { 38 | return nil, false, nil 39 | } 40 | 41 | return lgtms[0], true, nil 42 | } 43 | 44 | func (repo *LGTMsRepository) FindAll() (entities.LGTMs, error) { 45 | lgtms := entities.LGTMs{} 46 | 47 | tbl := repo.getTable() 48 | q := tbl.Get("status", entities.LGTMStatusOK).Index("index_by_status").Order(dynamo.Descending).Limit(20) 49 | if err := q.All(&lgtms); err != nil { 50 | return nil, errors.WithStack(err) 51 | } 52 | 53 | return lgtms, nil 54 | } 55 | 56 | func (repo *LGTMsRepository) FindRandomly() (entities.LGTMs, error) { 57 | keys, err := repo.S3API.List() 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | utils.Shuffle(keys) 63 | if len(keys) > 20 { 64 | keys = keys[:20] 65 | } 66 | 67 | lgtms := entities.LGTMs{} 68 | for _, k := range keys { 69 | lgtms = append(lgtms, &entities.LGTM{ID: k}) 70 | } 71 | return lgtms, nil 72 | } 73 | 74 | func (repo *LGTMsRepository) FindAllAfter(lgtm *entities.LGTM) (entities.LGTMs, error) { 75 | key, err := dynamodbattribute.MarshalMap(lgtm) 76 | if err != nil { 77 | return nil, errors.WithStack(err) 78 | } 79 | 80 | lgtms := entities.LGTMs{} 81 | tbl := repo.getTable() 82 | q := tbl.Get("status", entities.LGTMStatusOK).Index("index_by_status").Order(dynamo.Descending).Limit(20).StartFrom(key) 83 | if err := q.All(&lgtms); err != nil { 84 | return nil, errors.WithStack(err) 85 | } 86 | 87 | return lgtms, nil 88 | } 89 | 90 | func (repo *LGTMsRepository) Create(img *entities.LGTMImage) (*entities.LGTM, error) { 91 | now := time.Now() 92 | id := utils.UUIDV4() 93 | 94 | lgtm := &entities.LGTM{ID: id, Status: entities.LGTMStatusPending, CreatedAt: now} 95 | 96 | tbl := repo.getTable() 97 | if err := tbl.Put(&lgtm).Run(); err != nil { 98 | return nil, errors.WithStack(err) 99 | } 100 | 101 | if err := repo.S3API.Put(lgtm.ID, img.ContentType, img.Data); err != nil { 102 | return nil, err 103 | } 104 | 105 | lgtm.Status = entities.LGTMStatusOK 106 | upd := tbl.Update("id", lgtm.ID).Range("created_at", lgtm.CreatedAt) 107 | if err := upd.Set("status", lgtm.Status).Run(); err != nil { 108 | return nil, errors.WithStack(err) 109 | } 110 | 111 | return lgtm, nil 112 | } 113 | 114 | func (repo *LGTMsRepository) Delete(id string) error { 115 | lgtm, ok, err := repo.Find(id) 116 | if err != nil { 117 | return err 118 | } 119 | if !ok { 120 | return errors.Errorf("lgtm not found: %s", id) 121 | } 122 | 123 | tbl := repo.getTable() 124 | upd := tbl.Update("id", id).Range("created_at", lgtm.CreatedAt).Set("status", entities.LGTMStatusDeleting) 125 | if err := upd.Run(); err != nil { 126 | return errors.WithStack(err) 127 | } 128 | if err := repo.S3API.Delete(id); err != nil { 129 | return err 130 | } 131 | if err := tbl.Delete("id", id).Range("created_at", lgtm.CreatedAt).Run(); err != nil { 132 | return errors.WithStack(err) 133 | } 134 | 135 | return nil 136 | } 137 | 138 | func (repo *LGTMsRepository) getTable() dynamo.Table { 139 | return repo.DynamoDB.Table(fmt.Sprintf("%s-lgtms", repo.DBPrefix)) 140 | } 141 | -------------------------------------------------------------------------------- /backend/pkg/repositories/reports_repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/guregu/dynamo" 8 | "github.com/koki-develop/lgtm-generator/backend/pkg/entities" 9 | "github.com/koki-develop/lgtm-generator/backend/pkg/utils" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type ReportsRepository struct { 14 | DynamoDB *dynamo.DB 15 | DBPrefix string 16 | } 17 | 18 | func NewReportsRepository(db *dynamo.DB, dbPrefix string) *ReportsRepository { 19 | return &ReportsRepository{DynamoDB: db, DBPrefix: dbPrefix} 20 | } 21 | 22 | func (repo *ReportsRepository) Create(lgtmid string, t entities.ReportType, text string) (*entities.Report, error) { 23 | rpt := &entities.Report{ 24 | ID: utils.UUIDV4(), 25 | LGTMID: lgtmid, 26 | Type: t, 27 | Text: text, 28 | CreatedAt: time.Now(), 29 | } 30 | 31 | tbl := repo.getTable() 32 | if err := tbl.Put(rpt).Run(); err != nil { 33 | return nil, errors.WithStack(err) 34 | } 35 | 36 | return rpt, nil 37 | } 38 | 39 | func (repo *ReportsRepository) getTable() dynamo.Table { 40 | return repo.DynamoDB.Table(fmt.Sprintf("%s-reports", repo.DBPrefix)) 41 | } 42 | -------------------------------------------------------------------------------- /backend/pkg/static/fonts/Archivo_Black/ArchivoBlack-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koki-develop/lgtm-generator/9296acdaf9a58f0aa825915eaaec7f336aa0f53a/backend/pkg/static/fonts/Archivo_Black/ArchivoBlack-Regular.ttf -------------------------------------------------------------------------------- /backend/pkg/static/fonts/Archivo_Black/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2017 The Archivo Black Project Authors (https://github.com/Omnibus-Type/ArchivoBlack) 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /backend/pkg/utils/base64.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/base64" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | func IsBase64(str string) bool { 10 | _, err := base64.StdEncoding.DecodeString(str) 11 | return err == nil 12 | } 13 | 14 | func Base64Decode(str string) ([]byte, error) { 15 | b, err := base64.StdEncoding.DecodeString(str) 16 | if err != nil { 17 | return nil, errors.WithStack(err) 18 | } 19 | return b, nil 20 | } 21 | -------------------------------------------------------------------------------- /backend/pkg/utils/slice.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | func Shuffle[T any](s []T) { 9 | rand.Seed(time.Now().UnixNano()) 10 | rand.Shuffle(len(s), func(i, j int) { s[i], s[j] = s[j], s[i] }) 11 | } 12 | -------------------------------------------------------------------------------- /backend/pkg/utils/url.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "net/url" 4 | 5 | func IsHTTPSURL(href string) bool { 6 | u, err := url.ParseRequestURI(href) 7 | if err != nil { 8 | return false 9 | } 10 | return u.Scheme == "https" 11 | } 12 | 13 | func IsURL(str string) bool { 14 | _, err := url.ParseRequestURI(str) 15 | return err == nil 16 | } 17 | -------------------------------------------------------------------------------- /backend/pkg/utils/uuid.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | ) 6 | 7 | func UUIDV4() string { 8 | return uuid.New().String() 9 | } 10 | -------------------------------------------------------------------------------- /backend/serverless.yml: -------------------------------------------------------------------------------- 1 | service: lgtm-generator-backend 2 | 3 | frameworkVersion: "3" 4 | useDotenv: true 5 | configValidationMode: error 6 | 7 | custom: 8 | product: ${self:service} 9 | prefix: ${self:custom.product}-${self:provider.stage} 10 | allowOrigin: 11 | local: http://localhost:3000 12 | dev: https://lgtm-generator-*-koki-develop.vercel.app 13 | prod: https://www.lgtmgen.org 14 | imageBaseUrl: 15 | local: http://localhost:9000/lgtm-generator-backend-local-images 16 | dev: https://dev.images.lgtmgen.org 17 | prod: https://images.lgtmgen.org 18 | 19 | provider: 20 | name: aws 21 | region: us-east-1 22 | stage: ${opt:stage, "dev"} 23 | logs: 24 | restApi: true 25 | iam: 26 | role: 27 | statements: 28 | - Effect: Allow 29 | Action: 30 | - dynamodb:Query 31 | - dynamodb:PutItem 32 | - dynamodb:UpdateItem 33 | - dynamodb:DeleteItem 34 | Resource: 35 | - Fn::Join: 36 | - ":" 37 | - - arn:aws:dynamodb 38 | - Ref: AWS::Region 39 | - Ref: AWS::AccountId 40 | - table/${self:custom.prefix}-* 41 | - Effect: Allow 42 | Action: 43 | - s3:PutObject 44 | - s3:DeleteObject 45 | - s3:ListBucket 46 | Resource: 47 | - Fn::Join: 48 | - "" 49 | - - "arn:aws:s3:::" 50 | - ${self:custom.prefix}-images 51 | - Fn::Join: 52 | - "" 53 | - - "arn:aws:s3:::" 54 | - ${self:custom.prefix}-images 55 | - /* 56 | ecr: 57 | images: 58 | appimage: 59 | path: ./ 60 | file: ./containers/app/Dockerfile 61 | apiName: ${self:custom.prefix} 62 | environment: 63 | STAGE: ${self:provider.stage} 64 | ALLOW_ORIGIN: ${self:custom.allowOrigin.${self:provider.stage}} 65 | IMAGES_BASE_URL: ${self:custom.imageBaseUrl.${self:provider.stage}} 66 | SLACK_API_TOKEN: ${env:SLACK_API_TOKEN} 67 | GOOGLE_API_KEY: ${env:GOOGLE_API_KEY} 68 | GOOGLE_CUSTOM_SEARCH_ENGINE_ID: ${env:GOOGLE_CUSTOM_SEARCH_ENGINE_ID} 69 | 70 | package: 71 | individually: true 72 | 73 | functions: 74 | api: 75 | image: 76 | name: appimage 77 | entryPoint: 78 | - "/var/task/build/api" 79 | timeout: 30 80 | events: 81 | # health check 82 | - http: 83 | method: get 84 | path: /h 85 | - http: 86 | method: get 87 | path: /v1/h 88 | 89 | # images 90 | - http: 91 | method: options 92 | path: /v1/images 93 | - http: 94 | method: get 95 | path: /v1/images 96 | request: 97 | parameters: 98 | querystrings: 99 | q: true 100 | 101 | # lgtms 102 | - http: 103 | method: options 104 | path: /v1/lgtms 105 | - http: 106 | method: get 107 | path: /v1/lgtms 108 | request: 109 | parameters: 110 | querystrings: 111 | after: false 112 | - http: 113 | method: post 114 | path: /v1/lgtms 115 | # reports 116 | - http: 117 | method: options 118 | path: /v1/reports 119 | - http: 120 | method: post 121 | path: /v1/reports 122 | deletelgtm: 123 | image: 124 | name: appimage 125 | entryPoint: 126 | - "/var/task/build/deletelgtm" 127 | timeout: 60 128 | 129 | resources: 130 | Resources: 131 | LgtmsTable: 132 | Type: AWS::DynamoDB::Table 133 | Properties: 134 | TableName: ${self:custom.prefix}-lgtms 135 | BillingMode: PAY_PER_REQUEST 136 | AttributeDefinitions: 137 | - AttributeName: id 138 | AttributeType: S 139 | - AttributeName: created_at 140 | AttributeType: S 141 | - AttributeName: status 142 | AttributeType: S 143 | KeySchema: 144 | - AttributeName: id 145 | KeyType: HASH 146 | - AttributeName: created_at 147 | KeyType: RANGE 148 | GlobalSecondaryIndexes: 149 | - IndexName: index_by_status 150 | KeySchema: 151 | - AttributeName: status 152 | KeyType: HASH 153 | - AttributeName: created_at 154 | KeyType: RANGE 155 | Projection: 156 | ProjectionType: ALL 157 | ReportsTable: 158 | Type: AWS::DynamoDB::Table 159 | Properties: 160 | TableName: ${self:custom.prefix}-reports 161 | BillingMode: PAY_PER_REQUEST 162 | AttributeDefinitions: 163 | - AttributeName: id 164 | AttributeType: S 165 | - AttributeName: created_at 166 | AttributeType: S 167 | KeySchema: 168 | - AttributeName: id 169 | KeyType: HASH 170 | - AttributeName: created_at 171 | KeyType: RANGE 172 | GatewayResponseMissingAuthenticationToken: 173 | Type: AWS::ApiGateway::GatewayResponse 174 | Properties: 175 | ResponseType: MISSING_AUTHENTICATION_TOKEN 176 | RestApiId: 177 | Ref: ApiGatewayRestApi 178 | StatusCode: "404" 179 | ResponseTemplates: 180 | application/json: '{"code":"NOT_FOUND"}' 181 | -------------------------------------------------------------------------------- /docs/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koki-develop/lgtm-generator/9296acdaf9a58f0aa825915eaaec7f336aa0f53a/docs/architecture.png -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # アーキテクチャ 2 | 3 | ![](./architecture.png) 4 | 5 | # 開発環境構築 6 | 7 | ```sh 8 | git clone git@github.com:koki-develop/lgtm-generator.git 9 | cd lgtm-generator 10 | ``` 11 | 12 | ## バックエンド 13 | 14 | ```sh 15 | cd backend 16 | ``` 17 | 18 | ### `.env` を作成 19 | 20 | ``` 21 | cp .env.template .env 22 | direnv allow 23 | ``` 24 | 25 | ```sh 26 | # .env 27 | STAGE=local 28 | ALLOW_ORIGIN=http://localhost:3000 29 | IMAGES_BASE_URL=http://localhost:9000/lgtm-generator-backend-local-images 30 | SLACK_API_TOKEN=xxxx # Slack API のアクセストークン 31 | GOOGLE_API_KEY=xxxx # GCP で発行した API キー 32 | GOOGLE_CUSTOM_SEARCH_ENGINE_ID=xxxx # Google カスタム検索エンジン ID 33 | ``` 34 | 35 | ### minio, dynamodb を起動 36 | 37 | ```sh 38 | docker compose up 39 | aws --endpoint-url http://localhost:8000 dynamodb create-table --cli-input-json "$(yarn run --silent sls print --stage local | yq '.resources.Resources.LgtmsTable.Properties' -o json)" 40 | aws --endpoint-url http://localhost:8000 dynamodb create-table --cli-input-json "$(yarn run --silent sls print --stage local | yq '.resources.Resources.ReportsTable.Properties' -o json)" 41 | ``` 42 | 43 | ### API を起動 44 | 45 | ```sh 46 | air 47 | ``` 48 | 49 | ### デプロイ 50 | 51 | ``` 52 | yarn install --check-files 53 | yarn run deploy 54 | ``` 55 | 56 | ## フロントエンド 57 | 58 | ``` 59 | cd frontend 60 | ``` 61 | 62 | ### `.env` を作成 63 | 64 | ```sh 65 | cp .env.template .env 66 | ``` 67 | 68 | ### 依存パッケージをインストール 69 | 70 | ``` 71 | yarn install --check-files 72 | ``` 73 | 74 | ### ローカルで起動 75 | 76 | ``` 77 | yarn run dev 78 | ``` 79 | -------------------------------------------------------------------------------- /e2e/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | 3 | /cypress/screenshots/ 4 | /cypress/videos/ 5 | -------------------------------------------------------------------------------- /e2e/cypress.dev.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | defaultCommandTimeout: 30000, 5 | e2e: { 6 | baseUrl: "https://lgtm-generator-git-develop-koki-develop.vercel.app", 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /e2e/cypress.local.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | defaultCommandTimeout: 30000, 5 | e2e: { 6 | baseUrl: "http://localhost:3000", 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /e2e/cypress/e2e/frontend/pages/home.cy.ts: -------------------------------------------------------------------------------- 1 | const randomSearchKeyword = (): string => { 2 | const keywords: string[] = ["cat", "sheep", "hamster"]; 3 | return keywords[Math.floor(Math.random() * keywords.length)]; 4 | }; 5 | 6 | describe("/", () => { 7 | before(() => { 8 | cy.visit("/"); 9 | }); 10 | beforeEach(() => { 11 | cy.window().then((window) => { 12 | cy.stub(window, "prompt").returns("DISABLED WINDOW PROMPT"); 13 | }); 14 | }); 15 | 16 | const locales: ("ja" | "en")[] = ["ja", "en"]; 17 | for (const locale of locales) { 18 | describe(`locale: ${locale}`, () => { 19 | before(() => { 20 | cy.getByTestId("translate-open-button").click(); 21 | cy.getByTestId(`translate-list-item-${locale}`).click(); 22 | cy.pathname().should("equal", locale === "ja" ? "/" : `/${locale}`); 23 | }); 24 | 25 | describe("LGTM タブ", () => { 26 | before(() => { 27 | cy.getByTestId("home-tab-lgtms").click(); 28 | cy.search().should("equal", ""); 29 | }); 30 | 31 | describe("アップロード", () => { 32 | describe("対応している画像フォーマットの場合", () => { 33 | beforeEach(() => { 34 | cy.getByTestId("upload-file-input").attachFile("images/gray.png"); 35 | cy.getByTestId("lgtm-form-generate-button").click(); 36 | }); 37 | it("LGTM 画像を生成した旨を表示すること", () => { 38 | cy.contains( 39 | { ja: "LGTM 画像を生成しました", en: "Generated LGTM image." }[ 40 | locale 41 | ] 42 | ); 43 | }); 44 | }); 45 | }); 46 | 47 | describe("リンクコピー", () => { 48 | beforeEach(() => { 49 | cy.getByTestId("lgtm-card-copy-button").first().click(); 50 | }); 51 | describe("Markdown", () => { 52 | beforeEach(() => { 53 | cy.getByTestId("lgtm-card-copy-markdown-button").click(); 54 | }); 55 | it("クリップボードに Markdown 形式のリンクをコピーすること"); // TODO 56 | it("クリップボードにコピーした旨を表示すること", () => { 57 | cy.contains( 58 | { 59 | ja: "クリップボードにコピーしました", 60 | en: "Copied to clipboard", 61 | }[locale] 62 | ); 63 | }); 64 | }); 65 | describe("HTML", () => { 66 | beforeEach(() => { 67 | cy.getByTestId("lgtm-card-copy-html-button").click(); 68 | }); 69 | it("クリップボードに HTML 形式のリンクをコピーすること"); // TODO 70 | it("クリップボードにコピーした旨を表示すること", () => { 71 | cy.contains( 72 | { 73 | ja: "クリップボードにコピーしました", 74 | en: "Copied to clipboard", 75 | }[locale] 76 | ); 77 | }); 78 | }); 79 | }); 80 | 81 | describe("通報", () => { 82 | beforeEach(() => { 83 | cy.getByTestId("lgtm-card-report-button").first().click(); 84 | }); 85 | describe("種類を選択している場合", () => { 86 | beforeEach(() => { 87 | cy.getByTestId("report-form-type-radio-other").click(); 88 | cy.getByTestId("report-form-text-input").type("e2e test"); 89 | cy.getByTestId("report-form-send-button").click(); 90 | }); 91 | it("送信した旨を表示すること", () => { 92 | cy.contains( 93 | { 94 | ja: "送信しました", 95 | en: "Sent.", 96 | }[locale] 97 | ); 98 | }); 99 | }); 100 | describe("種類を選択していない場合", () => { 101 | afterEach(() => { 102 | cy.getByTestId("report-form-cancel-button").click(); 103 | }); 104 | it("送信ボタンをクリックできないこと", () => { 105 | cy.getByTestId("report-form-send-button").should("be.disabled"); 106 | }); 107 | }); 108 | }); 109 | }); 110 | 111 | describe("画像検索タブ", () => { 112 | before(() => { 113 | cy.getByTestId("home-tab-search-images").click(); 114 | cy.search().should("equal", "?tab=search_images"); 115 | }); 116 | 117 | describe("LGTM 画像生成", () => { 118 | beforeEach(() => { 119 | cy.getByTestId("search-images-keyword-input") 120 | .clear() 121 | .type(randomSearchKeyword()) 122 | .enter(); 123 | cy.getByTestId("image-card-action-area").first().click(); 124 | cy.getByTestId("lgtm-form-generate-button").click(); 125 | }); 126 | it("LGTM 画像を生成した旨を表示すること", () => { 127 | cy.contains( 128 | { 129 | ja: "LGTM 画像を生成しました", 130 | en: "Generated LGTM image.", 131 | }[locale] 132 | ); 133 | }); 134 | }); 135 | }); 136 | 137 | describe("お気に入りタブ", () => { 138 | before(() => { 139 | cy.getByTestId("home-tab-favorites").click(); 140 | cy.search().should("equal", "?tab=favorites"); 141 | }); 142 | 143 | describe("お気に入り追加・解除", () => { 144 | it("正しく動作すること", async () => { 145 | cy.getByTestId("no-favorites-text").first().contains( 146 | { 147 | ja: "お気に入りした LGTM 画像はありません。", 148 | en: "There are no favorites yet.", 149 | }[locale] 150 | ); 151 | // お気に入りを追加 152 | cy.getByTestId("home-tab-lgtms").click(); 153 | cy.getByTestId("lgtm-card-favorite-button").first().click(); 154 | cy.getByTestId("home-tab-favorites").click(); 155 | cy.getByTestId("no-favorites-text").should("not.exist"); 156 | 157 | // お気に入りを解除 158 | cy.getByTestId("lgtm-card-unfavorite-button") 159 | .visible() 160 | .first() 161 | .click(); 162 | await new Promise((resolve) => setTimeout(resolve, 1000)); 163 | cy.getByTestId("no-favorites-text").should("not.exist"); // 解除直後はまだメッセージが表示されないことを検証 164 | cy.getByTestId("home-tab-lgtms").click(); 165 | cy.getByTestId("home-tab-favorites").click(); 166 | cy.getByTestId("no-favorites-text").should("exist"); 167 | }); 168 | }); 169 | }); 170 | }); 171 | } 172 | }); 173 | -------------------------------------------------------------------------------- /e2e/cypress/fixtures/images/gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koki-develop/lgtm-generator/9296acdaf9a58f0aa825915eaaec7f336aa0f53a/e2e/cypress/fixtures/images/gray.png -------------------------------------------------------------------------------- /e2e/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | 27 | import "cypress-file-upload"; 28 | 29 | Cypress.Commands.add("getByTestId", (id: string) => { 30 | return cy.get(`[data-testid='${id}']`); 31 | }); 32 | 33 | Cypress.Commands.add( 34 | "findByTestId", 35 | { prevSubject: "element" }, 36 | (subject, id: string) => { 37 | return cy.wrap(subject).find(`[data-testid='${id}']`); 38 | } 39 | ); 40 | 41 | Cypress.Commands.add("pathname", () => { 42 | return cy.url().then((url) => { 43 | return cy.wrap(new URL(url).pathname); 44 | }); 45 | }); 46 | 47 | Cypress.Commands.add("search", () => { 48 | return cy.url().then((url) => { 49 | return cy.wrap(new URL(url).search); 50 | }); 51 | }); 52 | 53 | Cypress.Commands.add("enter", { prevSubject: "element" }, (subject) => { 54 | return cy.wrap(subject).type("{enter}"); 55 | }); 56 | 57 | Cypress.Commands.add("visible", { prevSubject: "element" }, (subject) => { 58 | return cy.wrap(subject).filter(":visible"); 59 | }); 60 | -------------------------------------------------------------------------------- /e2e/cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | declare global { 23 | namespace Cypress { 24 | interface Chainable { 25 | getByTestId(id: string): Chainable>; 26 | findByTestId(id: string): Chainable>; 27 | pathname(): Chainable; 28 | search(): Chainable; 29 | enter(): Chainable>; 30 | visible(): Chainable>; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lgtm-generator-e2e", 3 | "license": "MIT", 4 | "private": true, 5 | "scripts": { 6 | "open:local": "cypress open --config-file cypress.local.config.ts", 7 | "open:dev": "cypress open --config-file cypress.dev.config.ts", 8 | "start:local": "cypress run --config-file cypress.local.config.ts", 9 | "start:dev": "cypress run --config-file cypress.dev.config.ts" 10 | }, 11 | "devDependencies": { 12 | "@types/node": "18.16.18", 13 | "cypress": "10.11.0", 14 | "cypress-file-upload": "5.0.8", 15 | "prettier": "2.8.8", 16 | "typescript": "4.9.5" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | "lib": ["dom","es6"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "commonjs", /* Specify what module code is generated. */ 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | "types": ["cypress", "cypress-file-upload"], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 75 | 76 | /* Type Checking */ 77 | "strict": true, /* Enable all strict type-checking options. */ 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /frontend/.env.template: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_STAGE=local 2 | NEXT_PUBLIC_API_ORIGIN=http://localhost:8080 3 | NEXT_PUBLIC_LGTMS_ORIGIN=http://localhost:9000/lgtm-generator-backend-local-images 4 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | settings: { 4 | react: { 5 | version: 'detect', 6 | }, 7 | }, 8 | env: { 9 | browser: true, 10 | es2021: true, 11 | node: true, 12 | }, 13 | extends: [ 14 | 'eslint:recommended', 15 | 'plugin:react/recommended', 16 | 'plugin:react-hooks/recommended', 17 | 'plugin:import/recommended', 18 | 'prettier', 19 | ], 20 | parserOptions: { 21 | ecmaFeatures: { 22 | jsx: true, 23 | }, 24 | ecmaVersion: 12, 25 | sourceType: 'module', 26 | }, 27 | plugins: ['react', 'react-hooks', 'import', 'unused-imports'], 28 | rules: { 29 | 'react/prop-types': 'off', 30 | semi: ['error', 'always'], 31 | 'comma-dangle': ['error', 'always-multiline'], 32 | quotes: ['error', 'single'], 33 | 'object-curly-spacing': ['error', 'always'], 34 | 'react/jsx-tag-spacing': ['error'], 35 | 'unused-imports/no-unused-imports': 'error', 36 | 'import/order': [ 37 | 'error', 38 | { 39 | groups: [ 40 | 'builtin', 41 | 'external', 42 | 'internal', 43 | ['parent', 'sibling'], 44 | 'object', 45 | 'type', 46 | 'index', 47 | ], 48 | pathGroupsExcludedImportTypes: ['builtin'], 49 | alphabetize: { order: 'asc', caseInsensitive: true }, 50 | pathGroups: [ 51 | { 52 | pattern: '@/components/App/**', 53 | group: 'internal', 54 | position: 'before', 55 | }, 56 | { 57 | pattern: '@/components/Layout/**', 58 | group: 'internal', 59 | position: 'before', 60 | }, 61 | { 62 | pattern: '@/components/pages/**', 63 | group: 'internal', 64 | position: 'before', 65 | }, 66 | { 67 | pattern: '@/components/providers/**', 68 | group: 'internal', 69 | position: 'before', 70 | }, 71 | { 72 | pattern: '@/components/model/**', 73 | group: 'internal', 74 | position: 'before', 75 | }, 76 | { 77 | pattern: '@/components/utils/**', 78 | group: 'internal', 79 | position: 'before', 80 | }, 81 | { 82 | pattern: '@/hooks/**', 83 | group: 'internal', 84 | position: 'before', 85 | }, 86 | { pattern: '@/lib/**', group: 'internal', position: 'before' }, 87 | { pattern: '@/recoil/**', group: 'internal', position: 'before' }, 88 | { pattern: '@/types/**', group: 'internal', position: 'before' }, 89 | { pattern: '@/locales/**', group: 'internal', position: 'before' }, 90 | { pattern: '@/routes', group: 'internal', position: 'before' }, 91 | { pattern: '@/styles/**', group: 'internal', position: 'before' }, 92 | ], 93 | }, 94 | ], 95 | }, 96 | overrides: [ 97 | { 98 | files: ['*.ts', '*.tsx'], 99 | settings: { 100 | 'import/resolver': { 101 | typescript: { 102 | alwaysTryTypes: true, 103 | project: './', 104 | }, 105 | }, 106 | }, 107 | extends: ['plugin:@typescript-eslint/recommended'], 108 | plugins: ['@typescript-eslint'], 109 | parser: '@typescript-eslint/parser', 110 | rules: { 111 | '@typescript-eslint/no-unused-vars': [ 112 | 'error', 113 | { argsIgnorePattern: '^_' }, 114 | ], 115 | }, 116 | }, 117 | ], 118 | }; 119 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /.next/ 3 | /.env 4 | 5 | /public/robots.txt 6 | /public/sitemap*.xml 7 | 8 | # Created by https://www.toptal.com/developers/gitignore/api/terraform 9 | # Edit at https://www.toptal.com/developers/gitignore?templates=terraform 10 | 11 | ### Terraform ### 12 | # Local .terraform directories 13 | **/.terraform/* 14 | 15 | # .tfstate files 16 | *.tfstate 17 | *.tfstate.* 18 | 19 | # Crash log files 20 | crash.log 21 | 22 | # Exclude all .tfvars files, which are likely to contain sentitive data, such as 23 | # password, private keys, and other secrets. These should not be part of version 24 | # control as they are data points which are potentially sensitive and subject 25 | # to change depending on the environment. 26 | # 27 | *.tfvars 28 | 29 | # Ignore override files as they are usually used to override resources locally and so 30 | # are not checked in 31 | override.tf 32 | override.tf.json 33 | *_override.tf 34 | *_override.tf.json 35 | 36 | # Include override files you do wish to add to version control using negated pattern 37 | # !example_override.tf 38 | 39 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 40 | # example: *tfplan* 41 | 42 | # Ignore CLI configuration files 43 | .terraformrc 44 | terraform.rc 45 | 46 | # End of https://www.toptal.com/developers/gitignore/api/terraform 47 | -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | /README.md 2 | /.next/ 3 | /terraform/.terraform/ 4 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "trailingComma": "all", 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /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-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next-sitemap').IConfig} */ 2 | 3 | module.exports = { 4 | siteUrl: 'https://www.lgtmgen.org', 5 | generateRobotsTxt: true, 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | i18n: { 3 | locales: ['ja', 'en'], 4 | defaultLocale: 'ja', 5 | }, 6 | }; 7 | 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lgtm-generator-frontend", 3 | "license": "MIT", 4 | "engines": { 5 | "node": "18.x" 6 | }, 7 | "scripts": { 8 | "dev": "next dev", 9 | "postbuild": "next-sitemap", 10 | "build": "next build", 11 | "lint": "eslint --max-warnings 0 --ext .js,.ts,.tsx .", 12 | "fmt": "yarn lint --fix && prettier --write ." 13 | }, 14 | "dependencies": { 15 | "@emotion/cache": "11.11.0", 16 | "@emotion/react": "11.11.1", 17 | "@emotion/server": "11.11.0", 18 | "@emotion/styled": "11.11.0", 19 | "@fontsource/archivo-black": "^5.0.3", 20 | "@mui/icons-material": "5.11.16", 21 | "@mui/material": "5.13.5", 22 | "@mui/styles": "5.13.2", 23 | "axios": "1.4.0", 24 | "copy-to-clipboard": "3.3.3", 25 | "next": "12.3.4", 26 | "notistack": "2.0.8", 27 | "react": "18.2.0", 28 | "react-dom": "18.2.0", 29 | "recoil": "0.7.7", 30 | "ts-custom-error": "3.3.1", 31 | "url-join": "5.0.0", 32 | "uuid": "9.0.0" 33 | }, 34 | "devDependencies": { 35 | "@types/gtag.js": "0.0.12", 36 | "@types/node": "18.16.18", 37 | "@types/react": "18.2.11", 38 | "@types/url-join": "4.0.1", 39 | "@types/uuid": "9.0.2", 40 | "@typescript-eslint/eslint-plugin": "5.59.9", 41 | "@typescript-eslint/parser": "5.59.9", 42 | "depcheck": "1.4.3", 43 | "eslint": "8.42.0", 44 | "eslint-config-prettier": "8.8.0", 45 | "eslint-import-resolver-typescript": "3.5.5", 46 | "eslint-plugin-import": "2.27.5", 47 | "eslint-plugin-react": "7.32.2", 48 | "eslint-plugin-react-hooks": "4.6.0", 49 | "eslint-plugin-unused-imports": "2.0.0", 50 | "next-sitemap": "3.1.55", 51 | "prettier": "2.8.8", 52 | "sass": "1.63.3", 53 | "typescript": "4.9.5" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /frontend/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import NotFound from '@/components/pages/NotFound'; 2 | 3 | export default NotFound; 4 | -------------------------------------------------------------------------------- /frontend/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import App from '@/components/App'; 2 | import '@fontsource/archivo-black'; 3 | 4 | export default App; 5 | -------------------------------------------------------------------------------- /frontend/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import createEmotionServer from '@emotion/server/create-instance'; 2 | import NextDocument, { 3 | DocumentContext, 4 | Head, 5 | Html, 6 | Main, 7 | NextScript, 8 | } from 'next/document'; 9 | import React from 'react'; 10 | import { createEmotionCache } from '@/lib/emotion'; 11 | 12 | export default class Document extends NextDocument { 13 | render(): JSX.Element { 14 | return ( 15 | 16 | 17 | {process.env.NEXT_PUBLIC_STAGE === 'prod' && ( 18 | <> 19 | {/* Global site tag (gtag.js) - Google Analytics */} 20 | 24 | 35 | 36 | )} 37 | 38 | 39 |
40 | 41 | 42 | 43 | ); 44 | } 45 | } 46 | 47 | Document.getInitialProps = async (ctx: DocumentContext) => { 48 | const originalRenderPage = ctx.renderPage; 49 | 50 | const cache = createEmotionCache(); 51 | const { extractCriticalToChunks } = createEmotionServer(cache); 52 | 53 | ctx.renderPage = () => 54 | originalRenderPage({ 55 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 56 | enhanceApp: (App: any) => 57 | function EnhanceApp(props) { 58 | return ; 59 | }, 60 | }); 61 | 62 | const initialProps = await NextDocument.getInitialProps(ctx); 63 | const emotionStyles = extractCriticalToChunks(initialProps.html); 64 | const emotionStyleTags = emotionStyles.styles.map(style => ( 65 |