├── backend
├── .envrc
├── .dockerignore
├── pkg
│ ├── utils
│ │ ├── uuid.go
│ │ ├── slice.go
│ │ ├── url.go
│ │ └── base64.go
│ ├── static
│ │ └── fonts
│ │ │ └── Archivo_Black
│ │ │ ├── ArchivoBlack-Regular.ttf
│ │ │ └── OFL.txt
│ ├── handlers
│ │ ├── api
│ │ │ ├── local
│ │ │ │ └── main.go
│ │ │ └── lambda
│ │ │ │ └── main.go
│ │ └── deletelgtm
│ │ │ └── main.go
│ ├── entities
│ │ ├── image.go
│ │ ├── report.go
│ │ └── lgtm.go
│ ├── controllers
│ │ ├── health_controller.go
│ │ ├── errcode.go
│ │ ├── images_controller.go
│ │ ├── renderer.go
│ │ ├── cors_middleware.go
│ │ ├── logger_middleware.go
│ │ ├── reports_controller.go
│ │ ├── error_response_logger_middleware.go
│ │ └── lgtms_controller.go
│ ├── infrastructures
│ │ ├── dynamodb
│ │ │ └── dynamodb.go
│ │ ├── imagesearch
│ │ │ └── imagesearch.go
│ │ ├── s3
│ │ │ └── s3.go
│ │ ├── router
│ │ │ └── router.go
│ │ └── lgtmgen
│ │ │ └── lgtmgen.go
│ └── repositories
│ │ ├── reports_repository.go
│ │ └── lgtms_repository.go
├── .env.template
├── package.json
├── .gitignore
├── .air.toml
├── docker-compose.yml
├── containers
│ └── app
│ │ └── Dockerfile
├── go.mod
└── serverless.yml
├── .codeclimate.yml
├── README.md
├── frontend
├── .prettierignore
├── src
│ ├── models
│ │ ├── lgtm.ts
│ │ ├── image.ts
│ │ └── report.ts
│ ├── components
│ │ ├── App
│ │ │ ├── index.tsx
│ │ │ └── App.tsx
│ │ ├── Layout
│ │ │ ├── index.tsx
│ │ │ ├── Layout.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── theme.ts
│ │ │ └── Header.tsx
│ │ ├── pages
│ │ │ ├── Home
│ │ │ │ ├── index.tsx
│ │ │ │ ├── Home.tsx
│ │ │ │ ├── FavoritesPanel.tsx
│ │ │ │ ├── Tabs.tsx
│ │ │ │ ├── UploadButton.tsx
│ │ │ │ ├── SearchImagesPanel.tsx
│ │ │ │ └── LgtmsPanel.tsx
│ │ │ ├── NotFound
│ │ │ │ ├── index.tsx
│ │ │ │ └── NotFound.tsx
│ │ │ ├── Precautions
│ │ │ │ ├── index.tsx
│ │ │ │ └── Precautions.tsx
│ │ │ └── PrivacyPolicy
│ │ │ │ ├── index.tsx
│ │ │ │ └── PrivacyPolicy.tsx
│ │ ├── utils
│ │ │ ├── Field.tsx
│ │ │ ├── Form.tsx
│ │ │ ├── LoadableButton.tsx
│ │ │ ├── Link.tsx
│ │ │ ├── Loading.tsx
│ │ │ ├── Modal.tsx
│ │ │ ├── ModalCard.tsx
│ │ │ └── Meta.tsx
│ │ ├── model
│ │ │ ├── lgtm
│ │ │ │ ├── LgtmCardList.tsx
│ │ │ │ ├── LgtmCard.tsx
│ │ │ │ ├── LgtmForm.tsx
│ │ │ │ └── LgtmCardButtonGroup.tsx
│ │ │ ├── image
│ │ │ │ ├── ImageCardList.tsx
│ │ │ │ └── ImageCard.tsx
│ │ │ └── report
│ │ │ │ └── ReportForm.tsx
│ │ └── providers
│ │ │ └── ToastProvider.tsx
│ ├── lib
│ │ ├── errors.ts
│ │ ├── dataUrl.ts
│ │ ├── emotion.ts
│ │ ├── dataStorage.ts
│ │ ├── apiClient.ts
│ │ └── imageFileReader.ts
│ ├── routes.ts
│ ├── global.d.ts
│ ├── recoil
│ │ └── atoms.ts
│ ├── hooks
│ │ ├── i18n.ts
│ │ ├── translateHooks.ts
│ │ ├── imageHooks.ts
│ │ ├── reportHooks.ts
│ │ └── lgtmHooks.ts
│ └── locales
│ │ ├── translate.ts
│ │ ├── ja.tsx
│ │ └── en.tsx
├── pages
│ ├── index.tsx
│ ├── 404.tsx
│ ├── _app.tsx
│ ├── precautions.tsx
│ ├── privacy.tsx
│ └── _document.tsx
├── public
│ ├── card.png
│ ├── favicon.ico
│ ├── logo192.png
│ ├── logo512.png
│ └── manifest.json
├── .prettierrc
├── next.config.js
├── .env.template
├── next-sitemap.config.js
├── next-env.d.ts
├── tsconfig.json
├── .gitignore
├── package.json
└── .eslintrc.js
├── terraform
├── cicd
│ ├── locals.tf
│ ├── provider.tf
│ ├── terraform.tf
│ ├── .terraform.lock.hcl
│ └── iam.tf
├── app
│ ├── modules
│ │ └── aws
│ │ │ ├── variables.tf
│ │ │ ├── terraform.tf
│ │ │ ├── locals.tf
│ │ │ ├── ecr.tf
│ │ │ ├── apigateway.tf
│ │ │ ├── acm.tf
│ │ │ ├── s3.tf
│ │ │ ├── cloudfront.tf
│ │ │ └── route53.tf
│ ├── main.tf
│ ├── provider.tf
│ ├── tfsec.yml
│ ├── terraform.tf
│ ├── .tflint.hcl
│ └── .terraform.lock.hcl
└── .gitignore
├── e2e
├── .gitignore
├── cypress
│ ├── fixtures
│ │ └── images
│ │ │ └── gray.png
│ ├── support
│ │ ├── e2e.ts
│ │ └── commands.ts
│ └── e2e
│ │ └── frontend
│ │ └── pages
│ │ └── home.cy.ts
├── cypress.local.config.ts
├── cypress.dev.config.ts
├── package.json
└── tsconfig.json
├── docs
├── architecture.png
└── development.md
├── renovate.json
├── .github
└── workflows
│ ├── release_prod.yml
│ ├── release_dev.yml
│ ├── _e2e.yml
│ ├── _release.yml
│ └── _build.yml
└── LICENSE
/backend/.envrc:
--------------------------------------------------------------------------------
1 | dotenv
2 |
--------------------------------------------------------------------------------
/.codeclimate.yml:
--------------------------------------------------------------------------------
1 | exclude_patterns:
2 | - "backend/mocks/"
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Moved to https://github.com/koki-develop/lgtmgen
2 |
--------------------------------------------------------------------------------
/backend/.dockerignore:
--------------------------------------------------------------------------------
1 | /.serverless/
2 | /node_modules/
3 | /secrets.yml
4 |
--------------------------------------------------------------------------------
/frontend/.prettierignore:
--------------------------------------------------------------------------------
1 | /README.md
2 | /.next/
3 | /terraform/.terraform/
4 |
--------------------------------------------------------------------------------
/frontend/src/models/lgtm.ts:
--------------------------------------------------------------------------------
1 | export type Lgtm = {
2 | id: string;
3 | };
4 |
--------------------------------------------------------------------------------
/terraform/cicd/locals.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | prefix = "lgtm-generator-cicd"
3 | }
4 |
--------------------------------------------------------------------------------
/e2e/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 |
3 | /cypress/screenshots/
4 | /cypress/videos/
5 |
--------------------------------------------------------------------------------
/terraform/app/modules/aws/variables.tf:
--------------------------------------------------------------------------------
1 | variable "stage" {
2 | type = string
3 | }
4 |
--------------------------------------------------------------------------------
/frontend/src/components/App/index.tsx:
--------------------------------------------------------------------------------
1 | import App from './App';
2 |
3 | export default App;
4 |
--------------------------------------------------------------------------------
/frontend/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import Home from '@/components/pages/Home';
2 |
3 | export default Home;
4 |
--------------------------------------------------------------------------------
/frontend/src/models/image.ts:
--------------------------------------------------------------------------------
1 | export type Image = {
2 | title: string;
3 | url: string;
4 | };
5 |
--------------------------------------------------------------------------------
/docs/architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koki-develop/lgtm-generator/HEAD/docs/architecture.png
--------------------------------------------------------------------------------
/frontend/src/components/Layout/index.tsx:
--------------------------------------------------------------------------------
1 | import Layout from './Layout';
2 |
3 | export default Layout;
4 |
--------------------------------------------------------------------------------
/frontend/src/components/pages/Home/index.tsx:
--------------------------------------------------------------------------------
1 | import Home from './Home';
2 |
3 | export default Home;
4 |
--------------------------------------------------------------------------------
/frontend/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import NotFound from '@/components/pages/NotFound';
2 |
3 | export default NotFound;
4 |
--------------------------------------------------------------------------------
/frontend/public/card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koki-develop/lgtm-generator/HEAD/frontend/public/card.png
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koki-develop/lgtm-generator/HEAD/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koki-develop/lgtm-generator/HEAD/frontend/public/logo192.png
--------------------------------------------------------------------------------
/frontend/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koki-develop/lgtm-generator/HEAD/frontend/public/logo512.png
--------------------------------------------------------------------------------
/terraform/app/main.tf:
--------------------------------------------------------------------------------
1 | module "aws" {
2 | source = "./modules/aws"
3 |
4 | stage = terraform.workspace
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/src/components/pages/NotFound/index.tsx:
--------------------------------------------------------------------------------
1 | import NotFound from './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/precautions.tsx:
--------------------------------------------------------------------------------
1 | import Precautions from '@/components/pages/Precautions';
2 |
3 | export default Precautions;
4 |
--------------------------------------------------------------------------------
/frontend/pages/privacy.tsx:
--------------------------------------------------------------------------------
1 | import PrivacyPolicy from '@/components/pages/PrivacyPolicy';
2 |
3 | export default PrivacyPolicy;
4 |
--------------------------------------------------------------------------------
/frontend/src/components/pages/Precautions/index.tsx:
--------------------------------------------------------------------------------
1 | import Precautions from './Precautions';
2 |
3 | export default Precautions;
4 |
--------------------------------------------------------------------------------
/e2e/cypress/fixtures/images/gray.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koki-develop/lgtm-generator/HEAD/e2e/cypress/fixtures/images/gray.png
--------------------------------------------------------------------------------
/frontend/src/components/pages/PrivacyPolicy/index.tsx:
--------------------------------------------------------------------------------
1 | import PrivacyPolicy from './PrivacyPolicy';
2 |
3 | export default PrivacyPolicy;
4 |
--------------------------------------------------------------------------------
/frontend/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "jsxSingleQuote": true,
4 | "trailingComma": "all",
5 | "arrowParens": "avoid"
6 | }
7 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": ["github>koki-develop/renovate-config"]
4 | }
5 |
--------------------------------------------------------------------------------
/frontend/next.config.js:
--------------------------------------------------------------------------------
1 | const config = {
2 | i18n: {
3 | locales: ['ja', 'en'],
4 | defaultLocale: 'ja',
5 | },
6 | };
7 |
8 | module.exports = config;
9 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/terraform/app/provider.tf:
--------------------------------------------------------------------------------
1 | provider "aws" {
2 | region = "us-east-1"
3 |
4 | default_tags {
5 | tags = {
6 | App = "lgtm-generator"
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/terraform/cicd/provider.tf:
--------------------------------------------------------------------------------
1 | provider "aws" {
2 | region = "us-east-1"
3 |
4 | default_tags {
5 | tags = {
6 | App = "lgtm-generator"
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/backend/pkg/static/fonts/Archivo_Black/ArchivoBlack-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koki-develop/lgtm-generator/HEAD/backend/pkg/static/fonts/Archivo_Black/ArchivoBlack-Regular.ttf
--------------------------------------------------------------------------------
/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/src/lib/errors.ts:
--------------------------------------------------------------------------------
1 | import { CustomError } from 'ts-custom-error';
2 |
3 | export class UnsupportedImageFormatError extends CustomError {}
4 | export class FileTooLargeError extends CustomError {}
5 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/src/routes.ts:
--------------------------------------------------------------------------------
1 | export const Routes = {
2 | home: '/',
3 | precautions: '/precautions',
4 | privacyPolicy: '/privacy',
5 | } as const;
6 |
7 | export type Routes = typeof Routes[keyof typeof Routes];
8 |
--------------------------------------------------------------------------------
/frontend/src/lib/dataUrl.ts:
--------------------------------------------------------------------------------
1 | export class DataUrl {
2 | constructor(private dataUrl: string) {}
3 |
4 | public toBase64(): string {
5 | return this.dataUrl.slice(this.dataUrl.indexOf(',') + 1);
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/terraform/app/modules/aws/terraform.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_version = ">= 1.2.9"
3 |
4 | required_providers {
5 | aws = {
6 | source = "hashicorp/aws"
7 | version = "~> 4.67.0"
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/frontend/src/global.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace NodeJS {
2 | interface ProcessEnv {
3 | readonly NEXT_PUBLIC_STAGE: 'local' | 'dev' | 'prod';
4 | readonly NEXT_PUBLIC_GA_MEASUREMENT_ID: string;
5 | readonly NEXT_PUBLIC_API_ORIGIN: string;
6 | readonly NEXT_PUBLIC_LGTMS_ORIGIN: string;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/terraform/cicd/terraform.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | backend "s3" {
3 | region = "us-east-1"
4 | bucket = "lgtm-generator-tfstates"
5 | key = "cicd/terraform.tfstate"
6 | encrypt = true
7 | }
8 | required_providers {
9 | aws = {
10 | source = "hashicorp/aws"
11 | version = "~> 4.67.0"
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/Field.tsx:
--------------------------------------------------------------------------------
1 | import { Box, BoxProps } from '@mui/material';
2 | import React from 'react';
3 |
4 | export type FieldProps = BoxProps;
5 |
6 | const Field: React.FC = React.memo(props => {
7 | return ;
8 | });
9 |
10 | Field.displayName = 'Field';
11 |
12 | export default Field;
13 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/frontend/src/models/report.ts:
--------------------------------------------------------------------------------
1 | export type Report = {
2 | id: string;
3 | lgtmId: string;
4 | type: ReportType;
5 | text: string;
6 | createdAt: Date;
7 | };
8 |
9 | export const ReportType = {
10 | illegal: 'illegal',
11 | inappropriate: 'inappropriate',
12 | other: 'other',
13 | } as const;
14 |
15 | export type ReportType = typeof ReportType[keyof typeof ReportType];
16 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/terraform/app/modules/aws/locals.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | app = "lgtm-generator"
3 | prefix = "${local.app}-${var.stage}"
4 | prefix_backend = "${local.app}-backend-${var.stage}"
5 |
6 | domain = "lgtmgen.org"
7 |
8 | sub_domain = var.stage == "prod" ? "" : "${var.stage}."
9 | api_domain = "${local.sub_domain}api.${local.domain}"
10 | images_domain = "${local.sub_domain}images.${local.domain}"
11 | }
12 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/terraform/app/tfsec.yml:
--------------------------------------------------------------------------------
1 | minimum_severity: HIGH
2 |
3 | exclude:
4 | - aws-s3-enable-versioning
5 | - aws-s3-enable-bucket-logging
6 | - aws-s3-encryption-customer-key
7 | - aws-cloudfront-enable-logging
8 | - aws-cloudfront-enable-waf
9 |
10 | # TODO: 設定してるのに警告される。 tfsec 側の不具合?
11 | # 一旦 exclude しておく。
12 | - aws-s3-no-public-buckets
13 | - aws-s3-ignore-public-acls
14 | - aws-s3-block-public-policy
15 | - aws-s3-block-public-acls
16 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/terraform/app/terraform.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_version = ">= 1.2.9"
3 |
4 | backend "s3" {
5 | workspace_key_prefix = "workspaces"
6 | region = "us-east-1"
7 | bucket = "lgtm-generator-tfstates"
8 | key = "terraform.tfstate"
9 | encrypt = true
10 | }
11 | required_providers {
12 | aws = {
13 | source = "hashicorp/aws"
14 | version = "~> 4.67.0"
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/terraform/app/modules/aws/ecr.tf:
--------------------------------------------------------------------------------
1 | resource "aws_ecr_lifecycle_policy" "app" {
2 | repository = "serverless-${local.prefix_backend}"
3 | policy = jsonencode({
4 | rules = [
5 | {
6 | rulePriority = 1,
7 | action = { type = "expire" }
8 | selection = {
9 | tagStatus = "untagged"
10 | countType = "sinceImagePushed"
11 | countUnit = "days"
12 | countNumber = 1
13 | }
14 | }
15 | ]
16 | })
17 | }
18 |
--------------------------------------------------------------------------------
/terraform/app/.tflint.hcl:
--------------------------------------------------------------------------------
1 | config {
2 | module = true
3 | }
4 |
5 | plugin "aws" {
6 | enabled = true
7 | version = "0.23.1"
8 | source = "github.com/terraform-linters/tflint-ruleset-aws"
9 | }
10 |
11 | rule "terraform_comment_syntax" {
12 | enabled = true
13 | }
14 |
15 | rule "terraform_deprecated_index" {
16 | enabled = true
17 | }
18 |
19 | rule "terraform_deprecated_interpolation" {
20 | enabled = true
21 | }
22 |
23 | rule "terraform_unused_declarations" {
24 | enabled = true
25 | }
26 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/terraform/app/modules/aws/apigateway.tf:
--------------------------------------------------------------------------------
1 | data "aws_api_gateway_rest_api" "api" {
2 | name = local.prefix_backend
3 | }
4 |
5 | resource "aws_api_gateway_domain_name" "api" {
6 | certificate_arn = aws_acm_certificate.api.arn
7 | domain_name = local.api_domain
8 | security_policy = "TLS_1_2"
9 | }
10 |
11 | resource "aws_api_gateway_base_path_mapping" "api" {
12 | api_id = data.aws_api_gateway_rest_api.api.id
13 | stage_name = var.stage
14 | domain_name = aws_api_gateway_domain_name.api.domain_name
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/src/recoil/atoms.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil';
2 | import { DataStorage } from '@/lib/dataStorage';
3 | import { Image } from '@/models/image';
4 | import { Lgtm } from '@/models/lgtm';
5 |
6 | export const lgtmsState = atom({
7 | key: 'lgtmsState',
8 | default: [],
9 | });
10 |
11 | export const imagesState = atom({
12 | key: 'imagesState',
13 | default: [],
14 | });
15 |
16 | export const favoriteIdsState = atom({
17 | key: 'favoriteIdsState',
18 | default: DataStorage.getFavoriteIds(),
19 | });
20 |
--------------------------------------------------------------------------------
/frontend/src/hooks/i18n.ts:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import React from 'react';
3 |
4 | export const createTranslate = <
5 | T extends { [key: string]: { ja: React.ReactNode; en: React.ReactNode } },
6 | >(
7 | translate: T,
8 | ) => {
9 | const useTranslate = () => {
10 | const { locale } = useRouter();
11 | const t = React.useCallback(
12 | (key: keyof T) => {
13 | return translate[key][locale];
14 | },
15 | [locale],
16 | );
17 |
18 | return { t };
19 | };
20 | return useTranslate;
21 | };
22 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/Form.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 |
3 | export type FormProps = React.HTMLProps;
4 |
5 | const Form: React.FC = React.memo(props => {
6 | const { onSubmit, ...formProps } = props;
7 |
8 | const handleSubmit = useCallback(
9 | (e: React.FormEvent) => {
10 | e.preventDefault();
11 | onSubmit?.(e);
12 | },
13 | [onSubmit],
14 | );
15 |
16 | return ;
17 | });
18 |
19 | Form.displayName = 'Form';
20 |
21 | export default Form;
22 |
--------------------------------------------------------------------------------
/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "LGTM",
3 | "name": "LGTM Generator",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "browser",
23 | "theme_color": "#1E90FF",
24 | "background_color": "#1E90FF"
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/src/hooks/translateHooks.ts:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { useMemo } from 'react';
3 | import { en } from '@/locales/en';
4 | import { ja } from '@/locales/ja';
5 | import { Translate } from '@/locales/translate';
6 |
7 | /**
8 | * @deprecated
9 | */
10 | export const useTranslate = (): { t: Translate; locale: string } => {
11 | const { locale } = useRouter();
12 |
13 | const t = useMemo(() => {
14 | switch (locale) {
15 | case 'en':
16 | return en;
17 | default:
18 | return ja;
19 | }
20 | }, [locale]);
21 |
22 | return { locale, t };
23 | };
24 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/src/lib/emotion.ts:
--------------------------------------------------------------------------------
1 | import createCache, { EmotionCache, Options } from '@emotion/cache';
2 |
3 | /*
4 | * https://github.com/mui/material-ui/blob/master/examples/material-next-ts/src/createEmotionCache.ts
5 | */
6 |
7 | const isBrowser = typeof document !== 'undefined';
8 |
9 | export const createEmotionCache = (): EmotionCache => {
10 | const options: Options = { key: 'mui-style' };
11 | if (isBrowser) {
12 | const emotionInsertionPoint = document.querySelector(
13 | 'meta[name="emotion-insertion-point"]',
14 | );
15 | options.insertionPoint = emotionInsertionPoint ?? undefined;
16 | }
17 |
18 | return createCache(options);
19 | };
20 |
--------------------------------------------------------------------------------
/frontend/src/components/model/lgtm/LgtmCardList.tsx:
--------------------------------------------------------------------------------
1 | import { Grid } from '@mui/material';
2 | import React from 'react';
3 | import LgtmCard from '@/components/model/lgtm/LgtmCard';
4 |
5 | type LgtmCardListProps = {
6 | ids: string[];
7 | };
8 |
9 | const LgtmCardList: React.FC = React.memo(props => {
10 | const { ids } = props;
11 |
12 | return (
13 |
14 | {ids.map(id => (
15 |
16 |
17 |
18 | ))}
19 |
20 | );
21 | });
22 |
23 | LgtmCardList.displayName = 'LgtmCardList';
24 |
25 | export default LgtmCardList;
26 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "@/*": ["src/*"]
6 | },
7 | "target": "es5",
8 | "lib": ["dom", "dom.iterable", "esnext"],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": false,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "jsx": "preserve",
20 | "incremental": true
21 | },
22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
23 | "exclude": ["node_modules"]
24 | }
25 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/LoadableButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button, ButtonProps, CircularProgress } from '@mui/material';
2 | import React from 'react';
3 |
4 | export type LoadableButtonProps = ButtonProps & {
5 | loading: boolean;
6 | };
7 |
8 | const LoadableButton: React.FC = React.memo(props => {
9 | const { loading, children, ...buttonProps } = props;
10 |
11 | return (
12 |
16 | );
17 | });
18 |
19 | LoadableButton.displayName = 'LoadableButton';
20 |
21 | export default LoadableButton;
22 |
--------------------------------------------------------------------------------
/frontend/src/components/pages/NotFound/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import { Typography } from '@mui/material';
2 | import React from 'react';
3 | import { useTranslate } from '@/hooks/translateHooks';
4 | import Layout from '@/components/Layout';
5 |
6 | const NotFound: React.FC = React.memo(() => {
7 | const { t } = useTranslate();
8 |
9 | return (
10 |
11 | theme.typography.h5.fontSize,
14 | fontWeight: 'bold',
15 | textAlign: 'center',
16 | }}
17 | >
18 | {t.NOT_FOUND}
19 |
20 |
21 | );
22 | });
23 |
24 | NotFound.displayName = 'NotFound';
25 |
26 | export default NotFound;
27 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/Link.tsx:
--------------------------------------------------------------------------------
1 | import MuiLink, { LinkProps as MuiLinkProps } from '@mui/material/Link';
2 | import NextLink from 'next/link';
3 | import React from 'react';
4 |
5 | type LinkProps = MuiLinkProps & {
6 | locale?: string;
7 | external?: boolean;
8 | };
9 |
10 | const Link: React.FC = React.memo(props => {
11 | const { external, locale, ...linkProps } = props;
12 |
13 | if (external) {
14 | return ;
15 | }
16 |
17 | const { href, ...otherLinkProps } = linkProps;
18 |
19 | return (
20 |
21 |
22 |
23 | );
24 | });
25 |
26 | Link.displayName = 'Link';
27 |
28 | export default Link;
29 |
--------------------------------------------------------------------------------
/frontend/src/components/model/image/ImageCardList.tsx:
--------------------------------------------------------------------------------
1 | import Grid from '@mui/material/Grid';
2 | import React from 'react';
3 | import { Image } from '@/models/image';
4 | import ImageCard from './ImageCard';
5 |
6 | type ImageCardListProps = {
7 | images: Image[];
8 | onClickImage: (image: Image) => void;
9 | };
10 |
11 | const ImageCardList: React.FC = React.memo(props => {
12 | const { images, onClickImage } = props;
13 |
14 | return (
15 |
16 | {images.map(image => (
17 |
18 |
19 |
20 | ))}
21 |
22 | );
23 | });
24 |
25 | ImageCardList.displayName = 'ImageCardList';
26 |
27 | export default ImageCardList;
28 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/Layout.tsx:
--------------------------------------------------------------------------------
1 | import Box from '@mui/material/Box';
2 | import Container from '@mui/material/Container';
3 | import React from 'react';
4 | import Meta from '@/components/utils/Meta';
5 | import Footer from './Footer';
6 | import Header from './Header';
7 |
8 | type LayoutProps = {
9 | children: React.ReactNode;
10 | title?: string;
11 | };
12 |
13 | const Layout: React.FC = React.memo(props => {
14 | const { children, title } = props;
15 |
16 | return (
17 | theme.palette.primary.light,
20 | minHeight: '100vh',
21 | }}
22 | >
23 |
24 |
25 |
26 | {children}
27 |
28 |
29 |
30 | );
31 | });
32 |
33 | Layout.displayName = 'Layout';
34 |
35 | export default Layout;
36 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/Loading.tsx:
--------------------------------------------------------------------------------
1 | import { Box, CircularProgress, Typography } from '@mui/material';
2 | import React from 'react';
3 |
4 | export type LoadingProps = {
5 | text?: string;
6 | };
7 |
8 | const Loading: React.FC = React.forwardRef((props, ref) => {
9 | const { text } = props;
10 |
11 | return (
12 |
22 |
23 | {text && (
24 |
30 | {text}
31 |
32 | )}
33 |
34 | );
35 | });
36 |
37 | Loading.displayName = 'Loading';
38 |
39 | export default Loading;
40 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/Modal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Backdrop,
3 | Fade,
4 | Modal as MuiModal,
5 | ModalProps as MuiModalProps,
6 | } from '@mui/material';
7 | import React from 'react';
8 |
9 | export type ModalProps = Omit & {
10 | onClose?: () => void;
11 | };
12 |
13 | const Modal: React.FC = React.memo(props => {
14 | const { children, ...modalProps } = props;
15 |
16 | return (
17 |
30 | {children}
31 |
32 | );
33 | });
34 |
35 | Modal.displayName = 'Modal';
36 |
37 | export default Modal;
38 |
--------------------------------------------------------------------------------
/terraform/app/modules/aws/acm.tf:
--------------------------------------------------------------------------------
1 | resource "aws_acm_certificate" "api" {
2 | domain_name = local.api_domain
3 | validation_method = "DNS"
4 | tags = { Name = "${local.prefix_backend}-api" }
5 |
6 | lifecycle {
7 | create_before_destroy = true
8 | }
9 | }
10 |
11 | resource "aws_acm_certificate_validation" "api" {
12 | certificate_arn = aws_acm_certificate.api.arn
13 | validation_record_fqdns = [aws_route53_record.api_certificate_validation.fqdn]
14 | }
15 |
16 | resource "aws_acm_certificate" "images" {
17 | domain_name = local.images_domain
18 | validation_method = "DNS"
19 | tags = { Name = "${local.prefix_backend}-images" }
20 |
21 | lifecycle {
22 | create_before_destroy = true
23 | }
24 | }
25 |
26 | resource "aws_acm_certificate_validation" "images" {
27 | certificate_arn = aws_acm_certificate.images.arn
28 | validation_record_fqdns = [aws_route53_record.images_certificate_validation.fqdn]
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/src/hooks/imageHooks.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'react';
2 | import { useRecoilValue, useSetRecoilState } from 'recoil';
3 | import { ApiClient } from '@/lib/apiClient';
4 | import { imagesState } from '@/recoil/atoms';
5 | import { Image } from '@/models/image';
6 |
7 | export const useImages = (): Image[] => {
8 | return useRecoilValue(imagesState);
9 | };
10 |
11 | export type SearchImagesFn = (query: string) => Promise;
12 |
13 | export const useSearchImages = (): {
14 | searchImages: SearchImagesFn;
15 | loading: boolean;
16 | } => {
17 | const setImages = useSetRecoilState(imagesState);
18 | const [loading, setLoading] = useState(false);
19 |
20 | const searchImages = useCallback(
21 | async (query: string) => {
22 | setLoading(true);
23 | await ApiClient.searchImages(query)
24 | .then(images => {
25 | setImages(images);
26 | })
27 | .finally(() => {
28 | setLoading(false);
29 | });
30 | },
31 | [setImages],
32 | );
33 |
34 | return { searchImages, loading };
35 | };
36 |
--------------------------------------------------------------------------------
/terraform/app/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/hashicorp/aws" {
5 | version = "4.67.0"
6 | constraints = "~> 4.67.0"
7 | hashes = [
8 | "h1:3t9uBosa4EwbJF7f8LONm4YO3vB9r5CFSgxKiP3UrYw=",
9 | "h1:5Zfo3GfRSWBaXs4TGQNOflr1XaYj6pRnVJLX5VAjFX4=",
10 | "h1:DWVybabEz2gCyQkRM9zop1SKTG1tkH7+ruekC9KQM5w=",
11 | "h1:L5c1etFqHmzTzxe8SDT78dIuGfuETeXDtctyUtiBpRs=",
12 | "h1:LfOuBkdYCzQhtiRvVIxdP/KGJODa3cRsKjn8xKCTbVY=",
13 | "h1:Ohs//YZd3vzB0upovYRptLX5mTFKeyxFbeHgGajGj/A=",
14 | "h1:P43vwcDPG99x5WBbmqwUPgfJrfXf6/ucAIbGlRb7k1w=",
15 | "h1:QckVBFBnlQW8R0QvVqyad/9XNKsSistTpWhnoyeofcU=",
16 | "h1:X3t1gUv5lJkCyzE/q/LbZzhNd55SD8M0liZfI3D76b0=",
17 | "h1:dCRc4GqsyfqHEMjgtlM1EympBcgTmcTkWaJmtd91+KA=",
18 | "h1:kxoaBW0OshdCEVEBM7d3eUGoiT8Lqmray9WkRYiQ28U=",
19 | "h1:m+crorRcEqedJlwZs4Y89JWLPZHmiP7l/KlyNaywNGE=",
20 | "h1:vm5aeYUHIJh2Znz+5SbxcR+O9P0u+890WWl4clfISN8=",
21 | "h1:xzpipYzqP0dTwotCNTD+4DsIPOGoXlYsX3nJDqHRrZM=",
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/terraform/cicd/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/hashicorp/aws" {
5 | version = "4.67.0"
6 | constraints = "~> 4.67.0"
7 | hashes = [
8 | "h1:3t9uBosa4EwbJF7f8LONm4YO3vB9r5CFSgxKiP3UrYw=",
9 | "h1:5Zfo3GfRSWBaXs4TGQNOflr1XaYj6pRnVJLX5VAjFX4=",
10 | "h1:DWVybabEz2gCyQkRM9zop1SKTG1tkH7+ruekC9KQM5w=",
11 | "h1:L5c1etFqHmzTzxe8SDT78dIuGfuETeXDtctyUtiBpRs=",
12 | "h1:LfOuBkdYCzQhtiRvVIxdP/KGJODa3cRsKjn8xKCTbVY=",
13 | "h1:Ohs//YZd3vzB0upovYRptLX5mTFKeyxFbeHgGajGj/A=",
14 | "h1:P43vwcDPG99x5WBbmqwUPgfJrfXf6/ucAIbGlRb7k1w=",
15 | "h1:QckVBFBnlQW8R0QvVqyad/9XNKsSistTpWhnoyeofcU=",
16 | "h1:X3t1gUv5lJkCyzE/q/LbZzhNd55SD8M0liZfI3D76b0=",
17 | "h1:dCRc4GqsyfqHEMjgtlM1EympBcgTmcTkWaJmtd91+KA=",
18 | "h1:kxoaBW0OshdCEVEBM7d3eUGoiT8Lqmray9WkRYiQ28U=",
19 | "h1:m+crorRcEqedJlwZs4Y89JWLPZHmiP7l/KlyNaywNGE=",
20 | "h1:vm5aeYUHIJh2Znz+5SbxcR+O9P0u+890WWl4clfISN8=",
21 | "h1:xzpipYzqP0dTwotCNTD+4DsIPOGoXlYsX3nJDqHRrZM=",
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/terraform/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/terraform
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=terraform
3 |
4 | ### Terraform ###
5 | # Local .terraform directories
6 | **/.terraform/*
7 |
8 | # .tfstate files
9 | *.tfstate
10 | *.tfstate.*
11 |
12 | # Crash log files
13 | crash.log
14 | crash.*.log
15 |
16 | # Exclude all .tfvars files, which are likely to contain sensitive data, such as
17 | # password, private keys, and other secrets. These should not be part of version
18 | # control as they are data points which are potentially sensitive and subject
19 | # to change depending on the environment.
20 | *.tfvars
21 | *.tfvars.json
22 |
23 | # Ignore override files as they are usually used to override resources locally and so
24 | # are not checked in
25 | override.tf
26 | override.tf.json
27 | *_override.tf
28 | *_override.tf.json
29 |
30 | # Include override files you do wish to add to version control using negated pattern
31 | # !example_override.tf
32 |
33 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
34 | # example: *tfplan*
35 |
36 | # Ignore CLI configuration files
37 | .terraformrc
38 | terraform.rc
39 |
40 | # End of https://www.toptal.com/developers/gitignore/api/terraform
41 |
--------------------------------------------------------------------------------
/frontend/src/hooks/reportHooks.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'react';
2 | import { useToast } from '@/components/providers/ToastProvider';
3 | import { ApiClient } from '@/lib/apiClient';
4 | import { ReportType } from '@/models/report';
5 | import { useTranslate } from './translateHooks';
6 |
7 | export type SendReportFn = (
8 | lgtmId: string,
9 | type: ReportType,
10 | text: string,
11 | ) => Promise;
12 |
13 | export const useSendReport = (): {
14 | sendReport: SendReportFn;
15 | loading: boolean;
16 | } => {
17 | const [loading, setLoading] = useState(false);
18 |
19 | const { enqueueSuccess, enqueueError } = useToast();
20 | const { t } = useTranslate();
21 |
22 | const sendReport = useCallback(
23 | async (lgtmId: string, type: ReportType, text: string) => {
24 | setLoading(true);
25 | await ApiClient.createReport(lgtmId, type, text)
26 | .then(() => {
27 | enqueueSuccess(t.SENT);
28 | })
29 | .catch(err => {
30 | console.error(err);
31 | enqueueError(t.SENDING_FAILED);
32 | })
33 | .finally(() => {
34 | setLoading(false);
35 | });
36 | },
37 | [enqueueError, enqueueSuccess, t.SENDING_FAILED, t.SENT],
38 | );
39 |
40 | return { sendReport, loading };
41 | };
42 |
--------------------------------------------------------------------------------
/frontend/src/components/pages/Precautions/Precautions.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Typography } from '@mui/material';
2 | import { styled } from '@mui/material/styles';
3 | import React from 'react';
4 | import { useTranslate } from '@/hooks/translateHooks';
5 | import Layout from '@/components/Layout';
6 |
7 | const StyledList = styled('ul')(({ theme }) => ({
8 | listStyle: 'disc',
9 | paddingLeft: theme.spacing(4),
10 | }));
11 |
12 | const StyledListItem = styled('li')(({ theme }) => ({
13 | marginBottom: theme.spacing(1),
14 | }));
15 |
16 | const Precautions: React.FC = React.memo(() => {
17 | const { t } = useTranslate();
18 |
19 | return (
20 |
21 | theme.typography.h4.fontSize,
24 | fontWeight: 'bold',
25 | textAlign: 'center',
26 | }}
27 | >
28 | {t.PRECAUTIONS}
29 |
30 |
31 |
32 | {t.PRECAUTIONS_ITEMS.map((item, i) => (
33 |
34 | {item}
35 |
36 | ))}
37 |
38 |
39 |
40 | );
41 | });
42 |
43 | Precautions.displayName = 'Precautions';
44 |
45 | export default Precautions;
46 |
--------------------------------------------------------------------------------
/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/src/components/pages/Home/Home.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import React, { useCallback, useMemo } from 'react';
3 | import Field from '@/components/utils/Field';
4 | import Layout from '@/components/Layout';
5 | import FavoritesPanel from './FavoritesPanel';
6 | import LgtmsPanel from './LgtmsPanel';
7 | import SearchImagesPanel from './SearchImagesPanel';
8 | import Tabs, { TabValue } from './Tabs';
9 |
10 | const Home: React.FC = React.memo(() => {
11 | const router = useRouter();
12 |
13 | const tab = useMemo(() => {
14 | return Object.values(TabValue).find(v => v === router.query.tab) || 'lgtms';
15 | }, [router.query.tab]);
16 |
17 | const handleChangeTab = useCallback(
18 | (value: TabValue) => {
19 | router.replace({
20 | search: value === TabValue.lgtms ? '' : `tab=${value}`,
21 | });
22 | },
23 | [router],
24 | );
25 |
26 | return (
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | });
40 |
41 | Home.displayName = 'Home';
42 |
43 | export default Home;
44 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/terraform/app/modules/aws/s3.tf:
--------------------------------------------------------------------------------
1 | resource "aws_s3_bucket" "images" {
2 | bucket = "${local.prefix_backend}-images"
3 | force_destroy = false
4 |
5 | tags = {
6 | Name = "${local.prefix_backend}-images"
7 | }
8 | }
9 |
10 | resource "aws_s3_bucket_policy" "images" {
11 | bucket = aws_s3_bucket.images.id
12 | policy = data.aws_iam_policy_document.images_bucket_policy.json
13 | }
14 |
15 | data "aws_iam_policy_document" "images_bucket_policy" {
16 | statement {
17 | principals {
18 | type = "Service"
19 | identifiers = ["cloudfront.amazonaws.com"]
20 | }
21 | actions = ["s3:GetObject"]
22 | resources = ["${aws_s3_bucket.images.arn}/*"]
23 | condition {
24 | test = "StringEquals"
25 | variable = "aws:SourceArn"
26 | values = [aws_cloudfront_distribution.images.arn]
27 | }
28 | }
29 | }
30 |
31 | resource "aws_s3_bucket_public_access_block" "images" {
32 | bucket = aws_s3_bucket.images.id
33 |
34 | block_public_acls = true
35 | block_public_policy = true
36 | ignore_public_acls = true
37 | restrict_public_buckets = true
38 | }
39 |
40 | resource "aws_s3_bucket_server_side_encryption_configuration" "images" {
41 | bucket = aws_s3_bucket.images.id
42 |
43 | rule {
44 | apply_server_side_encryption_by_default {
45 | sse_algorithm = "AES256"
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/terraform/cicd/iam.tf:
--------------------------------------------------------------------------------
1 | data "aws_iam_openid_connect_provider" "github_actions" {
2 | url = "https://token.actions.githubusercontent.com"
3 | }
4 |
5 | resource "aws_iam_role" "github_actions" {
6 | name = "${local.prefix}-role"
7 | assume_role_policy = data.aws_iam_policy_document.github_actions_assume_role_policy.json
8 | }
9 |
10 | data "aws_iam_policy_document" "github_actions_assume_role_policy" {
11 | statement {
12 | effect = "Allow"
13 | actions = ["sts:AssumeRoleWithWebIdentity"]
14 |
15 | principals {
16 | type = "Federated"
17 | identifiers = [data.aws_iam_openid_connect_provider.github_actions.arn]
18 | }
19 |
20 | condition {
21 | test = "StringEquals"
22 | variable = "token.actions.githubusercontent.com:aud"
23 | values = ["sts.amazonaws.com"]
24 | }
25 |
26 | condition {
27 | test = "StringLike"
28 | variable = "token.actions.githubusercontent.com:sub"
29 | values = ["repo:koki-develop/lgtm-generator:*"]
30 | }
31 | }
32 | }
33 |
34 | data "aws_iam_policy" "administrator_access" {
35 | arn = "arn:aws:iam::aws:policy/AdministratorAccess"
36 | }
37 |
38 | resource "aws_iam_role_policy_attachment" "github_actions_administrator_access" {
39 | role = aws_iam_role.github_actions.name
40 | policy_arn = data.aws_iam_policy.administrator_access.arn
41 | }
42 |
--------------------------------------------------------------------------------
/frontend/src/lib/dataStorage.ts:
--------------------------------------------------------------------------------
1 | const favoriteIdsKey = 'FAVORITE_IDS';
2 | const randomlyKey = 'RANDOMLY';
3 |
4 | export class DataStorage {
5 | public static getFavoriteIds(): string[] {
6 | if (typeof window === 'undefined') {
7 | return [];
8 | }
9 | const val = window.localStorage.getItem(favoriteIdsKey);
10 | if (!val) {
11 | return [];
12 | }
13 | try {
14 | return JSON.parse(val);
15 | } catch {
16 | return [];
17 | }
18 | }
19 |
20 | public static saveFavoriteIds(favoriteIds: string[]): void {
21 | if (typeof window === 'undefined') {
22 | return;
23 | }
24 | window.localStorage.setItem(favoriteIdsKey, JSON.stringify(favoriteIds));
25 | }
26 |
27 | public static setRandomly(v: boolean): void {
28 | if (typeof window === 'undefined') {
29 | return;
30 | }
31 | window.localStorage.setItem(randomlyKey, JSON.stringify(v));
32 | }
33 |
34 | public static getRandomly(): boolean {
35 | if (typeof window === 'undefined') {
36 | return false;
37 | }
38 | const val = window.localStorage.getItem(randomlyKey);
39 | if (!val) {
40 | return false;
41 | }
42 | try {
43 | const v = JSON.parse(val);
44 | if (typeof v === 'boolean') {
45 | return v;
46 | }
47 | return false;
48 | } catch {
49 | return false;
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/frontend/src/locales/translate.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // TODO: 整理
4 | export type Translate = {
5 | ALERT: string;
6 |
7 | APP_NAME: string;
8 | APP_DESCRIPTION: string;
9 |
10 | PRECAUTIONS: string;
11 | PRECAUTIONS_ITEMS: string[];
12 | PRIVACY_POLICY: string;
13 |
14 | PLEASE_READ_PRECAUTIONS: React.ReactNode;
15 |
16 | COPIED_TO_CLIPBOARD: string;
17 |
18 | CONFIRM_GENERATION: string;
19 |
20 | GENERATE: string;
21 | CANCEL: string;
22 |
23 | ILLEGAL: string;
24 | INAPPROPRIATE: string;
25 | OTHER: string;
26 |
27 | SUPPLEMENT: string;
28 |
29 | SEND: string;
30 |
31 | NO_FAVORITES: string;
32 |
33 | RANDOM: string;
34 | RELOAD: string;
35 | SEE_MORE: string;
36 |
37 | KEYWORD: string;
38 |
39 | LGTM: string;
40 | IMAGE_SEARCH: string;
41 | FAVORITES: string;
42 |
43 | UPLOAD: string;
44 |
45 | NOT_FOUND: string;
46 |
47 | USE_OF_ACCESS_ANALYSIS_TOOLS: string;
48 | USE_OF_ACCESS_ANALYSIS_TOOLS_CONTENT: React.ReactNode;
49 |
50 | UPDATING_PRIVACY_POLICY: string;
51 | UPDATING_PRIVACY_POLICY_CONTENT: React.ReactNode;
52 |
53 | LOADING: string;
54 |
55 | UNSUPPORTED_IMAGE_FORMAT: string;
56 | GENERATED_LGTM_IMAGE: string;
57 | LGTM_IMAGE_GENERATION_FAILED: string;
58 |
59 | SENT: string;
60 | SENDING_FAILED: string;
61 |
62 | FILE_TOO_LARGE: string;
63 |
64 | FAILED_TO_LOAD_IMAGE: string;
65 | };
66 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/src/components/pages/Home/FavoritesPanel.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Typography } from '@mui/material';
2 | import React, { useEffect, useState } from 'react';
3 | import LgtmCardList from '@/components/model/lgtm/LgtmCardList';
4 | import { useFavoriteIds } from '@/hooks/lgtmHooks';
5 | import { useTranslate } from '@/hooks/translateHooks';
6 |
7 | type FavoritesPanelProps = {
8 | show: boolean;
9 | };
10 |
11 | const FavoritesPanel: React.FC = React.memo(props => {
12 | const { show } = props;
13 |
14 | const favoriteIds = useFavoriteIds();
15 | const [showingFavoriteIds, setShowingFavoriteIds] = useState([]);
16 | const { t } = useTranslate();
17 |
18 | useEffect(() => {
19 | if (!show) {
20 | setShowingFavoriteIds(favoriteIds);
21 | }
22 | }, [favoriteIds, show]);
23 |
24 | return (
25 |
26 | {showingFavoriteIds.length === 0 ? (
27 | theme.palette.text.secondary,
31 | textAlign: 'center',
32 | }}
33 | >
34 | {t.NO_FAVORITES}
35 |
36 | ) : (
37 |
38 | )}
39 |
40 | );
41 | });
42 |
43 | FavoritesPanel.displayName = 'FavoritesPanel';
44 |
45 | export default FavoritesPanel;
46 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/ModalCard.tsx:
--------------------------------------------------------------------------------
1 | import { Close as CloseIcon } from '@mui/icons-material';
2 | import { Box, Card, Container } from '@mui/material';
3 | import React, { useCallback } from 'react';
4 | import Modal, { ModalProps } from '@/components/utils/Modal';
5 |
6 | type ModalCardProps = Omit & {
7 | children: React.ReactNode;
8 | };
9 |
10 | const ModalCard: React.FC = React.memo(props => {
11 | const { onClose, ...modalProps } = props;
12 |
13 | const handleClickCloseIcon = useCallback(() => {
14 | onClose();
15 | }, [onClose]);
16 |
17 | return (
18 |
19 | theme.zIndex.modal + 1,
23 | }}
24 | >
25 |
26 |
33 | theme.palette.text.secondary,
36 | cursor: 'pointer',
37 | }}
38 | onClick={handleClickCloseIcon}
39 | />
40 |
41 | {props.children}
42 |
43 |
44 |
45 | );
46 | });
47 |
48 | ModalCard.displayName = 'ModalCard';
49 |
50 | export default ModalCard;
51 |
--------------------------------------------------------------------------------
/frontend/src/components/App/App.tsx:
--------------------------------------------------------------------------------
1 | import { CacheProvider, EmotionCache } from '@emotion/react';
2 | import { CssBaseline, ThemeProvider } from '@mui/material';
3 | import { AppProps } from 'next/app';
4 | import { useRouter } from 'next/router';
5 | import React, { useEffect } from 'react';
6 | import { RecoilRoot } from 'recoil';
7 | import { theme } from '@/components/Layout/theme';
8 | import ToastProvider from '@/components/providers/ToastProvider';
9 | import { createEmotionCache } from '@/lib/emotion';
10 |
11 | const clientSideEmotionCache = createEmotionCache();
12 |
13 | export type MyAppProps = AppProps & {
14 | emotionCache?: EmotionCache;
15 | };
16 |
17 | const App: React.FC = props => {
18 | const { Component, pageProps, emotionCache = clientSideEmotionCache } = props;
19 |
20 | const router = useRouter();
21 |
22 | useEffect(() => {
23 | if (process.env.NEXT_PUBLIC_STAGE === 'prod') {
24 | window.gtag('config', process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID, {
25 | page_path: router.pathname,
26 | });
27 | }
28 | }, [router.pathname]);
29 |
30 | return (
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | );
42 | };
43 |
44 | export default App;
45 |
--------------------------------------------------------------------------------
/docs/development.md:
--------------------------------------------------------------------------------
1 | # アーキテクチャ
2 |
3 | 
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 |
--------------------------------------------------------------------------------
/frontend/src/components/model/lgtm/LgtmCard.tsx:
--------------------------------------------------------------------------------
1 | import Box from '@mui/material/Box';
2 | import Card from '@mui/material/Card';
3 | import CardActions from '@mui/material/CardActions';
4 | import CardContent from '@mui/material/CardContent';
5 | import { styled } from '@mui/material/styles';
6 | import React from 'react';
7 | import urlJoin from 'url-join';
8 | import LgtmCardButtonGroup from '@/components/model/lgtm/LgtmCardButtonGroup';
9 |
10 | const StyledImage = styled('img')({});
11 |
12 | type LgtmCardProps = {
13 | id: string;
14 | };
15 |
16 | const LgtmCard: React.FC = React.memo(props => {
17 | const { id } = props;
18 |
19 | return (
20 |
21 |
22 |
30 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 | });
48 |
49 | LgtmCard.displayName = 'LgtmCard';
50 |
51 | export default LgtmCard;
52 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/terraform/app/modules/aws/cloudfront.tf:
--------------------------------------------------------------------------------
1 | resource "aws_cloudfront_distribution" "images" {
2 | enabled = true
3 | aliases = [local.images_domain]
4 | http_version = "http2"
5 |
6 | origin {
7 | origin_id = aws_s3_bucket.images.id
8 | domain_name = "${aws_s3_bucket.images.id}.s3.amazonaws.com"
9 | origin_access_control_id = aws_cloudfront_origin_access_control.images.id
10 | }
11 |
12 | viewer_certificate {
13 | acm_certificate_arn = aws_acm_certificate.images.arn
14 | ssl_support_method = "sni-only"
15 | minimum_protocol_version = "TLSv1.2_2021"
16 | }
17 |
18 | default_cache_behavior {
19 | target_origin_id = aws_s3_bucket.images.id
20 | viewer_protocol_policy = "redirect-to-https"
21 | cached_methods = ["GET", "HEAD"]
22 | allowed_methods = ["GET", "HEAD"]
23 | default_ttl = 86400
24 | max_ttl = 604800
25 | min_ttl = 0
26 | forwarded_values {
27 | query_string = false
28 | headers = []
29 | cookies {
30 | forward = "none"
31 | }
32 | }
33 | }
34 |
35 | restrictions {
36 | geo_restriction {
37 | restriction_type = "none"
38 | }
39 | }
40 | }
41 |
42 | resource "aws_cloudfront_origin_access_control" "images" {
43 | name = "${local.prefix}-images"
44 | origin_access_control_origin_type = "s3"
45 | signing_behavior = "always"
46 | signing_protocol = "sigv4"
47 | }
48 |
49 | resource "aws_cloudfront_origin_access_identity" "images" {}
50 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/src/components/pages/Home/Tabs.tsx:
--------------------------------------------------------------------------------
1 | import { Paper, Tabs as MuiTabs, Tab } from '@mui/material';
2 | import React, { useCallback } from 'react';
3 | import { useTranslate } from '@/hooks/translateHooks';
4 |
5 | export const TabValue = {
6 | lgtms: 'lgtms',
7 | searchImages: 'search_images',
8 | favorites: 'favorites',
9 | } as const;
10 |
11 | export type TabValue = typeof TabValue[keyof typeof TabValue];
12 |
13 | type TabsProps = {
14 | value: TabValue;
15 | onChange: (value: TabValue) => void;
16 | };
17 |
18 | const Tabs: React.FC = React.memo(props => {
19 | const { value, onChange } = props;
20 | const { t } = useTranslate();
21 |
22 | const handleChangeValue = useCallback(
23 | (_: React.ChangeEvent, value: string) => {
24 | onChange(value as TabValue);
25 | },
26 | [onChange],
27 | );
28 |
29 | return (
30 |
31 |
38 |
43 |
48 |
53 |
54 |
55 | );
56 | });
57 |
58 | Tabs.displayName = 'Tabs';
59 |
60 | export default Tabs;
61 |
--------------------------------------------------------------------------------
/frontend/src/components/model/image/ImageCard.tsx:
--------------------------------------------------------------------------------
1 | import Box from '@mui/material/Box';
2 | import Card from '@mui/material/Card';
3 | import CardActionArea from '@mui/material/CardActionArea';
4 | import CardContent from '@mui/material/CardContent';
5 | import { styled } from '@mui/material/styles';
6 | import React, { useCallback } from 'react';
7 | import { Image } from '@/models/image';
8 |
9 | const StyledImage = styled('img')({});
10 |
11 | type ImageCardProps = {
12 | image: Image;
13 | onClick: (image: Image) => void;
14 | };
15 |
16 | const ImageCard: React.FC = React.memo(props => {
17 | const { image, onClick } = props;
18 |
19 | const handleClick = useCallback(() => {
20 | onClick(image);
21 | }, [image, onClick]);
22 |
23 | return (
24 |
25 |
29 |
30 |
38 |
48 |
49 |
50 |
51 |
52 | );
53 | });
54 |
55 | ImageCard.displayName = 'ImageCard';
56 |
57 | export default ImageCard;
58 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Typography } from '@mui/material';
2 | import { styled } from '@mui/material/styles';
3 | import React from 'react';
4 | import Link from '@/components/utils/Link';
5 | import { createTranslate } from '@/hooks/i18n';
6 | import { Routes } from '@/routes';
7 |
8 | const useTranslate = createTranslate({
9 | precautions: {
10 | ja: '利用上の注意',
11 | en: 'Precautions',
12 | },
13 | privacy_policy: {
14 | ja: 'プライバシーポリシー',
15 | en: 'Privacy Policy',
16 | },
17 | });
18 |
19 | const LinkListItem = styled('li')(({ theme }) => ({
20 | marginBottom: theme.spacing(1),
21 | textAlign: 'center',
22 | }));
23 |
24 | const Footer: React.FC = React.memo(() => {
25 | const { t } = useTranslate();
26 |
27 | return (
28 |
36 |
37 |
38 |
39 | ©2021 koki sato
40 |
41 |
42 |
43 |
44 | View on GitHub
45 |
46 |
47 |
48 | {t('precautions')}
49 |
50 |
51 | {t('privacy_policy')}
52 |
53 |
54 |
55 | );
56 | });
57 |
58 | Footer.displayName = 'Footer';
59 |
60 | export default Footer;
61 |
--------------------------------------------------------------------------------
/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/src/components/Layout/theme.ts:
--------------------------------------------------------------------------------
1 | import { createTheme, PaletteOptions } from '@mui/material/styles';
2 |
3 | const palette: PaletteOptions = {
4 | primary: {
5 | main: '#1E90FF',
6 | dark: '#0070DF',
7 | light: '#E8EEF2',
8 | contrastText: '#ffffff',
9 | },
10 | secondary: {
11 | main: '#E0E0E0',
12 | dark: '#D5D5D5',
13 | light: '#F7F7F7',
14 | contrastText: '#000000',
15 | },
16 | };
17 |
18 | export const theme = createTheme({
19 | palette,
20 | components: {
21 | MuiButton: {
22 | defaultProps: {
23 | variant: 'contained',
24 | },
25 | styleOverrides: {
26 | root: {
27 | fontWeight: 'bold',
28 | textTransform: 'none',
29 | },
30 | },
31 | },
32 | MuiButtonGroup: {
33 | defaultProps: {
34 | variant: 'contained',
35 | },
36 | styleOverrides: {
37 | grouped: {
38 | ':not(:last-of-type)': {
39 | borderRight: 'none',
40 | },
41 | },
42 | },
43 | },
44 | MuiTextField: {
45 | defaultProps: {
46 | variant: 'outlined',
47 | },
48 | },
49 | MuiTab: {
50 | styleOverrides: {
51 | root: {
52 | textTransform: 'none',
53 | },
54 | },
55 | },
56 | MuiLink: {
57 | styleOverrides: {
58 | root: {
59 | color: 'inherit',
60 | textDecoration: 'none',
61 | },
62 | },
63 | },
64 | MuiCssBaseline: {
65 | styleOverrides: {
66 | a: {
67 | color: 'inherit',
68 | textDecoration: 'none',
69 | transition: '0.15s',
70 | '&:hover': {
71 | opacity: 0.6,
72 | },
73 | },
74 | ul: {
75 | listStyleType: 'none',
76 | paddingLeft: 0,
77 | },
78 | },
79 | },
80 | },
81 | });
82 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/src/components/pages/Home/UploadButton.tsx:
--------------------------------------------------------------------------------
1 | import { AddCircle as AddCircleIcon } from '@mui/icons-material';
2 | import { Fab } from '@mui/material';
3 | import React, { useCallback, useState } from 'react';
4 | import * as uuid from 'uuid';
5 | import { useTranslate } from '@/hooks/translateHooks';
6 |
7 | type UploadButtonProps = {
8 | onChange: (file: File) => void;
9 | };
10 |
11 | const UploadButton: React.FC = React.memo(props => {
12 | const { onChange } = props;
13 |
14 | const inputFileRef = React.createRef();
15 | const [inputFileKey, setInputFileKey] = useState(uuid.v4());
16 |
17 | const { t } = useTranslate();
18 |
19 | const handleClick = useCallback(() => {
20 | inputFileRef.current?.click();
21 | }, [inputFileRef]);
22 |
23 | const handleChangeFile = useCallback(
24 | (e: React.ChangeEvent) => {
25 | setInputFileKey(uuid.v4());
26 | const files = e.currentTarget.files;
27 | if (files && files.length > 0) {
28 | onChange(files[0]);
29 | }
30 | },
31 | [onChange],
32 | );
33 |
34 | return (
35 | theme.spacing(2),
41 | fontWeight: 'bold',
42 | position: 'fixed',
43 | right: theme => theme.spacing(2),
44 | zIndex: 999,
45 | }}
46 | >
47 |
56 |
57 | {t.UPLOAD}
58 |
59 | );
60 | });
61 |
62 | UploadButton.displayName = 'UploadButton';
63 |
64 | export default UploadButton;
65 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/src/components/pages/PrivacyPolicy/PrivacyPolicy.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Typography } from '@mui/material';
2 | import React, { useMemo } from 'react';
3 | import { useTranslate } from '@/hooks/translateHooks';
4 | import Layout from '@/components/Layout';
5 |
6 | const PrivacyPolicy: React.FC = React.memo(() => {
7 | const { t } = useTranslate();
8 |
9 | const items: { name: string; content: React.ReactNode }[] = useMemo(() => {
10 | return [
11 | {
12 | name: t.USE_OF_ACCESS_ANALYSIS_TOOLS,
13 | content: t.USE_OF_ACCESS_ANALYSIS_TOOLS_CONTENT,
14 | },
15 | {
16 | name: t.UPDATING_PRIVACY_POLICY,
17 | content: t.UPDATING_PRIVACY_POLICY_CONTENT,
18 | },
19 | ];
20 | }, [
21 | t.UPDATING_PRIVACY_POLICY,
22 | t.UPDATING_PRIVACY_POLICY_CONTENT,
23 | t.USE_OF_ACCESS_ANALYSIS_TOOLS,
24 | t.USE_OF_ACCESS_ANALYSIS_TOOLS_CONTENT,
25 | ]);
26 |
27 | return (
28 |
29 | theme.typography.h4.fontSize,
32 | fontWeight: 'bold',
33 | mb: 2,
34 | textAlign: 'center',
35 | }}
36 | >
37 | {t.PRIVACY_POLICY}
38 |
39 |
40 | {items.map(item => (
41 |
47 | theme.typography.h5.fontSize,
50 | textAlign: 'center',
51 | }}
52 | >
53 | {item.name}
54 |
55 | {item.content}
56 |
57 | ))}
58 |
59 |
60 | );
61 | });
62 |
63 | PrivacyPolicy.displayName = 'PrivacyPolicy';
64 |
65 | export default PrivacyPolicy;
66 |
--------------------------------------------------------------------------------
/frontend/src/components/providers/ToastProvider.tsx:
--------------------------------------------------------------------------------
1 | import { useSnackbar, SnackbarProvider, VariantType } from 'notistack';
2 | import React, { createContext, useCallback, useContext } from 'react';
3 |
4 | type Context = {
5 | enqueueSuccess?: (message: React.ReactNode) => void;
6 | enqueueWarn?: (message: React.ReactNode) => void;
7 | enqueueError?: (message: React.ReactNode) => void;
8 | };
9 |
10 | const ToastContext = createContext({});
11 |
12 | type ToastProviderProps = {
13 | children: React.ReactNode;
14 | };
15 |
16 | const Root: React.FC = (props: ToastProviderProps) => {
17 | const snackbarRef = React.createRef();
18 |
19 | return (
20 |
26 | {props.children}
27 |
28 | );
29 | };
30 |
31 | const ToastProvider: React.FC = (
32 | props: ToastProviderProps,
33 | ) => {
34 | const { enqueueSnackbar, closeSnackbar } = useSnackbar();
35 |
36 | const enqueueDefault = useCallback(
37 | (message: React.ReactNode, variant: VariantType) => {
38 | const key = enqueueSnackbar(message, {
39 | variant,
40 | onClick: () => closeSnackbar(key),
41 | });
42 | },
43 | [closeSnackbar, enqueueSnackbar],
44 | );
45 |
46 | const enqueueSuccess = useCallback(
47 | (message: React.ReactNode) => enqueueDefault(message, 'success'),
48 | [enqueueDefault],
49 | );
50 | const enqueueWarn = useCallback(
51 | (message: React.ReactNode) => enqueueDefault(message, 'warning'),
52 | [enqueueDefault],
53 | );
54 | const enqueueError = useCallback(
55 | (message: React.ReactNode) => enqueueDefault(message, 'error'),
56 | [enqueueDefault],
57 | );
58 |
59 | return (
60 |
63 | {props.children}
64 |
65 | );
66 | };
67 |
68 | export default Root;
69 |
70 | export const useToast = (): Context => useContext(ToastContext);
71 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/frontend/src/components/model/lgtm/LgtmForm.tsx:
--------------------------------------------------------------------------------
1 | import { Button, CardActions, CardContent, Typography } from '@mui/material';
2 | import { styled } from '@mui/material/styles';
3 | import React, { useCallback } from 'react';
4 | import LoadableButton from '@/components/utils/LoadableButton';
5 | import ModalCard from '@/components/utils/ModalCard';
6 | import { useTranslate } from '@/hooks/translateHooks';
7 |
8 | const StyledImg = styled('img')({});
9 |
10 | type ConfirmFormProps = {
11 | previewSrc: string;
12 | open: boolean;
13 | loading: boolean;
14 | onClose: () => void;
15 | onConfirm: () => void;
16 | };
17 |
18 | const ConfirmForm: React.FC = React.memo(props => {
19 | const { previewSrc, open, loading, onClose, onConfirm } = props;
20 | const { t } = useTranslate();
21 |
22 | const handleClose = useCallback(() => {
23 | if (loading) return;
24 | onClose();
25 | }, [loading, onClose]);
26 |
27 | return (
28 |
29 |
37 | {t.CONFIRM_GENERATION}
38 |
49 | {t.PLEASE_READ_PRECAUTIONS}
50 |
51 |
52 |
60 |
67 | {t.GENERATE}
68 |
69 |
70 |
71 | );
72 | });
73 |
74 | ConfirmForm.displayName = 'ConfirmForm';
75 |
76 | export default ConfirmForm;
77 |
--------------------------------------------------------------------------------
/terraform/app/modules/aws/route53.tf:
--------------------------------------------------------------------------------
1 | data "aws_route53_zone" "default" {
2 | name = local.domain
3 | private_zone = false
4 | }
5 |
6 | resource "aws_route53_record" "api" {
7 | zone_id = data.aws_route53_zone.default.id
8 | name = local.api_domain
9 | type = "A"
10 |
11 | alias {
12 | name = aws_api_gateway_domain_name.api.cloudfront_domain_name
13 | zone_id = aws_api_gateway_domain_name.api.cloudfront_zone_id
14 | evaluate_target_health = false
15 | }
16 | }
17 |
18 | resource "aws_route53_record" "api_certificate_validation" {
19 | zone_id = data.aws_route53_zone.default.zone_id
20 | name = aws_acm_certificate.api.domain_validation_options.*.resource_record_name[0]
21 | type = aws_acm_certificate.api.domain_validation_options.*.resource_record_type[0]
22 | records = [aws_acm_certificate.api.domain_validation_options.*.resource_record_value[0]]
23 | ttl = 60
24 | }
25 |
26 | resource "aws_route53_record" "images" {
27 | zone_id = data.aws_route53_zone.default.id
28 | name = local.images_domain
29 | type = "A"
30 |
31 | alias {
32 | name = aws_cloudfront_distribution.images.domain_name
33 | zone_id = aws_cloudfront_distribution.images.hosted_zone_id
34 | evaluate_target_health = false
35 | }
36 | }
37 |
38 | resource "aws_route53_record" "images_certificate_validation" {
39 | zone_id = data.aws_route53_zone.default.zone_id
40 | name = aws_acm_certificate.images.domain_validation_options.*.resource_record_name[0]
41 | type = aws_acm_certificate.images.domain_validation_options.*.resource_record_type[0]
42 | records = [aws_acm_certificate.images.domain_validation_options.*.resource_record_value[0]]
43 | ttl = 60
44 | }
45 |
46 | resource "aws_route53_record" "ui" {
47 | count = var.stage == "prod" ? 1 : 0
48 |
49 | zone_id = data.aws_route53_zone.default.zone_id
50 | name = "www"
51 | type = "CNAME"
52 | records = ["cname.vercel-dns.com"]
53 | ttl = 60
54 | }
55 |
56 | resource "aws_route53_record" "ui_apex" {
57 | count = var.stage == "prod" ? 1 : 0
58 |
59 | zone_id = data.aws_route53_zone.default.zone_id
60 | name = local.domain
61 | type = "A"
62 | records = ["76.76.21.21"]
63 | ttl = 60
64 | }
65 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/Meta.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from '@mui/material';
2 | import Head from 'next/head';
3 | import { useRouter } from 'next/router';
4 | import React, { useMemo } from 'react';
5 | import urlJoin from 'url-join';
6 | import { useTranslate } from '@/hooks/translateHooks';
7 |
8 | export type MetaProps = {
9 | title?: string;
10 | };
11 |
12 | const Meta: React.FC = React.memo(props => {
13 | const { title } = props;
14 |
15 | const router = useRouter();
16 |
17 | const { t } = useTranslate();
18 |
19 | const theme = useTheme();
20 |
21 | const titleText = useMemo(() => {
22 | if (!title) {
23 | return t.APP_NAME;
24 | }
25 | return `${title} | ${t.APP_NAME}`;
26 | }, [t.APP_NAME, title]);
27 |
28 | return (
29 |
30 | {/* basic */}
31 |
32 |
33 |
34 |
35 |
36 | {/* title */}
37 |
38 | {titleText}
39 |
40 |
41 | {/* description */}
42 |
43 |
44 |
45 | {/* image */}
46 |
47 |
48 |
49 |
53 | {/* TODO: locale によって切り替える */}
54 |
55 |
56 | {/* ogp */}
57 |
58 |
62 |
63 | {/* twitter */}
64 |
65 |
66 |
67 | );
68 | });
69 |
70 | Meta.displayName = 'Meta';
71 |
72 | export default Meta;
73 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
70 | ));
71 |
72 | return {
73 | ...initialProps,
74 | styles: [
75 | ...emotionStyleTags,
76 | ...React.Children.toArray(initialProps.styles),
77 | ],
78 | };
79 | };
80 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/frontend/src/lib/apiClient.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import urlJoin from 'url-join';
3 | import { UnsupportedImageFormatError } from '@/lib/errors';
4 | import { Image } from '@/models/image';
5 | import { Lgtm } from '@/models/lgtm';
6 | import { Report, ReportType } from '@/models/report';
7 |
8 | type ReportRaw = {
9 | id: string;
10 | lgtm_id: string;
11 | type: ReportType;
12 | text: string;
13 | created_at: string;
14 | };
15 |
16 | type ErrorResponse = {
17 | code: string;
18 | };
19 |
20 | export class ApiClient {
21 | public static async getLgtms(options: {
22 | after?: string;
23 | random: boolean;
24 | }): Promise {
25 | const endpoint = this.buildEndpoint('v1', 'lgtms');
26 | const { data } = await axios.get(endpoint, {
27 | params: { after: options.after, random: options.random },
28 | });
29 | return data;
30 | }
31 |
32 | public static async createLgtmFromBase64(
33 | base64: string,
34 | contentType: string,
35 | ): Promise {
36 | return this.createLgtm({ base64, content_type: contentType });
37 | }
38 |
39 | public static async createLgtmFromUrl(url: string): Promise {
40 | return this.createLgtm({ url });
41 | }
42 |
43 | public static async searchImages(q: string): Promise {
44 | const endpoint = this.buildEndpoint('v1', 'images');
45 | const response = await axios.get(endpoint, { params: { q } });
46 | return response.data;
47 | }
48 |
49 | public static async createReport(
50 | lgtmId: string,
51 | type: ReportType,
52 | text: string,
53 | ): Promise {
54 | const endpoint = this.buildEndpoint('v1', 'reports');
55 | const { data } = await axios.post(endpoint, {
56 | lgtm_id: lgtmId,
57 | type,
58 | text,
59 | });
60 | return this.reportFromRaw(data);
61 | }
62 |
63 | private static async createLgtm(
64 | body: { base64: string; content_type: string } | { url: string },
65 | ): Promise {
66 | const endpoint = this.buildEndpoint('v1', 'lgtms');
67 | const validateStatus = (status: number) => {
68 | return (status >= 200 && status < 300) || status === 400;
69 | };
70 | // TODO: エラー時の型指定にもっといい書き方無いか?要調査
71 | const response = await axios.post(endpoint, body, {
72 | validateStatus,
73 | });
74 | if (response.status === 201) {
75 | const data = response.data as Lgtm;
76 | return data;
77 | }
78 | const data = response.data as ErrorResponse;
79 | switch (data.code) {
80 | case 'UNSUPPORTED_IMAGE_FORMAT':
81 | throw new UnsupportedImageFormatError();
82 | default:
83 | throw new Error(data.code);
84 | }
85 | }
86 |
87 | private static buildEndpoint(...paths: string[]): string {
88 | return urlJoin(process.env.NEXT_PUBLIC_API_ORIGIN, ...paths);
89 | }
90 |
91 | private static reportFromRaw(raw: ReportRaw): Report {
92 | return {
93 | ...raw,
94 | lgtmId: raw.lgtm_id,
95 | createdAt: new Date(raw.created_at),
96 | };
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/frontend/src/locales/ja.tsx:
--------------------------------------------------------------------------------
1 | import Typography from '@mui/material/Typography';
2 | import React from 'react';
3 | import Link from '@/components/utils/Link';
4 | import { Routes } from '@/routes';
5 | import { Translate } from './translate';
6 |
7 | // TODO: 整理
8 | export const ja: Translate = {
9 | ALERT: 'LGTM Generator は 2023 年 6 月中にサービス終了予定です。',
10 |
11 | APP_NAME: 'LGTM Generator',
12 | APP_DESCRIPTION:
13 | 'シンプルな LGTM 画像作成サービスです。 LGTM とは「Looks Good To Me」の略で、コードレビューのときなどに用いられるネットスラングの一種です。',
14 |
15 | PRECAUTIONS: '利用上の注意',
16 | PRECAUTIONS_ITEMS: [
17 | '本サービスを利用して生成された画像に関する一切の責任はご利用者様にご負担いただきます。ご利用者様が生成した画像に関し、第三者が損害を被った場合、運営者はご利用者様に代わっての責任は一切負いません。',
18 | '本サービスを利用して生成された画像はインターネット上に公開されます。',
19 | '元画像の著作権などに注意してください。公序良俗に反する画像や違法な画像を作成しないでください。これらの画像、その他運営者が不適切と判断した画像は予告無しに削除することがあります。',
20 | '過剰な数のリクエストを送信してサービスに負荷をかける行為はおやめください。',
21 | 'その他、悪質な利用方法が確認された場合、特定のご利用者様を予告無しにアクセス禁止にすることがあります。',
22 | ],
23 | PRIVACY_POLICY: 'プライバシーポリシー',
24 |
25 | PLEASE_READ_PRECAUTIONS: (
26 |
27 | LGTM 画像を生成する前に
28 | theme.palette.primary.main,
33 | textDecoration: 'underline',
34 | }}
35 | >
36 | 利用上の注意
37 |
38 | をお読みください。
39 |
40 | ),
41 |
42 | COPIED_TO_CLIPBOARD: 'クリップボードにコピーしました',
43 |
44 | CONFIRM_GENERATION: 'この画像で LGTM 画像を生成しますか?',
45 | GENERATE: '生成',
46 | CANCEL: 'キャンセル',
47 |
48 | ILLEGAL: '法律違反 ( 著作権侵害、プライバシー侵害、名誉毀損等 )',
49 | INAPPROPRIATE: '不適切なコンテンツ',
50 | OTHER: 'その他',
51 |
52 | SUPPLEMENT: '( 任意 ) 補足',
53 |
54 | SEND: '送信',
55 |
56 | NO_FAVORITES: 'お気に入りした LGTM 画像はありません。',
57 |
58 | RANDOM: 'ランダムに表示',
59 | RELOAD: '再読み込み',
60 | SEE_MORE: 'もっと見る',
61 |
62 | KEYWORD: 'キーワード',
63 |
64 | LGTM: 'LGTM',
65 | IMAGE_SEARCH: '画像検索',
66 | FAVORITES: 'お気に入り',
67 |
68 | UPLOAD: 'アップロード',
69 |
70 | NOT_FOUND: 'お探しのページは見つかりませんでした',
71 |
72 | USE_OF_ACCESS_ANALYSIS_TOOLS: 'アクセス解析ツールについて',
73 | USE_OF_ACCESS_ANALYSIS_TOOLS_CONTENT: (
74 | <>
75 | 当サイトでは、 Google によるアクセス解析ツール「 Google
76 | アナリティクス」を利用しています。この Google
77 | アナリティクスはトラフィックデータの収集のために Cookie
78 | を使用しています。このトラフィックデータは匿名で収集されており、個人を特定するものではありません。この機能は
79 | Cookie
80 | を無効にすることで収集を拒否することが出来ますので、お使いのブラウザの設定をご確認ください。この規約に関して、詳しくは{' '}
81 | theme.palette.primary.main,
86 | textDecoration: 'underline',
87 | }}
88 | >
89 | Google アナリティクス利用規約
90 | {' '}
91 | を参照してください。
92 | >
93 | ),
94 | UPDATING_PRIVACY_POLICY: 'プライバシーポリシーの変更について',
95 | UPDATING_PRIVACY_POLICY_CONTENT:
96 | '当サイトは、個人情報に関して適用される日本の法令を遵守するとともに、本ポリシーの内容を適宜見直しその改善に努めます。修正された最新のプライバシーポリシーは常に本ページにて開示されます。',
97 |
98 | LOADING: '読込中',
99 |
100 | GENERATED_LGTM_IMAGE: 'LGTM 画像を生成しました',
101 | UNSUPPORTED_IMAGE_FORMAT: 'サポートしていない画像形式です',
102 | LGTM_IMAGE_GENERATION_FAILED: 'LGTM 画像の生成に失敗しました',
103 |
104 | SENT: '送信しました',
105 | SENDING_FAILED: '送信に失敗しました',
106 |
107 | FILE_TOO_LARGE: 'ファイルサイズが大きすぎます',
108 |
109 | FAILED_TO_LOAD_IMAGE: '画像の読み込みに失敗しました',
110 | };
111 |
--------------------------------------------------------------------------------
/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/src/components/pages/Home/SearchImagesPanel.tsx:
--------------------------------------------------------------------------------
1 | import { Search as SearchIcon } from '@mui/icons-material';
2 | import { Box, InputAdornment, TextField } from '@mui/material';
3 | import React, { useCallback, useState, useRef } from 'react';
4 | import Field from '@/components/utils/Field';
5 | import Form from '@/components/utils/Form';
6 | import Loading from '@/components/utils/Loading';
7 | import { useImages, useSearchImages } from '@/hooks/imageHooks';
8 | import { useCreateLgtmFromUrl } from '@/hooks/lgtmHooks';
9 | import { useTranslate } from '@/hooks/translateHooks';
10 | import { Image } from '@/models/image';
11 | import ImageCardList from '../../model/image/ImageCardList';
12 | import ConfirmForm from '../../model/lgtm/LgtmForm';
13 |
14 | type SearchImagesPanelProps = {
15 | show: boolean;
16 | };
17 |
18 | const SearchImagesPanel: React.FC = React.memo(
19 | props => {
20 | const { show } = props;
21 |
22 | const queryInputRef = useRef();
23 |
24 | const images = useImages();
25 | const [query, setQuery] = useState('');
26 | const [openConfirmForm, setOpenConfirmForm] = useState(false);
27 | const [previewUrl, setPreviewUrl] = useState('');
28 |
29 | const { t } = useTranslate();
30 | const { searchImages, loading: searching } = useSearchImages();
31 | const { createLgtmFromUrl, loading: generating } = useCreateLgtmFromUrl();
32 |
33 | const handleChangeQuery = useCallback(
34 | (e: React.ChangeEvent) => {
35 | setQuery(e.currentTarget.value);
36 | },
37 | [],
38 | );
39 |
40 | const handleSearch = useCallback(() => {
41 | const trimmedQuery = query.trim();
42 | if (trimmedQuery === '') {
43 | return;
44 | }
45 |
46 | queryInputRef.current?.blur();
47 | searchImages(trimmedQuery);
48 | }, [query, searchImages]);
49 |
50 | const handleClickImage = useCallback((image: Image) => {
51 | setPreviewUrl(image.url);
52 | setOpenConfirmForm(true);
53 | }, []);
54 |
55 | const handleCloseConfirmForm = useCallback(() => {
56 | setOpenConfirmForm(false);
57 | }, []);
58 |
59 | const handleConfirm = useCallback(() => {
60 | createLgtmFromUrl(previewUrl).then(() => {
61 | setOpenConfirmForm(false);
62 | });
63 | }, [createLgtmFromUrl, previewUrl]);
64 |
65 | return (
66 |
67 |
93 |
94 |
95 |
102 | {searching ? (
103 |
104 | ) : (
105 |
106 | )}
107 |
108 |
109 | );
110 | },
111 | );
112 |
113 | SearchImagesPanel.displayName = 'SearchImagesPanel';
114 |
115 | export default SearchImagesPanel;
116 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/src/lib/imageFileReader.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'react';
2 | import { useToast } from '@/components/providers/ToastProvider';
3 | import { useTranslate } from '@/hooks/translateHooks';
4 | import { FileTooLargeError, UnsupportedImageFormatError } from '@/lib/errors';
5 |
6 | export type ImageFile = {
7 | name: string;
8 | type: string;
9 | dataUrl: string;
10 | };
11 |
12 | export class ImageFileReader {
13 | public static async read(file: File): Promise {
14 | if (file.type === 'image/gif' && file.size > 1024 * 1024 * 4) {
15 | throw new FileTooLargeError();
16 | }
17 |
18 | const dataUrl = await this.readAsDataUrl(file);
19 | const imageFile = { name: file.name, type: file.type, dataUrl };
20 | return await this.resizeImageFile(imageFile, 400).catch(error => {
21 | switch (error.constructor) {
22 | case Error:
23 | if (error.message.startsWith('Failed to load the image')) {
24 | throw new UnsupportedImageFormatError();
25 | }
26 | break;
27 | }
28 | throw error;
29 | });
30 | }
31 |
32 | public static async readAsDataUrl(file: File): Promise {
33 | return new Promise((resolve, reject) => {
34 | const reader = new FileReader();
35 | reader.onload = () => {
36 | resolve(reader.result as string);
37 | };
38 | reader.onerror = err => {
39 | reject(err);
40 | };
41 | reader.readAsDataURL(file);
42 | });
43 | }
44 |
45 | private static async resizeImageFile(
46 | imageFile: ImageFile,
47 | sideLength: number,
48 | ): Promise {
49 | const canvas = document.createElement('canvas');
50 | const ctx = canvas.getContext('2d');
51 | if (!ctx) {
52 | throw new Error('Failed to create canvas');
53 | }
54 |
55 | const image = new Image();
56 | image.src = imageFile.dataUrl;
57 | await new Promise((resolve, reject) => {
58 | image.onload = () => {
59 | resolve();
60 | };
61 | image.onerror = err => {
62 | reject(err);
63 | };
64 | });
65 |
66 | const [destWidth, destHeight] = this.calcSize(
67 | image.width,
68 | image.height,
69 | sideLength,
70 | );
71 | canvas.width = destWidth;
72 | canvas.height = destHeight;
73 | ctx.drawImage(image, 0, 0, destWidth, destHeight);
74 |
75 | return {
76 | ...imageFile,
77 | type: 'image/png',
78 | dataUrl: canvas.toDataURL('image/png'),
79 | };
80 | }
81 |
82 | private static calcSize(
83 | width: number,
84 | height: number,
85 | sideLength: number,
86 | ): [number, number] {
87 | if (width > height) {
88 | return [sideLength, (sideLength / width) * height];
89 | } else {
90 | return [(sideLength / height) * width, sideLength];
91 | }
92 | }
93 | }
94 |
95 | export type LoadImageFn = (file: File) => Promise;
96 |
97 | // TODO: hooks/ に移動
98 | export const useLoadImage = (): {
99 | loadImage: LoadImageFn;
100 | loading: boolean;
101 | } => {
102 | const [loading, setLoading] = useState(false);
103 | const { enqueueWarn, enqueueError } = useToast();
104 | const { t } = useTranslate();
105 |
106 | const loadImage = useCallback(
107 | async (file: File) => {
108 | setLoading(true);
109 | return ImageFileReader.read(file)
110 | .then(imageFile => {
111 | return imageFile;
112 | })
113 | .catch(err => {
114 | switch (err.constructor) {
115 | case FileTooLargeError:
116 | enqueueWarn(`${t.FILE_TOO_LARGE}: ${file.name}`);
117 | break;
118 | case UnsupportedImageFormatError:
119 | enqueueError(t.UNSUPPORTED_IMAGE_FORMAT);
120 | break;
121 | default:
122 | enqueueError(t.FAILED_TO_LOAD_IMAGE);
123 | console.error(err);
124 | break;
125 | }
126 | return null;
127 | })
128 | .finally(() => {
129 | setLoading(false);
130 | });
131 | },
132 | [
133 | enqueueError,
134 | enqueueWarn,
135 | t.FAILED_TO_LOAD_IMAGE,
136 | t.FILE_TOO_LARGE,
137 | t.UNSUPPORTED_IMAGE_FORMAT,
138 | ],
139 | );
140 |
141 | return { loadImage, loading };
142 | };
143 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/src/locales/en.tsx:
--------------------------------------------------------------------------------
1 | import Typography from '@mui/material/Typography';
2 | import React from 'react';
3 | import Link from '@/components/utils/Link';
4 | import { Routes } from '@/routes';
5 | import { ja } from './ja';
6 | import { Translate } from './translate';
7 |
8 | // TODO: 整理
9 | export const en: Translate = {
10 | ALERT: 'LGTM Generator is scheduled to end its service in June 2023.',
11 |
12 | APP_NAME: ja.APP_NAME,
13 | APP_DESCRIPTION:
14 | 'A simple LGTM image generation service. LGTM stands for "Looks Good To Me," a kind of Internet slang used during code reviews.',
15 | PRECAUTIONS: 'Precautions',
16 | PRECAUTIONS_ITEMS: [
17 | 'Users are responsible for any and all liability related to images generated using this service. In the event that a third party suffers damage in relation to an image generated by the user, the operator will not be held liable on behalf of the user.',
18 | 'The images generated by using this service will be published on the Internet.',
19 | 'Please pay attention to the copyright of the original image. Do not generate images that are offensive to public order and morals or that are illegal. These images and any other images that the operator deems inappropriate may be deleted without notice.',
20 | 'Please do not overload this service by sending an excessive number of requests.',
21 | 'In addition, we reserve the right to prohibit access to certain users without notice when malicious use of the site is confirmed.',
22 | ],
23 | PRIVACY_POLICY: 'Privacy Policy',
24 |
25 | PLEASE_READ_PRECAUTIONS: (
26 |
27 | Please read{' '}
28 | theme.palette.primary.main,
33 | textDecoration: 'underline',
34 | }}
35 | >
36 | precautions
37 | {' '}
38 | before generating LGTM image.
39 |
40 | ),
41 |
42 | COPIED_TO_CLIPBOARD: 'Copied to clipboard',
43 |
44 | CONFIRM_GENERATION: 'Would you like to generate LGTM image with this image?',
45 |
46 | GENERATE: 'Generate',
47 | CANCEL: 'Cancel',
48 |
49 | ILLEGAL:
50 | 'Illegal ( Copyright infringement, invasion of privacy, defamation, etc. )',
51 | INAPPROPRIATE: 'Inappropriate content',
52 | OTHER: 'Other',
53 |
54 | SUPPLEMENT: '( Optional ) Supplement',
55 |
56 | SEND: 'Send',
57 |
58 | NO_FAVORITES: 'There are no favorites yet.',
59 |
60 | RANDOM: 'Random',
61 | RELOAD: 'Reload',
62 | SEE_MORE: 'See more',
63 |
64 | KEYWORD: 'Keyword',
65 |
66 | LGTM: 'LGTM',
67 | IMAGE_SEARCH: 'Image Search',
68 | FAVORITES: 'Favorites',
69 |
70 | UPLOAD: 'Upload',
71 |
72 | NOT_FOUND: 'Page not found',
73 |
74 | USE_OF_ACCESS_ANALYSIS_TOOLS: 'Use of Access Analysis Tools',
75 | USE_OF_ACCESS_ANALYSIS_TOOLS_CONTENT: (
76 | <>
77 | This website uses Google Analytics, an access analysis tool provided by
78 | Google. Google Analytics uses cookies to collect traffic data. This
79 | traffic data is collected anonymously and is not personally identifiable.
80 | You can opt out of this feature by disabling cookies, so please check your
81 | browser settings. For more information about these terms, please see the{' '}
82 | theme.palette.primary.main,
87 | textDecoration: 'underline',
88 | }}
89 | >
90 | Google Analytics Terms of Service.
91 |
92 | >
93 | ),
94 | UPDATING_PRIVACY_POLICY: 'Updating Privacy Policy',
95 | UPDATING_PRIVACY_POLICY_CONTENT:
96 | 'In addition to complying with the Japanese laws and regulations applicable to personal information, this website will review and improve the contents of this policy from time to time. The revised and updated privacy policy will always be disclosed on this page.',
97 |
98 | LOADING: 'Loading',
99 |
100 | GENERATED_LGTM_IMAGE: 'Generated LGTM image.',
101 | UNSUPPORTED_IMAGE_FORMAT: 'Unsupported image format.',
102 | LGTM_IMAGE_GENERATION_FAILED: 'LGTM image generation failed.',
103 |
104 | SENT: 'Sent.',
105 | SENDING_FAILED: 'Sending failed.',
106 |
107 | FILE_TOO_LARGE: 'File size is too large.',
108 |
109 | FAILED_TO_LOAD_IMAGE: 'Failed to load image.',
110 | };
111 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/frontend/src/components/pages/Home/LgtmsPanel.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox, FormControlLabel, FormGroup } from '@mui/material';
2 | import Box from '@mui/material/Box';
3 | import Button from '@mui/material/Button';
4 | import React, { useCallback, useEffect, useState } from 'react';
5 | import LgtmCardList from '@/components/model/lgtm/LgtmCardList';
6 | import LgtmForm from '@/components/model/lgtm/LgtmForm';
7 | import Field from '@/components/utils/Field';
8 | import Loading from '@/components/utils/Loading';
9 | import Modal from '@/components/utils/Modal';
10 | import {
11 | useCreateLgtmFromBase64,
12 | useFetchLgtms,
13 | useLgtms,
14 | } from '@/hooks/lgtmHooks';
15 | import { useTranslate } from '@/hooks/translateHooks';
16 | import { DataStorage } from '@/lib/dataStorage';
17 | import { DataUrl } from '@/lib/dataUrl';
18 | import { ImageFile, useLoadImage } from '@/lib/imageFileReader';
19 | import UploadButton from './UploadButton';
20 |
21 | type LgtmsPanelProps = {
22 | show: boolean;
23 | };
24 |
25 | const LgtmsPanel: React.FC = React.memo(props => {
26 | const { show } = props;
27 |
28 | const lgtms = useLgtms();
29 | const [openConfirmForm, setOpenConfirmForm] = useState(false);
30 | const [previewImageFile, setPreviewImageFile] = useState(
31 | null,
32 | );
33 | const [randomly, setRandomly] = useState(false);
34 |
35 | const { t } = useTranslate();
36 | const { fetchLgtms, loading, isTruncated } = useFetchLgtms();
37 | const { createLgtmFromBase64, loading: uploading } =
38 | useCreateLgtmFromBase64();
39 | const { loadImage, loading: loadingImage } = useLoadImage();
40 |
41 | const handleCloseConfirmForm = useCallback(() => {
42 | setOpenConfirmForm(false);
43 | }, []);
44 |
45 | const handleChangeRandomly = useCallback(
46 | (e: React.ChangeEvent) => {
47 | const checked = e.currentTarget.checked;
48 | setRandomly(checked);
49 | DataStorage.setRandomly(checked);
50 |
51 | if (checked) {
52 | fetchLgtms({ reset: true, random: true });
53 | } else {
54 | fetchLgtms({ reset: true, random: false });
55 | }
56 | },
57 | [fetchLgtms],
58 | );
59 |
60 | const handleClickReload = useCallback(() => {
61 | fetchLgtms({ reset: true, random: true });
62 | }, [fetchLgtms]);
63 |
64 | const handleChangeFile = useCallback(
65 | (file: File) => {
66 | loadImage(file).then(imageFile => {
67 | if (!imageFile) return;
68 | setPreviewImageFile(imageFile);
69 | setOpenConfirmForm(true);
70 | });
71 | },
72 | [loadImage],
73 | );
74 |
75 | const handleConfirm = useCallback(() => {
76 | createLgtmFromBase64(
77 | new DataUrl(previewImageFile.dataUrl).toBase64(),
78 | previewImageFile.type,
79 | ).then(() => {
80 | setOpenConfirmForm(false);
81 | });
82 | }, [createLgtmFromBase64, previewImageFile?.dataUrl, previewImageFile?.type]);
83 |
84 | const handleClickMore = useCallback(() => {
85 | fetchLgtms({ after: lgtms.slice(-1)[0]?.id, random: false });
86 | }, [fetchLgtms, lgtms]);
87 |
88 | useEffect(() => {
89 | setRandomly(DataStorage.getRandomly());
90 | fetchLgtms({ random: DataStorage.getRandomly() });
91 | }, [fetchLgtms]);
92 |
93 | return (
94 |
95 |
96 |
97 |
98 |
99 |
100 |
107 |
108 |
109 |
110 |
111 |
119 | }
120 | />
121 |
122 |
123 |
124 | lgtm.id)} />
125 |
126 |
127 |
128 | {loading && }
129 | {!randomly && !loading && isTruncated && (
130 |
133 | )}
134 | {randomly && !loading && isTruncated && (
135 |
138 | )}
139 |
140 |
141 | );
142 | });
143 |
144 | LgtmsPanel.displayName = 'LgtmsPanel';
145 |
146 | export default LgtmsPanel;
147 |
--------------------------------------------------------------------------------
/frontend/src/components/model/report/ReportForm.tsx:
--------------------------------------------------------------------------------
1 | import Button from '@mui/material/Button';
2 | import CardActions from '@mui/material/CardActions';
3 | import CardContent from '@mui/material/CardContent';
4 | import FormControlLabel from '@mui/material/FormControlLabel';
5 | import Radio from '@mui/material/Radio';
6 | import RadioGroup from '@mui/material/RadioGroup';
7 | import { styled } from '@mui/material/styles';
8 | import TextField from '@mui/material/TextField';
9 | import React, { useCallback, useMemo, useState } from 'react';
10 | import LoadableButton from '@/components/utils/LoadableButton';
11 | import ModalCard from '@/components/utils/ModalCard';
12 | import { useSendReport } from '@/hooks/reportHooks';
13 | import { useTranslate } from '@/hooks/translateHooks';
14 | import { ReportType } from '@/models/report';
15 |
16 | const StyledImage = styled('img')({});
17 |
18 | type ReportFormProps = {
19 | lgtmId: string;
20 | open: boolean;
21 | onClose: () => void;
22 | imgSrc: string;
23 | };
24 |
25 | const ReportForm: React.FC = React.memo(props => {
26 | const { lgtmId, imgSrc, open, onClose } = props;
27 |
28 | const [type, setType] = useState(null);
29 | const [text, setText] = useState('');
30 |
31 | const { sendReport, loading } = useSendReport();
32 | const { t } = useTranslate();
33 |
34 | const isValid: boolean = useMemo(() => {
35 | if (!Object.values(ReportType).includes(type)) {
36 | return false;
37 | }
38 | if (text.length > 1000) {
39 | return false;
40 | }
41 | return true;
42 | }, [text.length, type]);
43 |
44 | const handleClose = useCallback(() => {
45 | if (loading) return;
46 | onClose();
47 | }, [loading, onClose]);
48 |
49 | const handleChangeType = useCallback(
50 | (e: React.ChangeEvent) => {
51 | setType(e.currentTarget.value as ReportType);
52 | },
53 | [],
54 | );
55 |
56 | const handleChangeText = useCallback(
57 | (e: React.ChangeEvent) => {
58 | setText(e.currentTarget.value);
59 | },
60 | [],
61 | );
62 |
63 | const handleSendReport = useCallback(() => {
64 | sendReport(lgtmId, type, text).then(() => {
65 | setText('');
66 | setType(null);
67 | onClose();
68 | });
69 | }, [lgtmId, onClose, sendReport, text, type]);
70 |
71 | return (
72 |
73 |
81 |
92 |
93 | }
96 | label={t.ILLEGAL}
97 | disabled={loading}
98 | />
99 | }
102 | label={t.INAPPROPRIATE}
103 | disabled={loading}
104 | />
105 |
112 | }
113 | label={t.OTHER}
114 | disabled={loading}
115 | />
116 |
117 |
128 |
129 |
130 |
139 |
147 | {t.SEND}
148 |
149 |
150 |
151 | );
152 | });
153 |
154 | ReportForm.displayName = 'ReportForm';
155 |
156 | export default ReportForm;
157 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/Header.tsx:
--------------------------------------------------------------------------------
1 | import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
2 | import TranslateIcon from '@mui/icons-material/Translate';
3 | import { useMediaQuery, useTheme } from '@mui/material';
4 | import AppBar from '@mui/material/AppBar';
5 | import Box from '@mui/material/Box';
6 | import Button from '@mui/material/Button';
7 | import ClickAwayListener from '@mui/material/ClickAwayListener';
8 | import List from '@mui/material/List';
9 | import ListItem from '@mui/material/ListItem';
10 | import ListItemButton from '@mui/material/ListItemButton';
11 | import ListItemText from '@mui/material/ListItemText';
12 | import Paper from '@mui/material/Paper';
13 | import Popper from '@mui/material/Popper';
14 | import Toolbar from '@mui/material/Toolbar';
15 | import Typography from '@mui/material/Typography';
16 | import { useRouter } from 'next/router';
17 | import React, { useCallback, useMemo, useState } from 'react';
18 | import Link from '@/components/utils/Link';
19 | import { Routes } from '@/routes';
20 |
21 | type TranslateListItemProps = {
22 | text: string;
23 | locale: string;
24 | onClick: () => void;
25 | };
26 |
27 | const TranslateListItem: React.FC = React.memo(
28 | props => {
29 | const { text, locale, onClick } = props;
30 | const router = useRouter();
31 | const currentLocale = router.locale;
32 |
33 | const selected = useMemo(() => {
34 | return currentLocale === locale;
35 | }, [currentLocale, locale]);
36 |
37 | return (
38 |
39 |
40 |
41 | theme.palette.primary.main
48 | : undefined,
49 | fontWeight: selected ? 'bold' : undefined,
50 | },
51 | }}
52 | />
53 |
54 |
55 |
56 | );
57 | },
58 | );
59 |
60 | TranslateListItem.displayName = 'TranslateListItem';
61 |
62 | const Header: React.FC = React.memo(() => {
63 | const theme = useTheme();
64 |
65 | const isSmDown = useMediaQuery(theme.breakpoints.down('sm'));
66 |
67 | const [translateButtonEl, setTranslateButtonEl] =
68 | useState(null);
69 |
70 | const handleClickTranslate = useCallback(
71 | (e: React.MouseEvent) => {
72 | setTranslateButtonEl(e.currentTarget);
73 | },
74 | [],
75 | );
76 |
77 | const handleClickTranslateMenuItem = useCallback(() => {
78 | setTranslateButtonEl(null);
79 | }, []);
80 |
81 | const handleClickOutsideTranslateMenu = useCallback(() => {
82 | setTranslateButtonEl(null);
83 | }, []);
84 |
85 | return (
86 |
87 |
88 |
89 |
90 | ({
93 | fontFamily: 'Archivo Black',
94 | fontSize: theme => theme.typography.h4.fontSize,
95 | [theme.breakpoints.down('sm')]: {
96 | fontSize: theme => theme.typography.h5.fontSize,
97 | },
98 | })}
99 | >
100 | LGTM Generator
101 |
102 |
103 |
104 |
116 |
121 |
122 |
123 |
124 |
129 |
134 |
135 |
136 |
137 |
138 |
139 |
140 | );
141 | });
142 |
143 | Header.displayName = 'Header';
144 |
145 | export default Header;
146 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/src/hooks/lgtmHooks.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useMemo, useState } from 'react';
2 | import { useRecoilValue, useSetRecoilState } from 'recoil';
3 | import { useToast } from '@/components/providers/ToastProvider';
4 | import { ApiClient } from '@/lib/apiClient';
5 | import { DataStorage } from '@/lib/dataStorage';
6 | import { UnsupportedImageFormatError } from '@/lib/errors';
7 | import { lgtmsState, favoriteIdsState } from '@/recoil/atoms';
8 | import { Lgtm } from '@/models/lgtm';
9 | import { useTranslate } from './translateHooks';
10 |
11 | export const useLgtms = (): Lgtm[] => {
12 | return useRecoilValue(lgtmsState);
13 | };
14 |
15 | export type FetchLgtmsFn = (options: {
16 | reset?: boolean;
17 | after?: string;
18 | random: boolean;
19 | }) => Promise;
20 |
21 | export const useFetchLgtms = (): {
22 | fetchLgtms: FetchLgtmsFn;
23 | loading: boolean;
24 | isTruncated: boolean;
25 | } => {
26 | const perPage = useMemo(() => 20, []);
27 | const [loading, setLoading] = useState(false);
28 | const [isTruncated, setIsTruncated] = useState(false);
29 | const setLgtms = useSetRecoilState(lgtmsState);
30 |
31 | const fetchLgtms = useCallback(
32 | async (options: { reset?: boolean; after?: string; random: boolean }) => {
33 | if (options.reset) {
34 | setLgtms([]);
35 | }
36 | setLoading(true);
37 | await ApiClient.getLgtms(options)
38 | .then(lgtms => {
39 | setLgtms(prev => [...prev, ...lgtms]);
40 | setIsTruncated(lgtms.length === perPage);
41 | })
42 | .finally(() => {
43 | setLoading(false);
44 | });
45 | },
46 | [perPage, setLgtms],
47 | );
48 |
49 | useEffect(() => {
50 | return () => {
51 | setLgtms([]);
52 | };
53 | }, [setLgtms]);
54 |
55 | return { fetchLgtms, loading, isTruncated };
56 | };
57 |
58 | export type CreateLgtmFromBase64Fn = (
59 | base64: string,
60 | contentType: string,
61 | ) => Promise;
62 |
63 | // TODO: fromUrl と共通化する
64 | export const useCreateLgtmFromBase64 = (): {
65 | createLgtmFromBase64: CreateLgtmFromBase64Fn;
66 | loading: boolean;
67 | } => {
68 | const setLgtms = useSetRecoilState(lgtmsState);
69 | const [loading, setLoading] = useState(false);
70 |
71 | const { enqueueSuccess, enqueueError } = useToast();
72 | const { t } = useTranslate();
73 |
74 | const createLgtmFromBase64 = useCallback(
75 | async (base64: string, contentType: string) => {
76 | setLoading(true);
77 | await ApiClient.createLgtmFromBase64(base64, contentType)
78 | .then(lgtm => {
79 | setLgtms(prev => [lgtm, ...prev]);
80 | enqueueSuccess(t.GENERATED_LGTM_IMAGE);
81 | })
82 | .catch(err => {
83 | switch (err.constructor) {
84 | case UnsupportedImageFormatError:
85 | enqueueError(t.UNSUPPORTED_IMAGE_FORMAT);
86 | break;
87 | default:
88 | enqueueError(t.LGTM_IMAGE_GENERATION_FAILED);
89 | console.error(err);
90 | break;
91 | }
92 | })
93 | .finally(() => {
94 | setLoading(false);
95 | });
96 | },
97 | [
98 | enqueueError,
99 | enqueueSuccess,
100 | setLgtms,
101 | t.GENERATED_LGTM_IMAGE,
102 | t.LGTM_IMAGE_GENERATION_FAILED,
103 | t.UNSUPPORTED_IMAGE_FORMAT,
104 | ],
105 | );
106 |
107 | return { createLgtmFromBase64, loading };
108 | };
109 |
110 | export type CreateLgtmFromUrlFn = (url: string) => Promise;
111 |
112 | export const useCreateLgtmFromUrl = (): {
113 | createLgtmFromUrl: CreateLgtmFromUrlFn;
114 | loading: boolean;
115 | } => {
116 | const setLgtms = useSetRecoilState(lgtmsState);
117 | const [loading, setLoading] = useState(false);
118 |
119 | const { enqueueSuccess, enqueueError } = useToast();
120 | const { t } = useTranslate();
121 |
122 | const createLgtmFromUrl = useCallback(
123 | async (url: string) => {
124 | setLoading(true);
125 | await ApiClient.createLgtmFromUrl(url)
126 | .then(lgtm => {
127 | setLgtms(prev => [lgtm, ...prev]);
128 | enqueueSuccess(t.GENERATED_LGTM_IMAGE);
129 | })
130 | .catch(err => {
131 | switch (err.constructor) {
132 | case UnsupportedImageFormatError:
133 | enqueueError(t.UNSUPPORTED_IMAGE_FORMAT);
134 | break;
135 | default:
136 | enqueueError(t.LGTM_IMAGE_GENERATION_FAILED);
137 | console.error(err);
138 | break;
139 | }
140 | })
141 | .finally(() => {
142 | setLoading(false);
143 | });
144 | },
145 | [
146 | enqueueError,
147 | enqueueSuccess,
148 | setLgtms,
149 | t.GENERATED_LGTM_IMAGE,
150 | t.LGTM_IMAGE_GENERATION_FAILED,
151 | t.UNSUPPORTED_IMAGE_FORMAT,
152 | ],
153 | );
154 |
155 | return { createLgtmFromUrl, loading };
156 | };
157 |
158 | export const useFavoriteIds = (): string[] => {
159 | return useRecoilValue(favoriteIdsState);
160 | };
161 |
162 | export type AddFavoriteIdFn = (id: string) => void;
163 |
164 | export const useAddFavoriteId = (): { addFavoriteId: AddFavoriteIdFn } => {
165 | const setFavoriteId = useSetRecoilState(favoriteIdsState);
166 |
167 | const addFavoriteId = useCallback(
168 | (id: string) => {
169 | setFavoriteId(prev => {
170 | const after = [id, ...prev];
171 | DataStorage.saveFavoriteIds(after);
172 | return after;
173 | });
174 | },
175 | [setFavoriteId],
176 | );
177 |
178 | return { addFavoriteId };
179 | };
180 |
181 | export type RemoveFavoriteIdFn = (id: string) => void;
182 |
183 | export const useRemoveFavoriteId = (): {
184 | removeFavoriteId: RemoveFavoriteIdFn;
185 | } => {
186 | const setFavoriteId = useSetRecoilState(favoriteIdsState);
187 |
188 | const removeFavoriteId = useCallback(
189 | (id: string) => {
190 | setFavoriteId(prev => {
191 | const after = prev.filter(prevId => prevId !== id);
192 | DataStorage.saveFavoriteIds(after);
193 | return after;
194 | });
195 | },
196 | [setFavoriteId],
197 | );
198 |
199 | return { removeFavoriteId };
200 | };
201 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/src/components/model/lgtm/LgtmCardButtonGroup.tsx:
--------------------------------------------------------------------------------
1 | import FavoriteIcon from '@mui/icons-material/Favorite';
2 | import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
3 | import FileCopyOutlinedIcon from '@mui/icons-material/FileCopyOutlined';
4 | import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined';
5 | import Button from '@mui/material/Button';
6 | import ButtonGroup from '@mui/material/ButtonGroup';
7 | import ClickAwayListener from '@mui/material/ClickAwayListener';
8 | import { orange, pink } from '@mui/material/colors';
9 | import Divider from '@mui/material/Divider';
10 | import List from '@mui/material/List';
11 | import ListItem from '@mui/material/ListItem';
12 | import ListItemButton from '@mui/material/ListItemButton';
13 | import ListItemText from '@mui/material/ListItemText';
14 | import Paper from '@mui/material/Paper';
15 | import Popper from '@mui/material/Popper';
16 | import copy from 'copy-to-clipboard';
17 | import React, { useCallback, useMemo, useState } from 'react';
18 | import urlJoin from 'url-join';
19 | import { useToast } from '@/components/providers/ToastProvider';
20 | import ReportForm from '@/components/model/report/ReportForm';
21 | import {
22 | useAddFavoriteId,
23 | useFavoriteIds,
24 | useRemoveFavoriteId,
25 | } from '@/hooks/lgtmHooks';
26 | import { useTranslate } from '@/hooks/translateHooks';
27 |
28 | type LgtmCardButtonGroupProps = {
29 | id: string;
30 | };
31 |
32 | const LgtmCardButtonGroup: React.FC = React.memo(
33 | props => {
34 | const { id } = props;
35 |
36 | const { enqueueSuccess } = useToast();
37 | const { addFavoriteId } = useAddFavoriteId();
38 | const { removeFavoriteId } = useRemoveFavoriteId();
39 | const { t } = useTranslate();
40 |
41 | const favoriteIds = useFavoriteIds();
42 | const [copyButtonEl, setCopyButtonEl] = useState(
43 | null,
44 | );
45 | const [openReportForm, setOpenReportForm] = useState(false);
46 |
47 | const favorited = useMemo(() => {
48 | return favoriteIds.includes(id);
49 | }, [favoriteIds, id]);
50 |
51 | const handleClickFavoriteButton = useCallback(() => {
52 | addFavoriteId(id);
53 | }, [addFavoriteId, id]);
54 |
55 | const handleClickUnfavoriteButton = useCallback(() => {
56 | removeFavoriteId(id);
57 | }, [id, removeFavoriteId]);
58 |
59 | const handleClickCopyButton = useCallback(
60 | (e: React.MouseEvent) => {
61 | setCopyButtonEl(e.currentTarget);
62 | },
63 | [],
64 | );
65 |
66 | const handleClickOutsideCopyList = useCallback(() => {
67 | setCopyButtonEl(null);
68 | }, []);
69 |
70 | const handleClickCopyMarkdownLink = useCallback(() => {
71 | const text = `})`;
75 | copy(text);
76 | enqueueSuccess(t.COPIED_TO_CLIPBOARD);
77 | setCopyButtonEl(null);
78 | }, [enqueueSuccess, id, t.COPIED_TO_CLIPBOARD]);
79 |
80 | const handleClickCopyHtmlLink = useCallback(() => {
81 | const text = `
`;
85 | copy(text);
86 | enqueueSuccess(t.COPIED_TO_CLIPBOARD);
87 | setCopyButtonEl(null);
88 | }, [enqueueSuccess, id, t.COPIED_TO_CLIPBOARD]);
89 |
90 | const handleClickReportButton = useCallback(() => {
91 | setOpenReportForm(true);
92 | }, []);
93 |
94 | const handleCloseReportForm = useCallback(() => {
95 | setOpenReportForm(false);
96 | }, []);
97 |
98 | return (
99 | <>
100 |
106 |
107 | {/* コピー */}
108 |
114 |
119 |
120 |
121 |
122 |
123 |
127 |
133 |
134 |
135 |
136 |
137 |
141 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 | {/* お気に入り */}
155 | {favorited ? (
156 |
169 | ) : (
170 |
183 | )}
184 |
185 | {/* 通報 */}
186 |
199 |
200 | >
201 | );
202 | },
203 | );
204 |
205 | LgtmCardButtonGroup.displayName = 'LgtmCardButtonGroup';
206 |
207 | export default LgtmCardButtonGroup;
208 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------