├── .changeset ├── README.md ├── angry-schools-smoke.md ├── clean-olives-attack.md ├── config.json ├── kind-shirts-cross.md ├── many-cobras-dress.md ├── metal-adults-care.md ├── small-pumpkins-divide.md ├── two-elephants-roll.md └── wet-seahorses-kiss.md ├── .commitlintrc.json ├── .devcontainer ├── .env ├── .gitignore ├── Dockerfile ├── Dockerfile.minio ├── devcontainer.json ├── docker-compose.yml └── policy.json ├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ └── gh-pages.yml ├── .gitignore ├── .husky └── commit-msg ├── .huskyrc.json ├── .npmrc ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── callstack.png ├── docker-compose.yml ├── env_files ├── .gitignore ├── backend.prod.env.example ├── codespace.dev.env ├── logto.prod.env.sample ├── postgres.dev.env ├── postgres.prod.env.example └── prod.env.example ├── graphql.config.ts ├── package.json ├── packages ├── backend │ ├── .dockerignore │ ├── .eslintignore │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── .npmrc │ ├── .swcrc │ ├── codegen.ts │ ├── package.json │ ├── pnpm-lock.yaml │ ├── prisma │ │ ├── migrations │ │ │ ├── 20230704065830_init │ │ │ │ └── migration.sql │ │ │ ├── 20230711073849_20230711_dev │ │ │ │ └── migration.sql │ │ │ ├── 20230711134019_20230711_dev │ │ │ │ └── migration.sql │ │ │ ├── 20230806070641_ │ │ │ │ └── migration.sql │ │ │ ├── 20230806124111_ │ │ │ │ └── migration.sql │ │ │ ├── 20230806124217_ │ │ │ │ └── migration.sql │ │ │ ├── 20230925101132_dev │ │ │ │ └── migration.sql │ │ │ ├── 20231028023451_ │ │ │ │ └── migration.sql │ │ │ └── migration_lock.toml │ │ ├── schema.prisma │ │ └── seed.ts │ ├── src │ │ ├── context.ts │ │ ├── env.ts │ │ ├── index.ts │ │ ├── lib │ │ │ ├── error │ │ │ │ ├── error.ts │ │ │ │ └── handling.ts │ │ │ ├── generated │ │ │ │ ├── .gitignore │ │ │ │ └── resolver-types.ts │ │ │ ├── plugins │ │ │ │ └── useAuthMock.ts │ │ │ ├── scalars │ │ │ │ ├── BioString.ts │ │ │ │ ├── BodyString.ts │ │ │ │ ├── HandleString.ts │ │ │ │ ├── ScreenNameString.ts │ │ │ │ └── TitleString.ts │ │ │ ├── security │ │ │ │ ├── armor.ts │ │ │ │ ├── authn.ts │ │ │ │ └── authz.ts │ │ │ └── webhook │ │ │ │ └── webhook.ts │ │ ├── resolver.ts │ │ ├── resolvers │ │ │ ├── mutations │ │ │ │ └── panelMutation.ts │ │ │ ├── queries │ │ │ │ └── panelQuery.ts │ │ │ └── types │ │ │ │ ├── postType.ts │ │ │ │ └── userType.ts │ │ └── schema.ts │ └── tsconfig.json ├── docs │ ├── .gitignore │ ├── README.md │ ├── babel.config.js │ ├── docs │ │ ├── intro.md │ │ ├── tutorial-basics │ │ │ ├── _category_.json │ │ │ ├── congratulations.md │ │ │ ├── create-a-blog-post.md │ │ │ ├── create-a-document.md │ │ │ ├── create-a-page.md │ │ │ ├── deploy-your-site.md │ │ │ └── markdown-features.mdx │ │ └── tutorial-extras │ │ │ ├── _category_.json │ │ │ ├── img │ │ │ ├── docsVersionDropdown.png │ │ │ └── localeDropdown.png │ │ │ ├── manage-docs-versions.md │ │ │ └── translate-your-site.md │ ├── docusaurus.config.js │ ├── package.json │ ├── pnpm-lock.yaml │ ├── sidebars.js │ ├── src │ │ ├── components │ │ │ └── HomepageFeatures │ │ │ │ ├── index.js │ │ │ │ └── styles.module.css │ │ ├── css │ │ │ └── custom.css │ │ └── pages │ │ │ ├── index.js │ │ │ ├── index.module.css │ │ │ └── markdown-page.md │ └── static │ │ ├── .nojekyll │ │ └── img │ │ ├── docusaurus-social-card.jpg │ │ ├── docusaurus.png │ │ ├── favicon.ico │ │ ├── logo.svg │ │ ├── undraw_docusaurus_mountain.svg │ │ ├── undraw_docusaurus_react.svg │ │ └── undraw_docusaurus_tree.svg ├── frontend │ ├── .dockerignore │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── .npmrc │ ├── .storybook │ │ ├── main.ts │ │ └── preview.ts │ ├── codegen.ts │ ├── index.html │ ├── package.json │ ├── pnpm-lock.yaml │ ├── postcss.config.js │ ├── public │ │ └── callstack.png │ ├── src │ │ ├── 404.tsx │ │ ├── _document.tsx │ │ ├── callback.tsx │ │ ├── config.ts │ │ ├── env.ts │ │ ├── features │ │ │ ├── index │ │ │ │ └── pages │ │ │ │ │ └── IndexPage.tsx │ │ │ ├── posts │ │ │ │ ├── components │ │ │ │ │ ├── PostCard.tsx │ │ │ │ │ ├── PostDetailCard.tsx │ │ │ │ │ └── UserCardForPost.tsx │ │ │ │ └── pages │ │ │ │ │ ├── PostDetailPage.tsx │ │ │ │ │ └── PostsPage.tsx │ │ │ └── users │ │ │ │ ├── components │ │ │ │ ├── PostCardForUser.tsx │ │ │ │ ├── UserCard.tsx │ │ │ │ ├── UserDetailBioInput.tsx │ │ │ │ ├── UserDetailCard.tsx │ │ │ │ ├── UserDetailHandleInput.tsx │ │ │ │ ├── UserDetailProfileImageInput.tsx │ │ │ │ └── UserDetailScreenNameInput.tsx │ │ │ │ └── pages │ │ │ │ ├── UserDetailPage.tsx │ │ │ │ └── UsersPage.tsx │ │ ├── index.css │ │ ├── lib │ │ │ ├── generated │ │ │ │ ├── .gitignore │ │ │ │ ├── fragment-masking.ts │ │ │ │ ├── gql.ts │ │ │ │ ├── graphql.ts │ │ │ │ └── index.ts │ │ │ ├── provider │ │ │ │ ├── authn │ │ │ │ │ ├── AuthnProvider.tsx │ │ │ │ │ └── useAuthn.ts │ │ │ │ └── urql │ │ │ │ │ └── UrqlProvider.tsx │ │ │ └── route │ │ │ │ └── ProtectedRouter.tsx │ │ ├── main.tsx │ │ ├── route.tsx │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ └── vite.config.ts.timestamp-1694775590419-7203cb0b93586.mjs ├── graphql │ └── schemas │ │ └── schema.graphql └── infra │ ├── Dockerfiles │ ├── Backend-Dockerfile │ ├── Frontend-Dockerfile │ └── Minio-Dockerfile │ └── config │ ├── minio │ └── policy.json │ └── nginx-prod │ └── default.conf.template ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/angry-schools-smoke.md: -------------------------------------------------------------------------------- 1 | --- 2 | "frontend": major 3 | "backend": major 4 | --- 5 | 6 | CI のセットアップ並びにコードの修正 7 | -------------------------------------------------------------------------------- /.changeset/clean-olives-attack.md: -------------------------------------------------------------------------------- 1 | --- 2 | "backend": patch 3 | --- 4 | 5 | resolver.ts の import 先を修正 6 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.changeset/kind-shirts-cross.md: -------------------------------------------------------------------------------- 1 | --- 2 | "frontend": patch 3 | --- 4 | 5 | index.css のコメントを修正 6 | -------------------------------------------------------------------------------- /.changeset/many-cobras-dress.md: -------------------------------------------------------------------------------- 1 | --- 2 | "frontend": patch 3 | --- 4 | 5 | vite のポーリングを有効にし、docker からホットリロードを許可 6 | -------------------------------------------------------------------------------- /.changeset/metal-adults-care.md: -------------------------------------------------------------------------------- 1 | --- 2 | "frontend": major 3 | "backend": major 4 | --- 5 | 6 | docker compose に対応 7 | -------------------------------------------------------------------------------- /.changeset/small-pumpkins-divide.md: -------------------------------------------------------------------------------- 1 | --- 2 | "frontend": patch 3 | "backend": patch 4 | --- 5 | 6 | graphql-codegen の設定と grapqhl スキーマを変更 7 | -------------------------------------------------------------------------------- /.changeset/two-elephants-roll.md: -------------------------------------------------------------------------------- 1 | --- 2 | "backend": patch 3 | --- 4 | 5 | api のパスを変更 6 | -------------------------------------------------------------------------------- /.changeset/wet-seahorses-kiss.md: -------------------------------------------------------------------------------- 1 | --- 2 | "frontend": patch 3 | --- 4 | 5 | codegen の設定を追加 6 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } -------------------------------------------------------------------------------- /.devcontainer/.env: -------------------------------------------------------------------------------- 1 | # 各環境変数ファイルのパスを指定 2 | POSTGRES_ENV=../env_files/postgres.dev.env 3 | CODESPACE_ENV=../env_files/codespace.dev.env 4 | 5 | # コンテナから見たスキーマのパスを指定 6 | SCHEMA_PATH=../graphql/schemas/*.graphql 7 | # OPERATION_PATH=../graphql/operations/*.graphql 8 | 9 | # minioの環境変数の設定 10 | MINIO_ROOT_USER=development 11 | MINIO_ROOT_PASSWORD=development 12 | # minioのバケット名を設定 13 | MINIO_BUCKET_NAME=development -------------------------------------------------------------------------------- /.devcontainer/.gitignore: -------------------------------------------------------------------------------- 1 | !.env -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/javascript-node:1-20-bullseye 2 | 3 | # [Optional] Uncomment this section to install additional OS packages. 4 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 5 | # && apt-get -y install --no-install-recommends 6 | 7 | # [Optional] Uncomment if you want to install an additional version of node using nvm 8 | # ARG EXTRA_NODE_VERSION=10 9 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 10 | 11 | # [Optional] Uncomment if you want to install more global node modules 12 | # RUN su node -c "npm install -g " 13 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile.minio: -------------------------------------------------------------------------------- 1 | FROM quay.io/minio/minio:latest 2 | 3 | # developmentバケットの作成 4 | RUN mkdir -p /data/development 5 | # RUN mkdir -p /data/.minio.sys/buckets/development 6 | # COPY ./policy.json /data/.minio.sys/buckets/development/policy.json 7 | 8 | ENTRYPOINT ["minio", "server", "/data", "--console-address", ":9001"] -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node-postgres 3 | { 4 | "name": "Node.js & PostgreSQL", 5 | "dockerComposeFile": "docker-compose.yml", 6 | "service": "app", 7 | "workspaceFolder": "/workspaces/repo", 8 | "features": { 9 | "ghcr.io/devcontainers-contrib/features/pnpm:2": { 10 | "version": "latest" 11 | }, 12 | "ghcr.io/devcontainers-contrib/features/turborepo-npm:1": { 13 | "version": "latest" 14 | }, 15 | "ghcr.io/devcontainers-contrib/features/zsh-plugins:0": { 16 | "plugins": "ssh-agent npm", 17 | "omzPlugins": "https://github.com/zsh-users/zsh-autosuggestions", 18 | "username": "vscode" 19 | } 20 | }, 21 | // Features to add to the dev container. More info: https://containers.dev/features. 22 | // "features": {}, 23 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 24 | // This can be used to network with other containers or with the host. 25 | "forwardPorts": [6173, 5173, 9000, 9001], 26 | // Use 'postCreateCommand' to run commands after the container is created. 27 | "postCreateCommand": "pnpm install && pnpm primig && pnpm priseed", 28 | // Configure tool-specific properties. 29 | "customizations": { 30 | "vscode": { 31 | "extensions": [ 32 | "GitHub.copilot", 33 | "dbaeumer.vscode-eslint", 34 | "esbenp.prettier-vscode", 35 | // deprecated "ms-vscode.vscode-typescript-tslint-plugin", 36 | // "ms-vscode.vscode-typescript-tslint-plugin", 37 | "ms-vscode.vscode-typescript-next", 38 | "prisma.prisma", 39 | "redhat.vscode-yaml", 40 | "zhuangtongfa.material-theme", 41 | "dsznajder.es7-react-js-snippets", 42 | "VisualStudioExptTeam.vscodeintellicode", 43 | "yzhang.markdown-all-in-one", 44 | "naumovs.color-highlight", 45 | "vscode.json-language-features", 46 | "GraphQL.vscode-graphql", 47 | "vscode.git", 48 | "bradlc.vscode-tailwindcss", 49 | "formulahendry.auto-rename-tag", 50 | "PKief.material-icon-theme", 51 | "oderwat.indent-rainbow", 52 | "formulahendry.auto-close-tag", 53 | "markdown.showPreview", 54 | "eamodio.gitlens", 55 | "GitHub.vscode-pull-request-github", 56 | "ms-azuretools.vscode-docker", 57 | "capaj.graphql-codegen-vscode" 58 | ], 59 | "settings": { 60 | "editor.defaultFormatter": "esbenp.prettier-vscode" 61 | } 62 | } 63 | }, 64 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 65 | "remoteUser": "root" 66 | } 67 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | # ネットワークを作成 4 | networks: 5 | app-network: 6 | 7 | services: 8 | app: 9 | build: 10 | context: . 11 | dockerfile: Dockerfile 12 | 13 | volumes: 14 | # - ../..:/workspaces:cached 15 | # ワークスペースにリポジトリのデータをマウント 16 | - type: bind 17 | source: ../ 18 | target: /workspaces/repo 19 | consistency: cached 20 | # ルートのnode_modulesをボリュームにマウント 21 | - type: volume 22 | source: root-data 23 | target: /workspaces/repo/node_modules 24 | # フロントのnode_modulesをボリュームにマウント 25 | - type: volume 26 | source: frontend-data 27 | target: /workspaces/repo/packages/frontend/node_modules 28 | # バックのnode_modulesをボリュームにマウント 29 | - type: volume 30 | source: backend-data 31 | target: /workspaces/repo/packages/backend/node_modules 32 | 33 | environment: 34 | - SCHEMA_PATH=../graphql/schemas/*.graphql 35 | # バックエンドのminioのユーザー名とパスワードを指定 36 | - MINIO_ROOT_USER=${MINIO_ROOT_USER} 37 | - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD} 38 | # バックエンドのminioのバケット名を指定 39 | - MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME} 40 | # - OPERATION_PATH=../graphql/operations/*.graphql 41 | # アプリケーションコンテナの環境変数を読み込む 42 | env_file: ${CODESPACE_ENV} 43 | 44 | # Overrides default command so things don't shut down after the process ends. 45 | command: sleep infinity 46 | 47 | # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. 48 | networks: 49 | - app-network 50 | # depends_on: 51 | # db: 52 | # condition: service_healthy 53 | 54 | db: 55 | image: postgres:latest 56 | restart: unless-stopped 57 | volumes: 58 | - type: volume 59 | source: postgres-data 60 | target: /var/lib/postgresql/data 61 | # ポスグレの環境変数を読み込む 62 | env_file: ${POSTGRES_ENV} 63 | networks: 64 | - app-network 65 | 66 | minio: 67 | build: 68 | context: . 69 | dockerfile: Dockerfile.minio 70 | restart: unless-stopped 71 | volumes: 72 | - type: volume 73 | source: minio-data 74 | target: /data 75 | environment: 76 | - MINIO_ROOT_USER=${MINIO_ROOT_USER} 77 | - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD} 78 | networks: 79 | - app-network 80 | ports: 81 | - 9000:9000 82 | - 9001:9001 83 | volumes: 84 | postgres-data: 85 | minio-data: 86 | root-data: 87 | frontend-data: 88 | backend-data: 89 | -------------------------------------------------------------------------------- /.devcontainer/policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Principal": { 7 | "AWS": ["*"] 8 | }, 9 | "Action": ["s3:GetObject"], 10 | "Resource": ["arn:aws:s3:::development/*"] 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | # masterブランチへのpushに対応する 5 | push: 6 | branches: ["master"] 7 | # プルリクエストでのCIに対応する 8 | pull_request: 9 | types: [opened, synchronize] 10 | 11 | # 手動での実行に対応する 12 | workflow_dispatch: 13 | 14 | 15 | jobs: 16 | check: 17 | runs-on: ubuntu-latest 18 | timeout-minutes: 15 19 | 20 | # 環境変数の読み込み 21 | env: 22 | SCHEMA_PATH: ../graphql/schemas/*.graphql 23 | # OPERATION_PATH: ../graphql/operations/*.graphql 24 | 25 | # 実行ステップ 26 | steps: 27 | - uses: actions/checkout@v2 28 | 29 | - uses: pnpm/action-setup@v2.2.4 30 | with: 31 | version: 8.6.3 32 | 33 | - name: Setup Node.js environment 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: 18 37 | cache: "pnpm" 38 | 39 | # 依存関係のインストール 40 | - name: install dependencies 41 | run: pnpm install --frozen-lockfile 42 | 43 | # 型チェック 44 | - name: typecheck 45 | run: pnpm turbo typecheck 46 | 47 | # ビルド 48 | - name: build 49 | run: pnpm turbo build 50 | 51 | # - name: eslint 52 | # run: pnpm lint 53 | 54 | # - name: prettier 55 | # run: pnpm format 56 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | # Sample workflow for building and deploying a Docusaurus site to GitHub Pages 2 | name: Deploy Docusaurus with GitHub Pages dependencies preinstalled 3 | 4 | on: 5 | # masterブランチへのpushに対応する 6 | push: 7 | branches: ["master"] 8 | # プルリクエストでのCIに対応する 9 | pull_request: 10 | types: [opened, synchronize] 11 | 12 | # 手動での実行に対応する 13 | workflow_dispatch: 14 | 15 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 16 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 17 | concurrency: 18 | group: "pages" 19 | cancel-in-progress: false 20 | 21 | jobs: 22 | build: 23 | runs-on: ubuntu-latest 24 | timeout-minutes: 15 25 | 26 | # 環境変数の読み込み 27 | env: 28 | SCHEMA_PATH: ../graphql/schemas/*.graphql 29 | # OPERATION_PATH: ../graphql/operations/*.graphql 30 | 31 | # 実行ステップ 32 | steps: 33 | - uses: actions/checkout@v2 34 | 35 | - uses: pnpm/action-setup@v2.2.4 36 | with: 37 | version: 8.6.3 38 | 39 | - name: Setup Node.js environment 40 | uses: actions/setup-node@v3 41 | with: 42 | node-version: 18 43 | cache: "pnpm" 44 | 45 | # 依存関係のインストール 46 | - name: install dependencies 47 | run: pnpm install --frozen-lockfile 48 | 49 | - name: Build 50 | run: | 51 | pnpm run docbuild 52 | working-directory: packages/docs 53 | 54 | - name: Upload artifact 55 | uses: actions/upload-pages-artifact@v2 56 | with: 57 | path: packages/docs/build 58 | 59 | # Deployment job 60 | deploy: 61 | # masterブランチのときのみ実行する 62 | if : github.ref == 'refs/heads/master' 63 | # 必要な権限を付与する 64 | permissions: 65 | pages: write # to deploy to Pages 66 | id-token: write 67 | environment: 68 | name: github-pages 69 | url: ${{ steps.deployment.outputs.page_url }} 70 | runs-on: ubuntu-latest 71 | needs: build 72 | steps: 73 | - name: Deploy to GitHub Pages 74 | id: deployment 75 | uses: actions/deploy-pages@v2 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.env 2 | *.key 3 | node_modules 4 | packages/*/secret/* 5 | node-jiti -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm commitlint --edit $1 -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | store-dir=node_modules/.pnpm-store -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | pnpm-lock.yaml -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 160 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 calloc134 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

callstack

2 |

3 | callstack 4 |

5 | 6 |

pnpm + turborepoで構成されたモノリポ構成のボイラープレート

7 | 8 |

9 | GitHub stars 10 | GitHub forks 11 | GitHub issues 12 | GitHub license 13 | GitHub commit 14 | GitHub code size in bytes 15 | GitHub repo size 16 | 17 | ## コンテンツ内容 18 | 19 | - 📋 [概要](#概要) 20 | - ℹ️ [モノリポ構成とは](#モノリポ構成とは) 21 | - 🔧 [全体構成](#全体構成) 22 | - 🔧 [技術スタック](#技術スタック) 23 | - 💻 [バックエンド](#バックエンド) 24 | - 🌐 [フロントエンド](#フロントエンド) 25 | - ⚙️ [開発環境整備](#開発環境整備) 26 | - 🐳 [インフラ環境(開発環境)](#インフラ環境開発環境) 27 | - 🐳 [インフラ環境(本番環境)](#インフラ環境本番環境) 28 | - 🚀 [CI/CD 環境](#cicd-環境) 29 | - 📂 [ディレクトリ構成](#ディレクトリ構成) 30 | - 💻 [起動方法](#起動方法) 31 | - 💻 [開発環境の起動方法](#開発環境の起動方法) 32 | - 💻 [本番環境の起動方法](#本番環境の起動方法) 33 | - 📅 [今後の予定](#今後の予定) 34 | - 📜 [ライセンス](#ライセンス) 35 | 36 | # 概要 37 | 38 | このプロジェクトは、pnpm と turbo を用いて構成されたボイラープレートです。 39 | 現在開発途上です。 40 | 41 | ### モノリポ構成とは 42 | 43 | モノリポ構成とは、複数のプロジェクトを一つのリポジトリで管理する構成のことです。 44 | 45 | ## 全体構成 46 | 47 | このリポジトリは、バックエンドを graphql で、フロントエンドを react で実装した Web アプリケーションをモノリポ構成で管理しています。 48 | 49 | ## 技術スタック 50 | 51 | ### バックエンド 52 | 53 | - graphql-yoga 54 | graphql サーバの立ち上げに使用しています。 55 | - tsx 56 | 開発環境のビルドツールとして利用しています。 57 | - swc 58 | 本番環境のビルドツールとして利用しています。 59 | - prisma 60 | データベースへのアクセスに使用しています。 61 | - grpahql-scalars 62 | 日付型や JSON 型などのスカラー型を graphql-yoga へ追加するために使用しています。 63 | - graphql-codegen 64 | graphql スキーマから、resolver や型定義を生成するために使用しています。 65 | - @envelop/useAuth0 66 | 後述する Logto との連携に使用しています。 67 | - @envelop/useGenericAuth 68 | 認可処理や権限管理に使用しています。 69 | これを用いて graphql の@auth ディレクティブを実装しています。 70 | - graphql-armor 71 | 様々なセキュリティ対策に使用しています。 72 | - @envelop/disable-introspection 73 | GraphQL のイントロスペクションを無効化するために使用しています。 74 | - jsonwebtoken 75 | JWT 認証処理に利用しています。 76 | - jimp 77 | 画像のリサイズ処理に使用しています。 78 | - minio-js 79 | 後述する minio にリサイズ画像をアップロードするために使用しています。 80 | 81 | ### フロントエンド 82 | 83 | - react 84 | フロントエンドのフレームワークとして使用しています。 85 | - vite 86 | ビルドツールとして使用しています。 87 | - @tanstack/react-router 88 | ルーティングに使用しています。 89 | - nextui 90 | tailwind 対応のコンポーネントライブラリとして使用しています。 91 | - tailwindcss 92 | CSS フレームワークとして使用しています。 93 | - storybook 94 | コンポーネントの管理に使用しています。 95 | - urql 96 | graphql クライアントとして使用しています。 97 | - @urql/exchange-auth 98 | 後述する Logto との認証連携に使用しています。 99 | - graphql-codegen 100 | graphql スキーマから、型定義を生成するために使用しています。 101 | - logto-js 102 | 後述する Logto を用いた認証処理に使用しています。 103 | 104 | ### 開発環境整備 105 | 106 | - pnpm 107 | パッケージマネージャとして使用しています。 108 | - turborepo 109 | モノリポ構成の管理に使用しています。 110 | - prettier 111 | コードのフォーマットに使用しています。 112 | - husky 113 | git のフックに使用しています。 114 | - commitlint 115 | コミットメッセージのフォーマットをチェックするために使用しています。 116 | - changesets 117 | バージョン管理とリリース管理に使用しています。 118 | 119 | ### インフラ環境(開発環境) 120 | 121 | - devcontainer 122 | 開発環境の立ち上げに使用しています。 123 | ベースとなるアプリケーションコンテナに加えて、PostgreSQL のデータベースコンテナと minio を立ち上げています。 124 | - minio 125 | AWS S3 互換のオブジェクトストレージです。 126 | 127 | ### インフラ環境(本番環境) 128 | 129 | - docker compose 130 | 本番環境の立ち上げに使用しています。 131 | - Logto 132 | Auth0 互換の認証サービスです。 133 | - minio 134 | AWS S3 互換のオブジェクトストレージです。 135 | - nginx 136 | フロントエンドの HTML ファイルの配信を行っています。 137 | nginx のコンテナ内でフロントエンドのビルドを行い、そのビルド済みのファイルを配信しています。 138 | - traefik 139 | リバースプロキシとして使用しています。 140 | 141 | ### CI/CD 環境 142 | 143 | - Github Actions 144 | ここで、型チェックを行っています。 145 | 現時点では型チェックのみですが、今後はテストコードの実行も行う予定です。 146 | 147 | ## ライセンス 148 | 149 | MIT License 150 | 151 | Copyright (c) 2023 calloc134 152 | -------------------------------------------------------------------------------- /callstack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calloc134/callstack/f54b2e3150c476fd8ac36ad908b4e386ef9c829f/callstack.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | # ネットワークの作成 4 | networks: 5 | # 内部ネットワーク 6 | # バックエンドとlogtoコンテナのみ接続 7 | app-logto-network: 8 | # バックエンドとそれに対応するデータベースのみ接続 9 | app-db-network: 10 | # バックエンドとminioのみ接続 11 | app-minio-network: 12 | # logtoとそれに対応するデータベースのみ接続 13 | logto-db-network: 14 | # リバースプロキシで公開するネットワーク 15 | traefik-public: 16 | name: traefik-public 17 | 18 | # データベースのデータを格納するボリューム 19 | volumes: 20 | # バックエンドのデータベースデータを格納するボリューム 21 | app-db-vol: 22 | # logtoのデータベースデータを格納するボリューム 23 | logto-db-vol: 24 | # minioのデータを格納するボリューム 25 | minio-data: 26 | 27 | services: 28 | # バックエンド用のデータベース 29 | db-app: 30 | container_name: db-app 31 | # ポスグレの最新イメージを使用 32 | image: postgres:latest 33 | # ポスグレの環境変数を読み込む 34 | env_file: ${POSTGRES_ENV} 35 | # ポスグレのデータを格納するボリュームを指定 36 | volumes: 37 | - type: volume 38 | source: app-db-vol 39 | # ポスグレのデータを格納するボリュームのマウント先を指定 40 | target: /var/lib/postgresql/data 41 | # 内部ネットワークに接続 42 | networks: 43 | - app-db-network 44 | # コンテナが停止したら再起動 45 | restart: on-failure 46 | 47 | minio: 48 | container_name: minio 49 | # 画像ストレージ 50 | build: 51 | context: . 52 | dockerfile: packages/infra/Dockerfiles/Minio-Dockerfile 53 | volumes: 54 | - type: volume 55 | source: minio-data 56 | target: /data 57 | environment: 58 | - MINIO_ROOT_USER=${MINIO_ROOT_USER} 59 | - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD} 60 | # バックエンドのminioのバケット名を指定 61 | - MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME} 62 | networks: 63 | - app-minio-network 64 | - traefik-public 65 | labels: 66 | # traefikで公開する 67 | - "traefik.enable=true" 68 | # ホスト名を指定 69 | # 元オリジンのホスト名を指定 70 | - "traefik.http.routers.minio.rule=Host(`${HOSTNAME}`) && PathPrefix(`/${MINIO_BUCKET_NAME}/`)" 71 | - "traefik.http.routers.minio.service=minio" 72 | # 9000番ポートを指定 73 | - "traefik.http.services.minio.loadbalancer.server.port=9000" 74 | # SSLを有効化 75 | - "traefik.http.routers.minio.entrypoints=websecure" 76 | - "traefik.http.routers.minio.tls.certresolver=letsencrypt" 77 | # ホスト名を指定 78 | # minioadminサブドメインを指定 79 | - "traefik.http.routers.minio-admin.rule=Host(`minioadmin.${HOSTNAME}`)" 80 | - "traefik.http.routers.minio-admin.service=minio-admin" 81 | # 9001番ポートを指定 82 | - "traefik.http.services.minio-admin.loadbalancer.server.port=9001" 83 | # SSLを有効化 84 | - "traefik.http.routers.minio-admin.entrypoints=websecure" 85 | - "traefik.http.routers.minio-admin.tls.certresolver=letsencrypt" 86 | # コンテナが停止したら再起動 87 | restart: on-failure 88 | 89 | # logto用のデータベース 90 | db-logto: 91 | container_name: db-logto 92 | # ポスグレの最新イメージを使用 93 | image: postgres:latest 94 | # ポスグレの環境変数を読み込む 95 | env_file: ${POSTGRES_ENV} 96 | # ポスグレのデータを格納するボリュームを指定 97 | volumes: 98 | - type: volume 99 | source: logto-db-vol 100 | # ポスグレのデータを格納するボリュームのマウント先を指定 101 | target: /var/lib/postgresql/data 102 | # 内部ネットワークに接続 103 | networks: 104 | - logto-db-network 105 | # コンテナが停止したら再起動 106 | restart: on-failure 107 | 108 | # logtoコンテナ 109 | logto: 110 | container_name: logto 111 | image: svhd/logto:latest 112 | entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"] 113 | env_file: ${LOGTO_ENV} 114 | environment: 115 | # logtoコンテナのエンドポイントを指定 116 | - ENDPOINT=${LOGTO_ENDPOINT} 117 | - ADMIN_ENDPOINT=${LOGTO_ADMIN_ENDPOINT} 118 | # 内部ネットワークに接続 119 | networks: 120 | - app-logto-network 121 | - logto-db-network 122 | - traefik-public 123 | labels: 124 | # traefikで公開する 125 | - "traefik.enable=true" 126 | # ホスト名を指定 127 | # authサブドメインを指定 128 | - "traefik.http.routers.logto_auth.rule=Host(`auth.${HOSTNAME}`)" 129 | - "traefik.http.routers.logto_auth.service=logto_auth" 130 | # 3001番ポートを指定 131 | - "traefik.http.services.logto_auth.loadbalancer.server.port=3001" 132 | # SSLを有効化 133 | - "traefik.http.routers.logto_auth.entrypoints=websecure" 134 | - "traefik.http.routers.logto_auth.tls.certresolver=letsencrypt" 135 | # ホスト名を指定 136 | # authadminサブドメインを指定 137 | - "traefik.http.routers.logto_admin.rule=Host(`authadmin.${HOSTNAME}`)" 138 | - "traefik.http.routers.logto_admin.service=logto_admin" 139 | # 3002番ポートを指定 140 | - "traefik.http.services.logto_admin.loadbalancer.server.port=3002" 141 | # SSLを有効化 142 | - "traefik.http.routers.logto_admin.entrypoints=websecure" 143 | - "traefik.http.routers.logto_admin.tls.certresolver=letsencrypt" 144 | # コンテナが停止したら再起動 145 | restart: on-failure 146 | 147 | backend: 148 | container_name: backend 149 | # バックエンドのDockerfileを指定 150 | build: 151 | context: . 152 | dockerfile: packages/infra/Dockerfiles/Backend-Dockerfile 153 | args: 154 | # バックエンドのディレクトリを指定 155 | PACKAGE_PATH: ${BACKDIR} 156 | # ビルド時のスキーマのパスを指定 157 | SCHEMA_PATH: ${SCHEMA_PATH} 158 | # バックエンドの環境変数を読み込む 159 | env_file: ${BACK_ENV} 160 | # スキーマのパスを指定 161 | environment: 162 | # スキーマのパスを指定 163 | - SCHEMA_PATH=${SCHEMA_PATH} 164 | # バックエンドのminioのユーザー名とパスワードを指定 165 | - MINIO_ROOT_USER=${MINIO_ROOT_USER} 166 | - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD} 167 | # バックエンドのminioのバケット名を指定 168 | - MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME} 169 | # logtoコンテナのエンドポイントを指定 170 | - LOGTO_ENDPOINT=${LOGTO_ENDPOINT} 171 | # オーディエンスを指定 172 | - LOGTO_AUDIENCE=${LOGTO_AUDIENCE} 173 | # logtoのwebhookシークレットを指定 174 | - LOGTO_WEBHOOK_SECRET=${LOGTO_WEBHOOK_SECRET} 175 | # NODE_ENVをproductionに指定 176 | - NODE_ENV=production 177 | # 内部ネットワークに接続 178 | networks: 179 | - app-logto-network 180 | - app-db-network 181 | - app-minio-network 182 | - traefik-public 183 | labels: 184 | # traefikで公開する 185 | - "traefik.enable=true" 186 | # ホスト名を指定 187 | # apiディレクトリを指定 188 | - "traefik.http.routers.backend.rule=Host(`${HOSTNAME}`) && PathPrefix(`/api/`)" 189 | # 6173番ポートを指定 190 | - "traefik.http.services.backend.loadbalancer.server.port=6173" 191 | # SSLを有効化 192 | - "traefik.http.routers.backend.entrypoints=websecure" 193 | - "traefik.http.routers.backend.tls.certresolver=letsencrypt" 194 | # コンテナが停止したら再起動 195 | restart: on-failure 196 | 197 | frontend: 198 | container_name: frontend 199 | build: 200 | context: . 201 | # フロントエンドのDockerfileを指定 202 | dockerfile: packages/infra/Dockerfiles/Frontend-Dockerfile 203 | args: 204 | # vite側で必要な環境変数ここから 205 | # フロントエンドのディレクトリを指定 206 | PACKAGE_PATH: ${FRONTDIR} 207 | # ビルド時のスキーマのパスを指定 208 | SCHEMA_PATH: ${SCHEMA_PATH} 209 | # オペレーションの環境変数を指定 210 | # OPERATION_PATH: ${OPERATION_PATH} 211 | # logtoコンテナのエンドポイントを指定 212 | VITE_LOGTO_ENDPOINT: ${LOGTO_ENDPOINT} 213 | # logtoのアプリケーションIDを指定 214 | VITE_LOGTO_APPID: ${LOGTO_APPID} 215 | # logtoのアプリケーションIDを指定 216 | VITE_LOGTO_API_RESOURCE: ${LOGTO_API_RESOURCE} 217 | # viteとnginx両方で利用する環境変数ここから 218 | # ホスト名を指定 219 | VITE_HOSTNAME: ${HOSTNAME} 220 | # インフラの環境変数を指定 221 | INFRADIR: ${INFRADIR} 222 | environment: 223 | # ホストネームを指定 224 | - HOSTNAME=${HOSTNAME} 225 | networks: 226 | - traefik-public 227 | labels: 228 | # traefikで公開する 229 | - "traefik.enable=true" 230 | # ホスト名を指定 231 | # ホームディレクトリを指定 232 | - "traefik.http.routers.frontend.rule=Host(`${HOSTNAME}`) && PathPrefix(`/`)" 233 | # 6000番ポートを指定 234 | - "traefik.http.services.frontend.loadbalancer.server.port=6000" 235 | # SSLを有効化 236 | - "traefik.http.routers.frontend.entrypoints=websecure" 237 | - "traefik.http.routers.frontend.tls.certresolver=letsencrypt" 238 | depends_on: 239 | - backend 240 | # コンテナが停止したら再起動 241 | restart: on-failure 242 | 243 | # リバースプロキシコンテナ 244 | traefik: 245 | container_name: traefik 246 | # traefikの最新イメージを使用 247 | image: traefik:latest 248 | # traefikの環境変数を読み込む 249 | command: 250 | # traefikのdockerプロバイダを有効化 251 | - "--providers.docker=true" 252 | - "--providers.docker.exposedbydefault=false" 253 | # 80番ポートを指定 254 | - "--entrypoints.web.address=:80" 255 | # 443番ポートを指定 256 | - "--entrypoints.websecure.address=:443" 257 | # letsencryptを有効化 258 | # httpチャレンジを有効化 259 | - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" 260 | - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" 261 | # 環境変数より取得したメールアドレスを指定 262 | - "--certificatesresolvers.letsencrypt.acme.email=${CERTBOT_EMAIL}" 263 | - "--certificatesresolvers.letsencrypt.acme.storage=/etc/letsencrypt/acme.json" 264 | # ネットワークを指定 265 | - "--providers.docker.network=traefik-public" 266 | volumes: 267 | # ホストのdockerソケットをマウント 268 | - type: bind 269 | source: /var/run/docker.sock 270 | target: /var/run/docker.sock 271 | # 読み取り専用でマウント 272 | read_only: true 273 | # ホストのletsencryptディレクトリをマウント 274 | - type: bind 275 | source: /etc/letsencrypt 276 | target: /etc/letsencrypt 277 | # traefikの設定ファイルを指定 278 | # ポートを指定 279 | ports: 280 | - "80:80" 281 | - "443:443" 282 | # ネットワークに接続 283 | networks: 284 | - traefik-public 285 | # コンテナが停止したら再起動 286 | restart: on-failure 287 | -------------------------------------------------------------------------------- /env_files/.gitignore: -------------------------------------------------------------------------------- 1 | !codespace.dev.env 2 | !logto.dev.env 3 | !postgres.dev.env 4 | !minio.dev.env -------------------------------------------------------------------------------- /env_files/backend.prod.env.example: -------------------------------------------------------------------------------- 1 | # データベースURLの設定 2 | DATABASE_URL="postgresql://main:password@db-app:5432/db" 3 | # 内部ネットワークからのminioエンドポイント 4 | MINIO_INSIDE_ENDPOINT=minio 5 | # 外部ネットワークからのminioエンドポイント 6 | MINIO_OUTSIDE_ENDPOINT=http://localhost:9000 -------------------------------------------------------------------------------- /env_files/codespace.dev.env: -------------------------------------------------------------------------------- 1 | # データベースURLの設定 2 | DATABASE_URL="postgresql://main:password@db:5432/db" 3 | # 内部ネットワークからのminioエンドポイント 4 | MINIO_INSIDE_ENDPOINT=minio 5 | # 外部ネットワークからのminioエンドポイント 6 | MINIO_OUTSIDE_ENDPOINT=http://localhost:9000 -------------------------------------------------------------------------------- /env_files/logto.prod.env.sample: -------------------------------------------------------------------------------- 1 | DB_URL="postgresql://main:password@db-logto:5432/logto" 2 | TRUST_PROXY_HEADER=1 -------------------------------------------------------------------------------- /env_files/postgres.dev.env: -------------------------------------------------------------------------------- 1 | POSTGRES_DB=db 2 | POSTGRES_USER=main 3 | POSTGRES_PASSWORD=password -------------------------------------------------------------------------------- /env_files/postgres.prod.env.example: -------------------------------------------------------------------------------- 1 | POSTGRES_DB=db 2 | POSTGRES_USER=main 3 | POSTGRES_PASSWORD=password -------------------------------------------------------------------------------- /env_files/prod.env.example: -------------------------------------------------------------------------------- 1 | # 各パッケージのディレクトリを指定 2 | BACKDIR=packages/backend 3 | FRONTDIR=packages/frontend 4 | INFRADIR=packages/infra 5 | 6 | # 各環境変数ファイルのパスを指定 7 | BACK_ENV=env_files/backend.prod.env 8 | POSTGRES_ENV=env_files/postgres.prod.env 9 | LOGTO_ENV=env_files/logto.prod.env 10 | 11 | # minioの環境変数の設定 12 | MINIO_ROOT_USER=production 13 | MINIO_ROOT_PASSWORD=production 14 | # minioのバケット名を設定 15 | MINIO_BUCKET_NAME=production 16 | 17 | # logtoエンドポイントの指定 18 | # この設定はLogtoで行うため、HTTPスキームを指定する 19 | LOGTO_ENDPOINT="https://auth.dummy" 20 | LOGTO_ADMIN_ENDPOINT="https://authadmin.dummy" 21 | # logtoのJWTのオーディエンス 22 | # 接続を認可したいAPIリソースと同じ値を指定 23 | LOGTO_AUDIENCE="https://dummy.dummy" 24 | # logtoのアプリケーションID 25 | # logtoで指定されるランダムな識別子 26 | LOGTO_APPID="dummyx5gi659pnnri1ihw" 27 | # logtoのAPIリソース 28 | # 接続を認可したいAPIリソースを指定 29 | LOGTO_API_RESOURCE="https://dummy.dummy" 30 | # logtoのWebHook用の署名検証用のシークレット 31 | # logtoで指定されるランダムな識別子 32 | LOGTO_WEBHOOK_SECRET="secretco4i4xbvpuhwcuro" 33 | 34 | # コンテナから見たスキーマのパスを指定 35 | SCHEMA_PATH=/home/graphql/schemas/*.graphql 36 | # OPERATION_PATH=/home/graphql/operations/*.graphql 37 | 38 | # ホストネームを指定 39 | HOSTNAME="dummy" 40 | # メールを指定 41 | CERTBOT_EMAIL="dummy@dummy" -------------------------------------------------------------------------------- /graphql.config.ts: -------------------------------------------------------------------------------- 1 | import type { IGraphQLConfig } from "graphql-config"; 2 | 3 | const config: IGraphQLConfig = { 4 | schema: "packages/graphql/schemas/*.graphql", 5 | documents: ["packages/frontend/src/**/*.tsx", "packages/frontend/src/**/**/*.tsx", "packages/frontend/src/**/**/**/*.tsx"], 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "callstack", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "build": "turbo build", 6 | "typecheck": "turbo typecheck", 7 | "format": "turbo format", 8 | "lint": "turbo lint", 9 | "prigen": "turbo prigen", 10 | "primig": "turbo primig", 11 | "priseed": "turbo priseed", 12 | "prepare": "husky install && turbo prigen && turbo codegen" 13 | }, 14 | "keywords": [], 15 | "repository": { 16 | "type": "git", 17 | "url": "git+github.com:calloc134/callstack.git" 18 | }, 19 | "author": "calloc134 ", 20 | "license": "MIT", 21 | "packageManager": "pnpm@78.6.2", 22 | "engines": { 23 | "pnpm": ">=8.6.2" 24 | }, 25 | "dependencies": { 26 | "@changesets/cli": "^2.26.2", 27 | "@commitlint/cli": "^17.6.7", 28 | "@commitlint/config-conventional": "^17.6.7", 29 | "husky": "^8.0.3", 30 | "tsc": "^2.0.4", 31 | "tsx": "^3.12.7", 32 | "turbo": "^1.10.12" 33 | }, 34 | "devDependencies": { 35 | "graphql-config": "^5.0.2", 36 | "husky": "^8.0.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .pnpm-store 3 | .git 4 | .gitignore 5 | .github 6 | .vscode 7 | .eslintignore 8 | .eslintrc.js 9 | node_modules -------------------------------------------------------------------------------- /packages/backend/.eslintignore: -------------------------------------------------------------------------------- 1 | src/resolvers/generated/* -------------------------------------------------------------------------------- /packages/backend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 4 | parser: '@typescript-eslint/parser', 5 | plugins: ['@typescript-eslint'], 6 | }; -------------------------------------------------------------------------------- /packages/backend/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json -------------------------------------------------------------------------------- /packages/backend/.npmrc: -------------------------------------------------------------------------------- 1 | store-dir=node_modules/.pnpm-store -------------------------------------------------------------------------------- /packages/backend/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "baseUrl": ".", 4 | "parser": { 5 | "syntax": "typescript" 6 | }, 7 | "target": "es2020" 8 | 9 | }, 10 | "module": { 11 | "type": "commonjs", 12 | "noInterop": true 13 | } 14 | } -------------------------------------------------------------------------------- /packages/backend/codegen.ts: -------------------------------------------------------------------------------- 1 | import type { CodegenConfig } from "@graphql-codegen/cli"; 2 | 3 | const config: CodegenConfig = { 4 | overwrite: true, 5 | schema: process.env.SCHEMA_PATH, 6 | generates: { 7 | "src/lib/generated/resolver-types.ts": { 8 | plugins: ["typescript", "typescript-resolvers"], 9 | config: { 10 | contextType: "../../context#GraphQLContext", 11 | strictScalars: true, 12 | scalars: { 13 | UUID: "string", 14 | DateTime: "Date", 15 | NonNegativeInt: "number", 16 | NonEmptyString: "string", 17 | HandleString: "string", 18 | ScreenNameString: "string", 19 | BioString: "string", 20 | TitleString: "string", 21 | BodyString: "string", 22 | File: "File", 23 | }, 24 | enumsAsTypes: true, 25 | skipTypename: true, 26 | useTypeImports: true, 27 | }, 28 | }, 29 | }, 30 | }; 31 | 32 | export default config; 33 | -------------------------------------------------------------------------------- /packages/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.0.1", 4 | "type": "commonjs", 5 | "scripts": { 6 | "dev": "NODE_ENV=development tsx watch ./src/index.ts", 7 | "build": "swc src -d dist/src && tsc-alias", 8 | "start": "NODE_ENV=production node ./dist/src/index.js", 9 | "typecheck": "tsc --noEmit", 10 | "lint": "eslint src --ext .ts", 11 | "format": "prettier --write \"src/**/*.ts\"", 12 | "codegen": "graphql-codegen --config codegen.ts", 13 | "prigen": "prisma generate", 14 | "primig": "prisma migrate deploy", 15 | "priseed": "prisma db seed" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "ISC", 20 | "dependencies": { 21 | "@envelop/auth0": "^5.0.0", 22 | "@envelop/core": "^4.0.0", 23 | "@envelop/disable-introspection": "^5.0.0", 24 | "@envelop/generic-auth": "^6.0.0", 25 | "@envelop/graphql-jit": "^6.0.1", 26 | "@escape.tech/graphql-armor": "^2.2.0", 27 | "@graphql-tools/load-files": "^7.0.0", 28 | "@graphql-tools/merge": "^9.0.0", 29 | "@prisma/client": "^5.1.1", 30 | "@swc/helpers": "^0.5.1", 31 | "@types/jsonwebtoken": "^9.0.2", 32 | "@types/uuid": "^9.0.3", 33 | "graphql": "^16.7.1", 34 | "graphql-middleware": "^6.1.35", 35 | "graphql-scalars": "^1.22.2", 36 | "graphql-shield": "^7.6.5", 37 | "graphql-yoga": "^4.0.3", 38 | "jimp": "^0.22.10", 39 | "jsonwebtoken": "^9.0.1", 40 | "jwks-rsa": "^3.0.1", 41 | "minio": "^7.1.3", 42 | "prisma": "^5.1.1", 43 | "ts-patch": "^3.0.2", 44 | "tsc-alias": "^1.8.7", 45 | "tsx": "^3.12.7", 46 | "uuid": "^9.0.0" 47 | }, 48 | "devDependencies": { 49 | "@graphql-codegen/cli": "5.0.0", 50 | "@graphql-codegen/typescript": "4.0.1", 51 | "@graphql-codegen/typescript-resolvers": "4.0.1", 52 | "@swc/cli": "^0.1.62", 53 | "@swc/core": "^1.3.74", 54 | "@typescript-eslint/eslint-plugin": "^5.62.0", 55 | "@typescript-eslint/parser": "^5.62.0", 56 | "eslint": "^8.46.0", 57 | "typescript": "^5.1.6" 58 | }, 59 | "prisma": { 60 | "seed": "tsx prisma/seed.ts" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/backend/prisma/migrations/20230704065830_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "uuid" TEXT NOT NULL, 4 | "email" TEXT NOT NULL, 5 | 6 | CONSTRAINT "User_pkey" PRIMARY KEY ("uuid") 7 | ); 8 | 9 | -- CreateTable 10 | CREATE TABLE "Profile" ( 11 | "uuid" TEXT NOT NULL, 12 | "age" INTEGER NOT NULL, 13 | "name" TEXT NOT NULL, 14 | "userId" TEXT NOT NULL, 15 | 16 | CONSTRAINT "Profile_pkey" PRIMARY KEY ("uuid") 17 | ); 18 | 19 | -- CreateIndex 20 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 21 | 22 | -- CreateIndex 23 | CREATE UNIQUE INDEX "Profile_uuid_key" ON "Profile"("uuid"); 24 | 25 | -- CreateIndex 26 | CREATE UNIQUE INDEX "Profile_userId_key" ON "Profile"("userId"); 27 | 28 | -- AddForeignKey 29 | ALTER TABLE "Profile" ADD CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("uuid") ON DELETE RESTRICT ON UPDATE CASCADE; 30 | -------------------------------------------------------------------------------- /packages/backend/prisma/migrations/20230711073849_20230711_dev/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[sub_auth]` on the table `User` will be added. If there are existing duplicate values, this will fail. 5 | - Added the required column `sub_auth` to the `User` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "User" ADD COLUMN "sub_auth" TEXT NOT NULL; 10 | 11 | -- CreateIndex 12 | CREATE UNIQUE INDEX "User_sub_auth_key" ON "User"("sub_auth"); 13 | -------------------------------------------------------------------------------- /packages/backend/prisma/migrations/20230711134019_20230711_dev/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER'); 3 | 4 | -- AlterTable 5 | ALTER TABLE "User" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'USER'; 6 | -------------------------------------------------------------------------------- /packages/backend/prisma/migrations/20230806070641_/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint. 5 | - You are about to drop the column `email` on the `User` table. All the data in the column will be lost. 6 | - You are about to drop the column `sub_auth` on the `User` table. All the data in the column will be lost. 7 | - You are about to drop the column `uuid` on the `User` table. All the data in the column will be lost. 8 | - You are about to drop the `Profile` table. If the table is not empty, all the data it contains will be lost. 9 | - A unique constraint covering the columns `[auth_sub]` on the table `User` will be added. If there are existing duplicate values, this will fail. 10 | - Added the required column `auth_sub` to the `User` table without a default value. This is not possible if the table is not empty. 11 | - Added the required column `bio` to the `User` table without a default value. This is not possible if the table is not empty. 12 | - Added the required column `handle` to the `User` table without a default value. This is not possible if the table is not empty. 13 | - Added the required column `screen_name` to the `User` table without a default value. This is not possible if the table is not empty. 14 | - Added the required column `updated_at` to the `User` table without a default value. This is not possible if the table is not empty. 15 | - The required column `user_uuid` was added to the `User` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. 16 | 17 | */ 18 | -- DropForeignKey 19 | ALTER TABLE "Profile" DROP CONSTRAINT "Profile_userId_fkey"; 20 | 21 | -- DropIndex 22 | DROP INDEX "User_email_key"; 23 | 24 | -- DropIndex 25 | DROP INDEX "User_sub_auth_key"; 26 | 27 | -- AlterTable 28 | ALTER TABLE "User" DROP CONSTRAINT "User_pkey", 29 | DROP COLUMN "email", 30 | DROP COLUMN "sub_auth", 31 | DROP COLUMN "uuid", 32 | ADD COLUMN "auth_sub" TEXT NOT NULL, 33 | ADD COLUMN "bio" TEXT NOT NULL, 34 | ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 35 | ADD COLUMN "handle" TEXT NOT NULL, 36 | ADD COLUMN "screen_name" TEXT NOT NULL, 37 | ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL, 38 | ADD COLUMN "user_uuid" TEXT NOT NULL, 39 | ADD CONSTRAINT "User_pkey" PRIMARY KEY ("user_uuid"); 40 | 41 | -- DropTable 42 | DROP TABLE "Profile"; 43 | 44 | -- CreateTable 45 | CREATE TABLE "Post" ( 46 | "post_uuid" TEXT NOT NULL, 47 | "userUuid" TEXT NOT NULL, 48 | "title" TEXT NOT NULL, 49 | "body" TEXT NOT NULL, 50 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 51 | "updated_at" TIMESTAMP(3) NOT NULL, 52 | 53 | CONSTRAINT "Post_pkey" PRIMARY KEY ("post_uuid") 54 | ); 55 | 56 | -- CreateIndex 57 | CREATE UNIQUE INDEX "User_auth_sub_key" ON "User"("auth_sub"); 58 | 59 | -- AddForeignKey 60 | ALTER TABLE "Post" ADD CONSTRAINT "Post_userUuid_fkey" FOREIGN KEY ("userUuid") REFERENCES "User"("user_uuid") ON DELETE RESTRICT ON UPDATE CASCADE; 61 | -------------------------------------------------------------------------------- /packages/backend/prisma/migrations/20230806124111_/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Post" ADD COLUMN "is_public" BOOLEAN NOT NULL DEFAULT true; 3 | -------------------------------------------------------------------------------- /packages/backend/prisma/migrations/20230806124217_/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Post" ALTER COLUMN "is_public" SET DEFAULT false; 3 | -------------------------------------------------------------------------------- /packages/backend/prisma/migrations/20230925101132_dev/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `image_url` to the `User` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "User" ADD COLUMN "image_url" TEXT NOT NULL; 9 | -------------------------------------------------------------------------------- /packages/backend/prisma/migrations/20231028023451_/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[handle]` on the table `User` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX "User_handle_key" ON "User"("handle"); 9 | -------------------------------------------------------------------------------- /packages/backend/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /packages/backend/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // ロールを格納する列挙型を定義 5 | enum Role { 6 | ADMIN 7 | USER 8 | } 9 | 10 | // データベースソースの設定 11 | datasource db { 12 | provider = "postgresql" 13 | url = env("DATABASE_URL") 14 | } 15 | 16 | generator client { 17 | provider = "prisma-client-js" 18 | } 19 | 20 | // ユーザのモデルを定義 21 | // UUIDがプライマリキーの役割を果たす 22 | // ユーザとプロフィールは1対1の関係 23 | model User { 24 | user_uuid String @id @default(uuid()) 25 | auth_sub String @unique 26 | handle String @unique 27 | screen_name String 28 | bio String 29 | image_url String 30 | created_at DateTime @default(now()) 31 | updated_at DateTime @updatedAt 32 | role Role @default(USER) 33 | posts Post[] 34 | } 35 | 36 | // 投稿のモデルを定義 37 | // UUIDがプライマリキーの役割を果たす 38 | model Post { 39 | post_uuid String @id @default(uuid()) // UUIDはPrismaのデフォルトの関数を使用 40 | user User @relation(fields: [userUuid], references: [user_uuid]) 41 | userUuid String 42 | title String 43 | body String 44 | created_at DateTime @default(now()) 45 | updated_at DateTime @updatedAt 46 | is_public Boolean @default(false) 47 | } 48 | -------------------------------------------------------------------------------- /packages/backend/prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | function generateRandomString(num_chars = 8) { 6 | return Math.random().toString(num_chars).substring(2, 15) + Math.random().toString(36).substring(2, 15); 7 | } 8 | 9 | async function main() { 10 | const auth_subs = ["1g2h3j4k5l6", "9h8g7f6d5s4"]; 11 | 12 | await prisma.user.createMany({ 13 | data: [ 14 | { 15 | auth_sub: auth_subs[0], 16 | handle: generateRandomString(), 17 | screen_name: generateRandomString(), 18 | bio: generateRandomString(), 19 | image_url: "https://picsum.photos/200", 20 | }, 21 | 22 | { 23 | auth_sub: auth_subs[1], 24 | handle: generateRandomString(), 25 | screen_name: generateRandomString(), 26 | bio: generateRandomString(), 27 | image_url: "https://picsum.photos/200", 28 | }, 29 | ], 30 | }); 31 | 32 | await Promise.all( 33 | auth_subs.map(async (auth_sub) => { 34 | await prisma.post.create({ 35 | data: { 36 | title: generateRandomString(), 37 | body: generateRandomString(36), 38 | user: { 39 | connect: { 40 | auth_sub: auth_sub, 41 | }, 42 | }, 43 | }, 44 | }); 45 | }) 46 | ); 47 | } 48 | 49 | main() 50 | .catch((e) => { 51 | throw e; 52 | }) 53 | .finally(async () => { 54 | await prisma.$disconnect(); 55 | }); 56 | -------------------------------------------------------------------------------- /packages/backend/src/context.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { Client } from "minio"; 3 | import { UserPayload } from "@envelop/auth0"; 4 | import { IncomingMessage } from "http"; 5 | import { User } from "@prisma/client"; 6 | 7 | // コンテキストの型定義 8 | export type GraphQLContext = { 9 | // Prismaクライアントの型定義 10 | prisma: PrismaClient; 11 | // minioクライアントの型定義 12 | minio: Client; 13 | // JWTのペイロードの型定義 14 | logto?: UserPayload; 15 | // リクエストの型定義 16 | req: IncomingMessage; 17 | // 現在ログインしているユーザーのUUID 18 | currentUser: User; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/backend/src/env.ts: -------------------------------------------------------------------------------- 1 | // 環境変数を取得し、開発環境かどうかを判定 2 | const is_dev = process.env.NODE_ENV === "development"; 3 | 4 | // 環境変数が存在していれば、そのパスから読み込み 5 | // 環境変数が存在しなければ、../graphql/schema.graphqlから読み込み 6 | const schema_path = process.env.SCHEMA_PATH || "../graphql/schemas/*.graphql"; 7 | 8 | // minioの内部エンドポイントを設定 9 | const minio_inside_endpoint = process.env.MINIO_INSIDE_ENDPOINT || ""; 10 | 11 | // minioの外部エンドポイントを設定 12 | const minio_outside_endpoint = process.env.MINIO_OUTSIDE_ENDPOINT || ""; 13 | 14 | // minioのユーザー名とパスワードを取得 15 | const minio_root_user = process.env.MINIO_ROOT_USER || ""; 16 | const minio_root_password = process.env.MINIO_ROOT_PASSWORD || ""; 17 | 18 | // minioのバケット名を設定 19 | const minio_bucket_name = process.env.MINIO_BUCKET_NAME || ""; 20 | 21 | // 開発環境であれば、空文字を設定 22 | // 本番環境であれば、LogtoエンドポイントのURLを設定 23 | const logto_endpoint = is_dev ? "" : process.env.LOGTO_ENDPOINT || ""; 24 | 25 | // 開発環境であれば、空文字を設定 26 | // 本番環境であれば、Logtoのオーディエンスを設定 27 | const logto_audience = is_dev ? "" : process.env.LOGTO_AUDIENCE || ""; 28 | 29 | // 開発環境であれば、空文字を設定 30 | // 本番環境であれば、Logtoのwebhookの検証用シークレットを設定 31 | const logto_webhook_secret = is_dev ? "" : process.env.LOGTO_WEBHOOK_SECRET || ""; 32 | 33 | export { 34 | is_dev, 35 | minio_inside_endpoint, 36 | minio_outside_endpoint, 37 | minio_root_user, 38 | minio_root_password, 39 | minio_bucket_name, 40 | logto_endpoint, 41 | logto_audience, 42 | schema_path, 43 | logto_webhook_secret, 44 | }; 45 | -------------------------------------------------------------------------------- /packages/backend/src/index.ts: -------------------------------------------------------------------------------- 1 | // node:httpサーバ 2 | import { createServer } from "node:http"; 3 | // graphql-yogaサーバ 4 | import { createYoga } from "graphql-yoga"; 5 | 6 | // Prismaのクライアント 7 | import { PrismaClient } from "@prisma/client"; 8 | // minioのクライアント 9 | import { Client } from "minio"; 10 | // 開発環境でのJWT検証のモックプラグイン 11 | import { useAuthMock } from "./lib/plugins/useAuthMock"; 12 | // 本番環境でJWTの検証等を行うプラグイン 13 | import { useAuth0 } from "@envelop/auth0"; 14 | // 認可処理を行うプラグイン 15 | import { useGenericAuth } from "@envelop/generic-auth"; 16 | // graphqlのインタロスペクションを無効化するプラグイン 17 | import { useDisableIntrospection } from "@envelop/disable-introspection"; 18 | 19 | // graphqlスキーマ 20 | import { schema } from "./schema"; 21 | // graphql-armorのプラグイン 22 | import { Armor } from "./lib/security/armor"; 23 | // 認証プラグインのオプション 24 | import { AuthMockOption, AuthnOption } from "./lib/security/authn"; 25 | // 認可プラグインのオプション 26 | import { authzOption } from "./lib/security/authz"; 27 | // 開発環境かどうかを判断する変数 28 | import { is_dev, minio_inside_endpoint, minio_root_password, minio_root_user } from "./env"; 29 | import { useWebHook } from "./lib/webhook/webhook"; 30 | import { useGraphQlJit } from "@envelop/graphql-jit"; 31 | 32 | // graphql-armorのプラグインを取得 33 | const enhancements = Armor.protect(); 34 | 35 | // Prismaクライアントを作成 36 | const prisma = new PrismaClient(); 37 | 38 | // minioクライアントを作成 39 | const minio = new Client({ 40 | endPoint: minio_inside_endpoint, 41 | port: 9000, 42 | useSSL: false, 43 | accessKey: minio_root_user, 44 | secretKey: minio_root_password, 45 | }); 46 | 47 | // graphql-yogaのcreateYoga関数を利用してyogaサーバーを作成 48 | const yoga = createYoga({ 49 | // エンドポイントは/api/graphqlに指定 50 | graphqlEndpoint: "/api/graphql", 51 | // スキーマを設定 52 | schema, 53 | // 利用するコンテキストを設定 54 | context: { 55 | prisma, 56 | minio, 57 | }, 58 | // 開発環境の場合はplaygroundを有効化 59 | graphiql: is_dev, 60 | // 開発環境のときはCORSをすべて許可 61 | // そうでないときはすべて拒否 62 | cors: is_dev 63 | ? { 64 | origin: "*", 65 | } 66 | : false, 67 | plugins: [ 68 | // もし開発環境でなければ、webhookの検証を行う 69 | ...(is_dev ? [] : [useWebHook(prisma)]), 70 | // もし開発環境でなければ、introspectionを無効化 71 | ...(is_dev ? [] : [useDisableIntrospection()]), 72 | // もし開発環境でなければ、graphql-armorを有効化 73 | ...(is_dev ? [] : [...enhancements.plugins]), 74 | // 開発環境であるならば、useAuthMockを利用 75 | // そうでなければ、useAuth0を利用 76 | is_dev ? useAuthMock(AuthMockOption) : useAuth0(AuthnOption), 77 | // 一般的な認可処理のプラグインを追加 78 | useGenericAuth(authzOption), 79 | // JITプラグインを利用 80 | useGraphQlJit(), 81 | ], 82 | }); 83 | 84 | // yogaサーバーをnodeのhttpサーバーとして起動 85 | const server = createServer(yoga); 86 | 87 | server.listen(6173, () => { 88 | console.log("🚀 Server is running"); 89 | }); 90 | 91 | // SIGTERMを受け取ったら、プロセスを終了 92 | process.on("SIGTERM", async () => { 93 | console.log("✅ SIGTERM signal received: closing HTTP server"); 94 | await server.close(); 95 | 96 | try { 97 | console.log("🔥 Closing database connection"); 98 | await prisma.$disconnect(); 99 | } catch (error) { 100 | console.log("❌ Error closing database connection: ", error); 101 | } 102 | console.log("👋 Process terminated"); 103 | }); 104 | -------------------------------------------------------------------------------- /packages/backend/src/lib/error/error.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from "graphql"; 2 | 3 | // エラーコード 4 | const error_code = { 5 | // JWTのエラー 6 | // JWTの期限切れエラー 7 | jwt_expired: "JWT_EXPIRED", 8 | // JWTの署名エラー 9 | jwt_invalid_signature: "JWT_INVALID_SIGNATURE", 10 | // JWTの有効期限が開始前エラー 11 | jwt_not_before: "JWT_NOT_BEFORE", 12 | // JWTトークンのエラー 13 | jwt_web_token_error: "JWT_WEB_TOKEN_ERROR", 14 | 15 | // 認可において対応するユーザが存在しないエラー(ログインしていない またはログインが無効) 16 | authz_not_logged_in: "AUTHZ_NOT_LOGGED_IN", 17 | // 認可ディレクティブで指定した引数に対応するロールが存在しないエラー 18 | authz_role_not_found: "AUTHZ_ROLE_NOT_FOUND", 19 | // ログインユーザでの認可に失敗したエラー 20 | authz_failed: "AUTHZ_FAILED", 21 | 22 | // 存在しないアイテムのエラー 23 | item_not_found: "ITEM_NOT_FOUND", 24 | // 存在するアイテムのエラー 25 | item_already_exists: "ITEM_ALREADY_EXISTS", 26 | 27 | // アイテムのオーナが自分ではないエラー 28 | item_not_owned: "ITEM_NOT_OWNED", 29 | 30 | // その他のエラー 31 | unknown_error: "UNKNOWN_ERROR", 32 | }; 33 | 34 | // エラーコードの型 35 | type ErrorCode = keyof typeof error_code; 36 | 37 | // エラーコードのメッセージ 38 | const error_message: { [key in ErrorCode]: string } = { 39 | jwt_expired: "⏰ JWT is expired", 40 | jwt_invalid_signature: "❌ JWT signature is invalid", 41 | jwt_not_before: "⏳ JWT is not before", 42 | jwt_web_token_error: "🚫 JWT is invalid", 43 | authz_not_logged_in: "👤 Not logged in", 44 | authz_role_not_found: "🔍 Role not found", 45 | authz_failed: "🔐 Authorization failed", 46 | item_not_found: "🔎 Item not found", 47 | item_already_exists: "🔄 Item already exists", 48 | item_not_owned: "🚷 Item is not owned", 49 | unknown_error: "❓ Unknown error", 50 | }; 51 | 52 | // カスタムのエラークラス 53 | class GraphQLErrorWithCode extends GraphQLError { 54 | constructor(code: ErrorCode, message?: string) { 55 | // 拡張フィールドの定義 56 | const extensions = { 57 | code: code, 58 | }; 59 | 60 | // エラーの内容を表示 61 | console.error(`[ERROR] ${code}: ${message ? message : error_message[code]}`); 62 | 63 | // 親クラスのコンストラクタを呼び出す 64 | super(message ? message : error_message[code], { 65 | extensions: extensions, 66 | }); 67 | } 68 | } 69 | 70 | export { GraphQLErrorWithCode, error_code }; 71 | -------------------------------------------------------------------------------- /packages/backend/src/lib/error/handling.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; 2 | import { GraphQLErrorWithCode } from "src/lib/error/error"; 3 | 4 | type AsyncFunction = (...args: T) => Promise; 5 | 6 | // 例外ハンドリングを行うための関数 7 | // 高階関数を引数に取る 8 | const withErrorHandling = 9 | (func: AsyncFunction): AsyncFunction => 10 | async (...args: T): Promise => { 11 | // ここで与えられた高階関数を実行する 12 | try { 13 | return await func(...args); 14 | } catch (error) { 15 | if (error instanceof PrismaClientKnownRequestError) { 16 | switch (error.code) { 17 | // アイテムが見つからない場合 18 | case "P2025": 19 | throw new GraphQLErrorWithCode("item_not_found"); 20 | // アイテムが存在している場合 21 | case "P2002": 22 | throw new GraphQLErrorWithCode("item_already_exists"); 23 | // ここにエラーの種類を追加していく 24 | // その他のエラー 25 | default: 26 | console.error(error); 27 | throw new GraphQLErrorWithCode("unknown_error", error.message); 28 | } 29 | } else if (error instanceof Error) { 30 | throw new GraphQLErrorWithCode("unknown_error", error.message); 31 | } else { 32 | throw new GraphQLErrorWithCode("unknown_error"); 33 | } 34 | } 35 | }; 36 | 37 | export { withErrorHandling }; 38 | -------------------------------------------------------------------------------- /packages/backend/src/lib/generated/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /packages/backend/src/lib/plugins/useAuthMock.ts: -------------------------------------------------------------------------------- 1 | // ESLintの特定のルールを無効にする 2 | /* eslint-disable no-console */ 3 | /* eslint-disable dot-notation */ 4 | 5 | // 必要なライブラリをインポートする 6 | import { sign, decode, verify } from "jsonwebtoken"; // JWTの生成と検証を行うライブラリ 7 | import { Plugin } from "@envelop/core"; // GraphQLサーバーのプラグインシステムを提供するライブラリ 8 | import { generateKeyPairSync } from "crypto"; 9 | 10 | // JWTの鍵ペアを生成するユーティリティ 11 | // これはテスト環境でのみ使用する 12 | const generateJWTKeyPair = () => { 13 | // 鍵ペアを生成 14 | const { publicKey, privateKey } = generateKeyPairSync("ec", { 15 | namedCurve: "secp384r1", // P-384 curve 16 | publicKeyEncoding: { 17 | type: "spki", 18 | format: "pem", 19 | }, 20 | privateKeyEncoding: { 21 | type: "pkcs8", 22 | format: "pem", 23 | }, 24 | }); 25 | 26 | return { publicKey, privateKey }; 27 | }; 28 | 29 | // プラグインの設定を定義する型 30 | export type AuthMockPluginOptions = { 31 | preventUnauthenticatedAccess?: boolean; // 認証されていないアクセスを防ぐかどうか 32 | onError?: (error: Error) => void; // エラーハンドリングの方法 33 | extendContextField?: "_auth0" | string; // コンテキストに追加するフィールドの名前 34 | tokenType?: string; // トークンの種類 35 | headerName?: string; // JWTを含むヘッダーの名前 36 | }; 37 | 38 | // 認証が失敗した場合にスローされるエラークラス 39 | export class UnauthenticatedError extends Error {} 40 | 41 | // デコードされたJWTのペイロードを表す型 42 | export type UserPayload = { 43 | sub: string; // ユーザーの一意の識別子 44 | [key: string]: unknown; // 任意の追加フィールド 45 | }; 46 | 47 | // GraphQLのコンテキストに追加するフィールドを定義する型 48 | type BuildContext = TOptions["extendContextField"] extends string 49 | ? { [TName in TOptions["extendContextField"] as TOptions["extendContextField"]]: UserPayload } 50 | : { _auth0: UserPayload }; 51 | 52 | // モックを使用した認証を行うGraphQLプラグインを作成するフック 53 | export const useAuthMock = (options: TOptions): Plugin> => { 54 | // JWTの鍵ペアを生成する 55 | const { publicKey, privateKey } = generateJWTKeyPair(); 56 | 57 | // コンテキストフィールド、トークンタイプ、ヘッダー名を設定する 58 | const contextField = options.extendContextField || "_mock"; 59 | const tokenType = options.tokenType || "Bearer"; 60 | const headerName = options.headerName || "authorization"; 61 | 62 | // 定数を設定する 63 | const issuer = "https://dummy/oidc"; 64 | const audience = "mock"; 65 | 66 | // ダミーのユーザIDを設定する 67 | const userid: string[] = ["1g2h3j4k5l6", "9h8g7f6d5s4"]; 68 | 69 | // ダミーのJWTを返す 70 | const payload_list: UserPayload[] = [ 71 | { 72 | sub: userid[0], // ユーザID 73 | name: null, 74 | picture: null, 75 | username: null, 76 | auth_time: 9999999999, 77 | at_hash: null, 78 | }, 79 | { 80 | sub: userid[1], // ユーザID 81 | name: null, 82 | picture: null, 83 | username: null, 84 | auth_time: 9999999999, 85 | at_hash: null, 86 | }, 87 | ]; 88 | 89 | console.log("📝 This is a sample JWT. Please use it for testing."); 90 | 91 | // サンプルのJWTを生成して表示 92 | payload_list.map((payload) => { 93 | const jwt = sign(payload, privateKey, { 94 | algorithm: "ES384", // 使用するアルゴリズム 95 | audience: audience, // オーディエンスを指定する 96 | issuer: issuer, // 発行者を指定する 97 | expiresIn: "24h", // 有効期限を指定する 98 | keyid: "dummy", // キーIDを指定する 99 | }); 100 | 101 | // ここでペイロードのsubとJWTを表示する 102 | console.log(`sub: ${payload.sub}, jwt: ${jwt}`); 103 | 104 | return jwt; 105 | }); 106 | 107 | // JWTの取得関数を設定する 108 | const extractFn = (ctx: Record = {}): string | null => { 109 | const req = ctx["req"] || ctx["request"] || {}; 110 | const headers = req.headers || ctx["headers"] || null; 111 | 112 | if (!headers) { 113 | console.warn( 114 | `useAuthMock plugin unable to locate your request or headers on the execution context. Please make sure to pass that, or provide custom "extractTokenFn" function.` 115 | ); 116 | } else { 117 | let authHeader: string | null = null; 118 | if (headers[headerName] && typeof headers[headerName] === "string") { 119 | authHeader = headers[headerName] || null; 120 | } else if (headers.get && headers.has && headers.has(headerName)) { 121 | authHeader = headers.get(headerName) || null; 122 | } 123 | if (authHeader === null) { 124 | return null; 125 | } 126 | 127 | const split = authHeader.split(" "); 128 | 129 | if (split.length !== 2) { 130 | throw new Error(`Invalid value provided for header "${headerName}"!`); 131 | } else { 132 | const [type, value] = split; 133 | 134 | if (type !== tokenType) { 135 | throw new Error(`Unsupported token type provided: "${type}"!`); 136 | } else { 137 | return value; 138 | } 139 | } 140 | } 141 | 142 | return null; 143 | }; 144 | 145 | // JWTの検証関数を定義する 146 | const verifyToken = async (token: string): Promise => { 147 | // JWTをデコードする 148 | // @ts-expect-error: 型エラーを無視 149 | const decodedToken = (decode(token, { complete: true }) as Record) || {}; 150 | 151 | // デコードされたJWTにkidが存在する場合 152 | if (decodedToken && decodedToken.header && decodedToken.header.kid) { 153 | // JWTを検証する 154 | const decoded = verify(token, publicKey, { 155 | algorithms: ["ES384"], // 使用するアルゴリズム 156 | audience: audience, // オーディエンスを指定する 157 | issuer: issuer, // 発行者を指定する, 158 | }) as { sub: string }; 159 | 160 | // デコードされたペイロードを返す 161 | return decoded; 162 | } 163 | // JWTのデコードに失敗した場合、エラーをスローする 164 | throw new Error(`Failed to verify authentication token!`); 165 | }; 166 | 167 | // プラグインの定義を返す 168 | return { 169 | // コンテキストを構築する際に呼び出されるメソッド 170 | async onContextBuilding({ context, extendContext }) { 171 | try { 172 | // JWTを取得する 173 | const token = await extractFn(context); 174 | 175 | // JWTが存在する場合 176 | if (token) { 177 | // JWTを検証する 178 | const decodedPayload = await verifyToken(token); 179 | 180 | // デコードされたペイロードをコンテキストに追加する 181 | extendContext({ 182 | [contextField]: decodedPayload, 183 | } as BuildContext); 184 | // JWTが存在しない場合、preventUnauthenticatedAccessがtrueであればエラーをスローする 185 | } else if (options.preventUnauthenticatedAccess) { 186 | throw new UnauthenticatedError(`Unauthenticated!`); 187 | } 188 | // エラーが発生した場合 189 | } catch (e) { 190 | // onErrorが指定されていればそれを呼び出し、指定されていなければエラーをスローする 191 | if (options.onError) { 192 | options.onError(e as Error); 193 | } else { 194 | throw e; 195 | } 196 | } 197 | }, 198 | }; 199 | }; 200 | -------------------------------------------------------------------------------- /packages/backend/src/lib/scalars/BioString.ts: -------------------------------------------------------------------------------- 1 | import { ASTNode, GraphQLScalarType, Kind } from "graphql"; 2 | import { createGraphQLError } from "graphql-yoga"; 3 | 4 | const validate = (value: any, ast?: ASTNode) => { 5 | if (typeof value !== "string") { 6 | throw createGraphQLError(`Value is not a string: ${value}`, ast ? { nodes: ast } : undefined); 7 | } 8 | 9 | // 空文字列を許容しない 10 | if (!value.trim().length) { 11 | throw createGraphQLError(`Value cannot be an empty string: ${value}`, ast ? { nodes: ast } : undefined); 12 | } 13 | 14 | // 400文字を超える文字列を許容しない 15 | if (value.length > 400) { 16 | throw createGraphQLError(`Value cannot be longer than 400 characters: ${value}`, ast ? { nodes: ast } : undefined); 17 | } 18 | 19 | return value; 20 | }; 21 | 22 | export const GraphQLBioString = /*#__PURE__*/ new GraphQLScalarType({ 23 | name: "BioString", 24 | 25 | description: "A string that is used for the bio of a user", 26 | 27 | serialize: validate, 28 | 29 | parseValue: validate, 30 | 31 | parseLiteral(ast) { 32 | if (ast.kind !== Kind.STRING) { 33 | throw createGraphQLError(`Can only validate strings but got a: ${ast.kind}`, { nodes: ast }); 34 | } 35 | return validate(ast.value, ast); 36 | }, 37 | extensions: { 38 | codegenScalarType: "string", 39 | jsonSchema: { 40 | title: "BioString", 41 | type: "string", 42 | minLength: 1, 43 | }, 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /packages/backend/src/lib/scalars/BodyString.ts: -------------------------------------------------------------------------------- 1 | import { ASTNode, GraphQLScalarType, Kind } from "graphql"; 2 | import { createGraphQLError } from "graphql-yoga"; 3 | 4 | const validate = (value: any, ast?: ASTNode) => { 5 | if (typeof value !== "string") { 6 | throw createGraphQLError(`Value is not a string: ${value}`, ast ? { nodes: ast } : undefined); 7 | } 8 | 9 | if (value.length > 1000) { 10 | throw createGraphQLError(`Value cannot be longer than 1000 characters: ${value}`, ast ? { nodes: ast } : undefined); 11 | } 12 | 13 | return value; 14 | }; 15 | 16 | export const GraphQLBodyString = /*#__PURE__*/ new GraphQLScalarType({ 17 | name: "BodyString", 18 | 19 | description: "A string that is used for the body of a post", 20 | 21 | serialize: validate, 22 | 23 | parseValue: validate, 24 | 25 | parseLiteral(ast) { 26 | if (ast.kind !== Kind.STRING) { 27 | throw createGraphQLError(`Can only validate strings but got a: ${ast.kind}`, { nodes: ast }); 28 | } 29 | return validate(ast.value, ast); 30 | }, 31 | extensions: { 32 | codegenScalarType: "string", 33 | jsonSchema: { 34 | title: "BodyString", 35 | type: "string", 36 | minLength: 1, 37 | }, 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /packages/backend/src/lib/scalars/HandleString.ts: -------------------------------------------------------------------------------- 1 | import { ASTNode, GraphQLScalarType, Kind } from "graphql"; 2 | import { createGraphQLError } from "graphql-yoga"; 3 | 4 | const validate = (value: any, ast?: ASTNode) => { 5 | if (typeof value !== "string") { 6 | throw createGraphQLError(`Value is not a string: ${value}`, ast ? { nodes: ast } : undefined); 7 | } 8 | 9 | // 空文字列を許容しない 10 | if (!value.trim().length) { 11 | throw createGraphQLError(`Value cannot be an empty string: ${value}`, ast ? { nodes: ast } : undefined); 12 | } 13 | 14 | // ASCIIで表現可能な小文字、数字、簡単な記号のみを許容 15 | if (!/^[a-z0-9_]+$/.test(value)) { 16 | throw createGraphQLError(`Value must consist only of lowercase ASCII letters, numbers, or underscores: ${value}`, ast ? { nodes: ast } : undefined); 17 | } 18 | 19 | // 20文字を超える文字列を許容しない 20 | if (value.length > 20) { 21 | throw createGraphQLError(`Value cannot be longer than 20 characters: ${value}`, ast ? { nodes: ast } : undefined); 22 | } 23 | 24 | return value; 25 | }; 26 | 27 | export const GraphQLHandleString = /*#__PURE__*/ new GraphQLScalarType({ 28 | name: "HandleString", 29 | 30 | description: "A string that is used for the handle of a user", 31 | 32 | serialize: validate, 33 | 34 | parseValue: validate, 35 | 36 | parseLiteral(ast) { 37 | if (ast.kind !== Kind.STRING) { 38 | throw createGraphQLError(`Can only validate strings but got a: ${ast.kind}`, { nodes: ast }); 39 | } 40 | return validate(ast.value, ast); 41 | }, 42 | extensions: { 43 | codegenScalarType: "string", 44 | jsonSchema: { 45 | title: "HandleString", 46 | type: "string", 47 | minLength: 1, 48 | pattern: "^[a-z0-9_]+$", // JSON Schemaでの正規表現パターン 49 | }, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /packages/backend/src/lib/scalars/ScreenNameString.ts: -------------------------------------------------------------------------------- 1 | import { ASTNode, GraphQLScalarType, Kind } from "graphql"; 2 | import { createGraphQLError } from "graphql-yoga"; 3 | 4 | const validate = (value: any, ast?: ASTNode) => { 5 | if (typeof value !== "string") { 6 | throw createGraphQLError(`Value is not a string: ${value}`, ast ? { nodes: ast } : undefined); 7 | } 8 | 9 | // 空文字列を許容しない 10 | if (!value.trim().length) { 11 | throw createGraphQLError(`Value cannot be an empty string: ${value}`, ast ? { nodes: ast } : undefined); 12 | } 13 | 14 | // 50文字を超える文字列を許容しない 15 | if (value.length > 50) { 16 | throw createGraphQLError(`Value cannot be longer than 50 characters: ${value}`, ast ? { nodes: ast } : undefined); 17 | } 18 | 19 | return value; 20 | }; 21 | 22 | export const GraphQLScreenNameString = /*#__PURE__*/ new GraphQLScalarType({ 23 | name: "ScreenNameString", 24 | 25 | description: "A string that is used for the screen name of a user", 26 | 27 | serialize: validate, 28 | 29 | parseValue: validate, 30 | 31 | parseLiteral(ast) { 32 | if (ast.kind !== Kind.STRING) { 33 | throw createGraphQLError(`Can only validate strings but got a: ${ast.kind}`, { nodes: ast }); 34 | } 35 | return validate(ast.value, ast); 36 | }, 37 | extensions: { 38 | codegenScalarType: "string", 39 | jsonSchema: { 40 | title: "ScreenNameString", 41 | type: "string", 42 | minLength: 1, 43 | }, 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /packages/backend/src/lib/scalars/TitleString.ts: -------------------------------------------------------------------------------- 1 | import { ASTNode, GraphQLScalarType, Kind } from "graphql"; 2 | import { createGraphQLError } from "graphql-yoga"; 3 | 4 | const validate = (value: any, ast?: ASTNode) => { 5 | if (typeof value !== "string") { 6 | throw createGraphQLError(`Value is not a string: ${value}`, ast ? { nodes: ast } : undefined); 7 | } 8 | 9 | if (!value.trim().length) { 10 | throw createGraphQLError(`Value cannot be an empty string: ${value}`, ast ? { nodes: ast } : undefined); 11 | } 12 | 13 | if (value.length > 50) { 14 | throw createGraphQLError(`Value cannot be longer than 50 characters: ${value}`, ast ? { nodes: ast } : undefined); 15 | } 16 | 17 | return value; 18 | }; 19 | 20 | export const GraphQLTitleString = /*#__PURE__*/ new GraphQLScalarType({ 21 | name: "TitleString", 22 | 23 | description: "A string that is used for the body of a post", 24 | 25 | serialize: validate, 26 | 27 | parseValue: validate, 28 | 29 | parseLiteral(ast) { 30 | if (ast.kind !== Kind.STRING) { 31 | throw createGraphQLError(`Can only validate strings but got a: ${ast.kind}`, { nodes: ast }); 32 | } 33 | return validate(ast.value, ast); 34 | }, 35 | extensions: { 36 | codegenScalarType: "string", 37 | jsonSchema: { 38 | title: "TitleString", 39 | type: "string", 40 | minLength: 1, 41 | }, 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /packages/backend/src/lib/security/armor.ts: -------------------------------------------------------------------------------- 1 | import { EnvelopArmor } from "@escape.tech/graphql-armor"; 2 | 3 | // graphql-armorのセットアップ 4 | export const Armor = new EnvelopArmor({ 5 | // 最大深度を設定 6 | maxDepth: { 7 | enabled: true, 8 | n: 15, 9 | }, 10 | // 最大トークン数を設定 11 | maxTokens: { 12 | enabled: true, 13 | n: 1000, 14 | }, 15 | // 最大ディレクティブ数を設定 16 | maxDirectives: { 17 | enabled: true, 18 | n: 50, 19 | }, 20 | // 最大エイリアス数を設定 21 | maxAliases: { 22 | enabled: true, 23 | n: 10, 24 | }, 25 | // 最大コスト数を設定 26 | costLimit: { 27 | maxCost: 5000, 28 | objectCost: 2, 29 | scalarCost: 1, 30 | depthCostFactor: 1.5, 31 | ignoreIntrospection: true, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /packages/backend/src/lib/security/authn.ts: -------------------------------------------------------------------------------- 1 | import { Auth0PluginOptions } from "@envelop/auth0"; 2 | import { AuthMockPluginOptions } from "../plugins/useAuthMock"; 3 | import { logto_audience, logto_endpoint } from "src/env"; 4 | import { TokenExpiredError, JsonWebTokenError, NotBeforeError } from "jsonwebtoken"; 5 | import { GraphQLErrorWithCode } from "src/lib/error/error"; 6 | 7 | // エラー処理を行う関数 8 | const onError = (error: Error) => { 9 | if (error instanceof TokenExpiredError) { 10 | // JWTが期限切れの場合 11 | throw new GraphQLErrorWithCode("jwt_expired"); 12 | } else if (error instanceof NotBeforeError) { 13 | // JWTが有効期限前の場合 14 | throw new GraphQLErrorWithCode("jwt_not_before"); 15 | } else if (error instanceof JsonWebTokenError) { 16 | // JWTが不正な場合 17 | throw new GraphQLErrorWithCode("jwt_web_token_error"); 18 | } else { 19 | // その他のエラー 20 | throw new GraphQLErrorWithCode("unknown_error", error.message); 21 | } 22 | }; 23 | 24 | const AuthMockOption: AuthMockPluginOptions = { 25 | // 認証されていないリクエストも許可 26 | preventUnauthenticatedAccess: false, 27 | // ペイロードを格納するフィールド名を指定 28 | extendContextField: "logto", 29 | // エラー処理 30 | onError: onError, 31 | }; 32 | 33 | const AuthnOption: Auth0PluginOptions = { 34 | // ドメイン部分は上書きするためダミー 35 | domain: "", 36 | // audienceは環境変数から取得 37 | audience: logto_audience, 38 | // オプションを上書き 39 | // logtoのjwksUriと指定 40 | jwksClientOptions: { 41 | jwksUri: `${logto_endpoint}/oidc/jwks`, 42 | }, 43 | // JWTの検証オプションを上書き 44 | jwtVerifyOptions: { 45 | algorithms: ["ES384"], 46 | issuer: `${logto_endpoint}/oidc`, 47 | }, 48 | // 認証されていないリクエストも許可 49 | preventUnauthenticatedAccess: false, 50 | // ペイロードを格納するフィールド名を指定 51 | extendContextField: "logto", 52 | // エラー処理 53 | onError: onError, 54 | }; 55 | 56 | export { AuthMockOption, AuthnOption }; 57 | -------------------------------------------------------------------------------- /packages/backend/src/lib/security/authz.ts: -------------------------------------------------------------------------------- 1 | import { User, Role } from "@prisma/client"; 2 | import { GraphQLContext } from "src/context"; 3 | import { ResolveUserFn, ValidateUserFn } from "@envelop/generic-auth"; 4 | import { Kind } from "graphql"; 5 | import { GenericAuthPluginOptions } from "@envelop/generic-auth"; 6 | import { GraphQLErrorWithCode } from "src/lib/error/error"; 7 | 8 | const resolveUserFn: ResolveUserFn = async (context) => { 9 | // コンテキストからsubを取得 10 | const sub = context.logto?.sub; 11 | 12 | // もしsubが存在しない場合は 13 | if (!sub) { 14 | // nullを返す 15 | return null; 16 | } 17 | 18 | try { 19 | // 対応するユーザーを取得 20 | const user = await context.prisma.user.findUniqueOrThrow({ 21 | where: { 22 | auth_sub: sub, 23 | }, 24 | }); 25 | 26 | return user; 27 | } catch (error) { 28 | // エラーが発生した場合はnullを返す 29 | return null; 30 | } 31 | }; 32 | 33 | const validateUserFn: ValidateUserFn = ({ user, fieldAuthDirectiveNode }) => { 34 | // ユーザが存在しない場合 35 | if (!user) { 36 | // 失敗とする 37 | throw new GraphQLErrorWithCode("authz_not_logged_in"); 38 | } 39 | 40 | // ディレクティブに引数が指定されていなければ 41 | if (!fieldAuthDirectiveNode?.arguments) { 42 | // 成功とする 43 | return; 44 | } 45 | 46 | // 引数を取得 47 | const args = fieldAuthDirectiveNode.arguments; 48 | 49 | // argsの指定されている引数がRoleであるものを取得 50 | const role = args.find((arg) => arg.name.value === "role")?.value; 51 | 52 | // ロールが指定されていない場合は 53 | if (!role) { 54 | // エラーを返す 55 | throw new GraphQLErrorWithCode("authz_role_not_found"); 56 | } 57 | 58 | // ロールが指定されている場合は 59 | if (role.kind === Kind.ENUM) { 60 | // ロールの値を取得 61 | const roleValue = role.value; 62 | 63 | // ユーザーのロールが指定されたロールと一致する場合は 64 | if (user.role === (roleValue as Role)) { 65 | // 成功とする 66 | return; 67 | } else { 68 | // 失敗とする 69 | throw new GraphQLErrorWithCode("authz_failed"); 70 | } 71 | } 72 | }; 73 | 74 | // オプションを定義 75 | export const authzOption: GenericAuthPluginOptions = { 76 | resolveUserFn, 77 | validateUser: validateUserFn, 78 | mode: "protect-granular", 79 | }; 80 | -------------------------------------------------------------------------------- /packages/backend/src/lib/webhook/webhook.ts: -------------------------------------------------------------------------------- 1 | import { createHmac } from "crypto"; 2 | import { logto_webhook_secret } from "../../env"; 3 | import { Plugin } from "graphql-yoga/typings/plugins/types"; 4 | import { PrismaClient } from "@prisma/client"; 5 | 6 | type WebHookBodyType = { 7 | // hookId: string; 8 | // event: string; 9 | // interactionEvent: string; 10 | // createdAt: string; 11 | // sessionId: string; 12 | // userAgent: string; 13 | userId: string; 14 | user: { 15 | // id: string; 16 | username: string | null; 17 | primaryEmail: string | null; 18 | // primaryPhone: string | null; 19 | // name: string | null; 20 | // avatar: string | null; 21 | // customData: Record; 22 | // identities: Record; 23 | // lastSignInAt: number; 24 | // createdAt: number; 25 | // applicationId: string | null; 26 | // isSuspended: boolean; 27 | }; 28 | }; 29 | 30 | // WebHookへのリクエストに対する署名検証を行う関数 31 | const verifyWebHook = (signingKey: string, rawBody: string, expectedSignature: string) => { 32 | // HMAC-SHA256アルゴリズムで署名を生成 33 | const hmac = createHmac("sha256", signingKey); 34 | // リクエストボディを更新 35 | hmac.update(rawBody); 36 | // 正しい署名を16進数の文字列として取得 37 | const signature = hmac.digest("hex"); 38 | // 期待する署名と一致するかどうかを判定 39 | return signature === expectedSignature; 40 | }; 41 | 42 | // WebHookのリクエストを処理する関数 43 | export const useWebHook = (prisma: PrismaClient): Plugin => ({ 44 | async onRequest({ request, url, fetchAPI, endResponse }) { 45 | if (url.pathname === "/api/loginWebHookEndPoint") { 46 | // rawBodyを取得 47 | const rawBody = await request.text(); 48 | 49 | // ヘッダより署名を取得 50 | const expected_signature = request.headers.get("logto-signature-sha-256") || ""; 51 | 52 | // 署名検証 53 | const is_valid = verifyWebHook(logto_webhook_secret, rawBody, expected_signature); 54 | 55 | if (is_valid) { 56 | // bodyのJSONをパース 57 | const body = JSON.parse(rawBody) as WebHookBodyType; 58 | 59 | // ランダムな文字列を生成する関数 60 | // ランダムな数字を取得してハッシュ化 61 | const generateRandomString = () => { 62 | const random = Math.random().toString(36).slice(-8); 63 | const hash = createHmac("sha256", random).digest("hex"); 64 | return hash; 65 | }; 66 | 67 | // 適するユーザをデータベースに追加 68 | await prisma.user.create({ 69 | data: { 70 | // 認証サービスのユーザIDを保持 71 | auth_sub: body.userId, 72 | // ユーザネームかランダムな文字列を保持 73 | handle: body.user.username || generateRandomString(), 74 | // スクリーンネームかランダムな文字列を保持 75 | screen_name: body.user.username || generateRandomString(), 76 | bio: "自己紹介がここに入ります。", 77 | image_url: "https://picsum.photos/200", 78 | }, 79 | }); 80 | 81 | // 署名が正しいため、200を返す 82 | endResponse( 83 | new fetchAPI.Response("OK", { 84 | status: 200, 85 | }) 86 | ); 87 | 88 | console.log("🔐 Webhook works correctly"); 89 | 90 | return; 91 | } else { 92 | // 署名が正しくない場合は、403を返す 93 | endResponse( 94 | new fetchAPI.Response("Forbidden", { 95 | status: 403, 96 | }) 97 | ); 98 | 99 | console.error("🔐 Webhook signature is invalid"); 100 | } 101 | } 102 | }, 103 | }); 104 | -------------------------------------------------------------------------------- /packages/backend/src/resolver.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLContext } from "./context"; 2 | import { Resolvers } from "./lib/generated/resolver-types"; 3 | import { resolvers as scalarResolvers } from "graphql-scalars"; 4 | import { UserTypeResolver } from "./resolvers/types/userType"; 5 | import { PostTypeResolver } from "./resolvers/types/postType"; 6 | import { PanelQueryResolver } from "./resolvers/queries/panelQuery"; 7 | import { PanelMutationResolver } from "./resolvers/mutations/panelMutation"; 8 | import { GraphQLHandleString } from "./lib/scalars/HandleString"; 9 | import { GraphQLTitleString } from "./lib/scalars/TitleString"; 10 | import { GraphQLBodyString } from "./lib/scalars/BodyString"; 11 | import { GraphQLScreenNameString } from "./lib/scalars/ScreenNameString"; 12 | import { GraphQLBioString } from "./lib/scalars/BioString"; 13 | 14 | // リゾルバーの定義 15 | export const resolvers: Resolvers = { 16 | // スカラー型に対応するリゾルバーをマージ 17 | ...scalarResolvers, 18 | 19 | // ユーザのハンドル用のスカラー型のリゾルバー 20 | HandleString: GraphQLHandleString, 21 | // ユーザのスクリーンネーム用のスカラー型のリゾルバー 22 | ScreenNameString: GraphQLScreenNameString, 23 | // ユーザの自己紹介用のスカラー型のリゾルバー 24 | BioString: GraphQLBioString, 25 | // タイトル用のスカラー型のリゾルバー 26 | TitleString: GraphQLTitleString, 27 | // 本文用のスカラー型のリゾルバー 28 | BodyString: GraphQLBodyString, 29 | 30 | // クエリのリゾルバー 31 | Query: { 32 | ...PanelQueryResolver, 33 | }, 34 | 35 | // ミューテーションのリゾルバー 36 | Mutation: { 37 | ...PanelMutationResolver, 38 | }, 39 | // ユーザ型のリゾルバー 40 | User: { 41 | ...UserTypeResolver, 42 | }, 43 | // 投稿型のリゾルバー 44 | Post: { 45 | ...PostTypeResolver, 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /packages/backend/src/resolvers/mutations/panelMutation.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | // import { GraphQLErrorWithCode } from "src/lib/error/error"; 3 | import { MutationResolvers } from "src/lib/generated/resolver-types"; 4 | import { GraphQLContext } from "src/context"; 5 | import { withErrorHandling } from "src/lib/error/handling"; 6 | import { GraphQLErrorWithCode } from "src/lib/error/error"; 7 | import { Client } from "minio"; 8 | import { v4 as uuidv4 } from "uuid"; 9 | import { minio_bucket_name, minio_outside_endpoint } from "src/env"; 10 | import Jimp = require("jimp"); 11 | 12 | // prismaのupdateは、undefinedな値を渡すと、そのフィールドを更新しないことに留意する 13 | const PanelMutationResolver: MutationResolvers = { 14 | // createPresignedURLForUploadImageフィールドのリゾルバー 15 | // @ts-expect-error postsフィールドが存在しないためエラーが出るが、実際には存在するので無視 16 | uploadProfileImage: async (_parent, args, context) => { 17 | const safe = withErrorHandling(async (currentUser_uuid: string, prisma: PrismaClient, minioClient: Client, file: File) => { 18 | // ファイルのアレイバッファを取得 19 | const fileArrayBuffer = await file.arrayBuffer(); 20 | 21 | // 画像をjimpで開く 22 | const image = await Jimp.read(Buffer.from(fileArrayBuffer)); 23 | 24 | // 画像のサイズを変更 25 | image.cover(200, 200); 26 | 27 | // uuid v4を生成 28 | const filename = `${uuidv4()}.png`; 29 | 30 | // ファイルをアップロード 31 | await minioClient.putObject(minio_bucket_name, filename, await image.getBufferAsync(Jimp.MIME_PNG)); 32 | 33 | // ファイルのURLを生成 34 | const url = minio_outside_endpoint + "/" + minio_bucket_name + "/" + filename; 35 | 36 | // ユーザーのプロフィール画像を更新 37 | const result = await prisma.user.update({ 38 | where: { 39 | user_uuid: currentUser_uuid, 40 | }, 41 | data: { 42 | image_url: url, 43 | }, 44 | }); 45 | 46 | return result; 47 | }); 48 | 49 | // コンテキストからPrismaクライアントと現在ログインしているユーザーのデータを取得 50 | const { currentUser, prisma, minio: minioClient } = context; 51 | 52 | return await safe(currentUser.user_uuid, prisma, minioClient, args.file); 53 | }, 54 | 55 | // updateUserForAdminフィールドのリゾルバー 56 | // @ts-expect-error postsフィールドが存在しないためエラーが出るが、実際には存在するので無視 57 | updateUserForAdmin: async (_parent, args, context) => { 58 | const safe = withErrorHandling( 59 | async (user_uuid: string, prisma: PrismaClient, { bio, handle, screen_name }: { bio?: string; handle?: string; screen_name?: string }) => { 60 | // UUIDからユーザーを取得 61 | const result = await prisma.user.update({ 62 | where: { 63 | user_uuid: user_uuid, 64 | }, 65 | data: { 66 | bio: bio, 67 | handle: handle, 68 | screen_name: screen_name, 69 | }, 70 | }); 71 | return result; 72 | } 73 | ); 74 | 75 | // 引数からユーザーのUUIDとミューテーションの引数を取得 76 | const { user_uuid, bio: maybeBio, handle: maybeHandle, screen_name: maybeScreenName } = args; 77 | // コンテキストからPrismaクライアントを取得 78 | const { prisma } = context; 79 | 80 | const bio = maybeBio ?? undefined; 81 | const handle = maybeHandle ?? undefined; 82 | const screen_name = maybeScreenName ?? undefined; 83 | 84 | return await safe(user_uuid, prisma, { bio, handle, screen_name }); 85 | }, 86 | 87 | // deleteUserForAdminフィールドのリゾルバー 88 | // @ts-expect-error postsフィールドが存在しないためエラーが出るが、実際には存在するので無視 89 | deleteUserForAdmin: async (_parent, args, context) => { 90 | const safe = withErrorHandling(async (user_uuid: string, prisma: PrismaClient) => { 91 | // UUIDからユーザーを取得 92 | const result = await prisma.user.delete({ 93 | where: { 94 | user_uuid: user_uuid, 95 | }, 96 | }); 97 | return result; 98 | }); 99 | 100 | // 引数からユーザーのUUIDを取得 101 | const { user_uuid } = args; 102 | // コンテキストからPrismaクライアントを取得 103 | const { prisma } = context; 104 | 105 | return await safe(user_uuid, prisma); 106 | }, 107 | 108 | // updateMyUserフィールドのリゾルバー 109 | // @ts-expect-error postsフィールドが存在しないためエラーが出るが、実際には存在するので無視 110 | updateMyUser: async (_parent, args, context) => { 111 | const safe = withErrorHandling( 112 | async (currentUser_uuid: string, prisma: PrismaClient, { bio, handle, screen_name }: { bio?: string; handle?: string; screen_name?: string }) => { 113 | // UUIDからユーザーを取得 114 | const result = await prisma.user.update({ 115 | where: { 116 | user_uuid: currentUser_uuid, 117 | }, 118 | data: { 119 | bio: bio, 120 | handle: handle, 121 | screen_name: screen_name, 122 | }, 123 | }); 124 | return result; 125 | } 126 | ); 127 | 128 | // 引数からミューテーションの引数を取得 129 | const { bio: maybeBio, handle: maybeHandle, screen_name: maybeScreenName } = args.input; 130 | 131 | // コンテキストからPrismaクライアントと現在ログインしているユーザーのデータを取得 132 | const { prisma, currentUser } = context; 133 | 134 | const bio = maybeBio ?? undefined; 135 | const handle = maybeHandle ?? undefined; 136 | const screen_name = maybeScreenName ?? undefined; 137 | 138 | return await safe(currentUser.user_uuid, prisma, { bio, handle, screen_name }); 139 | }, 140 | 141 | // deleteMyUserフィールドのリゾルバー 142 | // @ts-expect-error postsフィールドが存在しないためエラーが出るが、実際には存在するので無視 143 | deleteMyUser: async (_parent, _args, context) => { 144 | const safe = withErrorHandling(async (currentUser_uuid: string, prisma: PrismaClient) => { 145 | // UUIDからユーザーを取得 146 | const result = await prisma.user.delete({ 147 | where: { 148 | user_uuid: currentUser_uuid, 149 | }, 150 | }); 151 | return result; 152 | }); 153 | 154 | // コンテキストからPrismaクライアントと現在ログインしているユーザーのデータを取得 155 | const { prisma, currentUser } = context; 156 | 157 | return await safe(currentUser.user_uuid, prisma); 158 | }, 159 | 160 | // createPostフィールドのリゾルバー 161 | // @ts-expect-error postsフィールドが存在しないためエラーが出るが、実際には存在するので無視 162 | createPost: async (_parent, _, context) => { 163 | const safe = withErrorHandling(async (currentUser_uuid: string, prisma: PrismaClient) => { 164 | // UUIDからユーザーを取得 165 | const result = await prisma.post.create({ 166 | data: { 167 | title: "下書き", 168 | body: "", 169 | userUuid: currentUser_uuid, 170 | }, 171 | }); 172 | return result; 173 | }); 174 | 175 | // コンテキストからPrismaクライアントと現在ログインしているユーザーのデータを取得 176 | const { prisma, currentUser } = context; 177 | 178 | return await safe(currentUser.user_uuid, prisma); 179 | }, 180 | 181 | // updatePostフィールドのリゾルバー 182 | // @ts-expect-error postsフィールドが存在しないためエラーが出るが、実際には存在するので無視 183 | updatePost: async (_parent, args, context) => { 184 | const safe = withErrorHandling( 185 | async (currentUser_uuid: string, prisma: PrismaClient, post_uuid: string, { title, body }: { title?: string; body?: string }) => { 186 | // UUIDからユーザーを取得 187 | const result = await prisma.post.update({ 188 | where: { 189 | userUuid: currentUser_uuid, 190 | post_uuid: post_uuid, 191 | }, 192 | data: { 193 | title: title, 194 | body: body, 195 | }, 196 | }); 197 | 198 | // もし削除した投稿が存在しなかった場合はエラーを投げる 199 | if (!result) { 200 | throw new GraphQLErrorWithCode("item_not_owned"); 201 | } 202 | 203 | return result; 204 | } 205 | ); 206 | 207 | // 引数からユーザーのUUIDとミューテーションの引数を取得 208 | const { 209 | post_uuid, 210 | input: { title: maybeTitle, body: maybeBody }, 211 | } = args; 212 | 213 | // コンテキストからPrismaクライアントと現在ログインしているユーザーのデータを取得 214 | const { prisma, currentUser } = context; 215 | 216 | const title = maybeTitle ?? undefined; 217 | const body = maybeBody ?? undefined; 218 | 219 | return await safe(currentUser.user_uuid, prisma, post_uuid, { title, body }); 220 | }, 221 | 222 | // deletePostフィールドのリゾルバー 223 | // @ts-expect-error postsフィールドが存在しないためエラーが出るが、実際には存在するので無視 224 | deletePost: async (_parent, args, context) => { 225 | const safe = withErrorHandling(async (currentUser_uuid: string, prisma: PrismaClient, post_uuid: string) => { 226 | // UUIDからユーザーを取得 227 | const result = await prisma.post.delete({ 228 | where: { 229 | userUuid: currentUser_uuid, 230 | post_uuid: post_uuid, 231 | }, 232 | }); 233 | 234 | // もし削除した投稿が存在しなかった場合はエラーを投げる 235 | if (!result) { 236 | throw new GraphQLErrorWithCode("item_not_owned"); 237 | } 238 | 239 | return result; 240 | }); 241 | 242 | // 引数からユーザーのUUIDを取得 243 | const { post_uuid } = args; 244 | // コンテキストからPrismaクライアントを取得 245 | const { prisma, currentUser } = context; 246 | 247 | return await safe(currentUser.user_uuid, prisma, post_uuid); 248 | }, 249 | }; 250 | 251 | export { PanelMutationResolver }; 252 | -------------------------------------------------------------------------------- /packages/backend/src/resolvers/queries/panelQuery.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { GraphQLErrorWithCode } from "src/lib/error/error"; 3 | import { QueryResolvers } from "src/lib/generated/resolver-types"; 4 | import { GraphQLContext } from "src/context"; 5 | import { withErrorHandling } from "src/lib/error/handling"; 6 | 7 | const PanelQueryResolver: QueryResolvers = { 8 | // getUserByUUIDクエリのリゾルバー 9 | // @ts-expect-error postsフィールドが存在しないためエラーが出るが、実際には存在するので無視 10 | getUserByUUID: async (_parent, args, context) => { 11 | const safe = withErrorHandling(async (prisma: PrismaClient, user_uuid: string) => { 12 | // UUIDからユーザーを取得 13 | const result = await prisma.user.findUniqueOrThrow({ 14 | where: { 15 | user_uuid: user_uuid, 16 | }, 17 | }); 18 | return result; 19 | }); 20 | 21 | // 引数からユーザーのUUIDを取得 22 | const { uuid: user_uuid } = args; 23 | // コンテキストからPrismaクライアントを取得 24 | const { prisma } = context; 25 | 26 | return await safe(prisma, user_uuid); 27 | }, 28 | 29 | // getAllUsersクエリのリゾルバー 30 | // @ts-expect-error postsフィールドが存在しないためエラーが出るが、実際には存在するので無視 31 | getAllUsers: async (_parent, args, context) => { 32 | const safe = withErrorHandling(async (prisma: PrismaClient, { offset, limit }: { offset: number; limit: number }) => { 33 | // ユーザーを全件取得 34 | const result = await prisma.user.findMany({ 35 | skip: offset, 36 | take: limit, 37 | // ユーザを新しい順に並び替える 38 | orderBy: { 39 | created_at: "desc", 40 | }, 41 | }); 42 | return result; 43 | }); 44 | 45 | // 引数からページネーションのoffsetとlimitを取得 46 | const { offset, limit } = args; 47 | // コンテキストからPrismaクライアントを取得 48 | const { prisma } = context; 49 | 50 | return await safe(prisma, { limit, offset }); 51 | }, 52 | 53 | // getMyUserクエリのリゾルバー 54 | // @ts-expect-error postsフィールドが存在しないためエラーが出るが、実際には存在するので無視 55 | getMyUser: async (_parent, _args, context) => { 56 | const safe = withErrorHandling(async (currentUser_uuid: string, prisma: PrismaClient) => { 57 | // 現在ログインしているユーザーのデータを取得 58 | const result = await prisma.user.findUniqueOrThrow({ 59 | where: { 60 | user_uuid: currentUser_uuid, 61 | }, 62 | }); 63 | return result; 64 | }); 65 | 66 | // コンテキストからPrismaクライアントと現在ログインしているユーザーのデータを取得 67 | const { prisma, currentUser } = context; 68 | 69 | return await safe(currentUser.user_uuid, prisma); 70 | }, 71 | 72 | // getPostByUUIDクエリのリゾルバー 73 | // @ts-expect-error userフィールドが存在しないためエラーが出るが、実際には存在するので無視 74 | getPostByUUID: async (_parent, args, context) => { 75 | const safe = withErrorHandling(async (post_uuid: string, prisma: PrismaClient) => { 76 | // UUIDから投稿を取得 77 | const result = await prisma.post.findUniqueOrThrow({ 78 | where: { 79 | post_uuid: post_uuid, 80 | }, 81 | }); 82 | return result; 83 | }); 84 | 85 | // 引数から投稿のUUIDを取得 86 | const { uuid: post_uuid } = args; 87 | // コンテキストからPrismaクライアントと現在ログインしているユーザーのデータを取得 88 | const { prisma, currentUser } = context; 89 | 90 | const result = await safe(post_uuid, prisma); 91 | 92 | // もし投稿者が自分でない かつ 投稿が非公開の場合はエラーを返す 93 | // TODO: 投稿が非公開の処理を追加 94 | if (result.userUuid !== currentUser.user_uuid && result.is_public === false) { 95 | throw new GraphQLErrorWithCode("item_not_owned"); 96 | } 97 | 98 | return result; 99 | }, 100 | 101 | // getAllPostsクエリのリゾルバー 102 | // @ts-expect-error userフィールドが存在しないためエラーが出るが、実際には存在するので無視 103 | getAllPosts: async (_parent, args, context) => { 104 | const safe = withErrorHandling(async (currentUser_uuid: string, prisma: PrismaClient, { offset, limit }: { offset: number; limit: number }) => { 105 | const result = await prisma.post.findMany({ 106 | // 投稿が自分でない かつ 非公開のものは除外する 107 | // つまり、投稿が自分 または 公開のもののみ取得する 108 | where: { 109 | OR: [ 110 | { 111 | userUuid: currentUser_uuid, 112 | }, 113 | { 114 | is_public: true, 115 | }, 116 | ], 117 | }, 118 | skip: offset, 119 | take: limit, 120 | // 投稿を新しい順に並び替える 121 | orderBy: { 122 | created_at: "desc", 123 | }, 124 | }); 125 | return result; 126 | }); 127 | 128 | // 引数からページネーションのoffsetとlimitを取得 129 | const { offset, limit } = args; 130 | 131 | // コンテキストからPrismaクライアントと現在ログインしているユーザーのデータを取得 132 | const { prisma, currentUser } = context; 133 | 134 | return await safe(currentUser.user_uuid, prisma, { limit, offset }); 135 | }, 136 | }; 137 | 138 | export { PanelQueryResolver }; 139 | -------------------------------------------------------------------------------- /packages/backend/src/resolvers/types/postType.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { PostResolvers } from "src/lib/generated/resolver-types"; 3 | import { GraphQLContext } from "src/context"; 4 | import { withErrorHandling } from "src/lib/error/handling"; 5 | 6 | const PostTypeResolver: PostResolvers = { 7 | // ユーザーフィールドのリゾルバー 8 | // @ts-expect-error 返却されるpostにuserフィールドが存在しないためエラーが出るが、実際には存在するので無視 9 | user: async (parent, _args, context) => { 10 | const safe = withErrorHandling(async (post_uuid: string, prisma: PrismaClient) => { 11 | // UUIDから投稿を取得 12 | const result = await prisma.post 13 | .findUniqueOrThrow({ 14 | where: { 15 | post_uuid: post_uuid, 16 | }, 17 | }) 18 | // そこから投稿者を取得 19 | .user(); 20 | return result; 21 | }); 22 | 23 | // ペアレントオブジェクトから投稿のUUIDを取得 24 | const { post_uuid } = parent; 25 | // コンテキストからPrismaクライアントを取得 26 | const { prisma } = context; 27 | 28 | return await safe(post_uuid, prisma); 29 | }, 30 | }; 31 | 32 | export { PostTypeResolver }; 33 | -------------------------------------------------------------------------------- /packages/backend/src/resolvers/types/userType.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { UserResolvers } from "src/lib/generated/resolver-types"; 3 | import { GraphQLContext } from "src/context"; 4 | import { withErrorHandling } from "src/lib/error/handling"; 5 | 6 | const UserTypeResolver: UserResolvers = { 7 | // 投稿フィールドのリゾルバー 8 | // @ts-expect-error 返却されるuserにpostsフィールドが存在しないためエラーが出るが、実際には存在するので無視 9 | posts: async (parent, args, context) => { 10 | const safe = withErrorHandling( 11 | async (currentUser_uuid: string, user_uuid: string, prisma: PrismaClient, { offset, limit }: { offset: number; limit: number }) => { 12 | // UUIDからユーザーを取得 13 | const result = await prisma.user 14 | .findUniqueOrThrow({ 15 | where: { 16 | user_uuid: user_uuid, 17 | }, 18 | }) 19 | // そこから投稿を取得 20 | .posts({ 21 | where: { 22 | // 投稿者が自分でない かつ 非公開のものは除外する 23 | // つまり、投稿が自分 または 公開のもののみ取得する 24 | OR: [ 25 | { 26 | userUuid: currentUser_uuid, 27 | }, 28 | { 29 | is_public: true, 30 | }, 31 | ], 32 | }, 33 | skip: offset, 34 | take: limit, 35 | // 投稿を新しい順に並び替える 36 | orderBy: { 37 | created_at: "desc", 38 | }, 39 | }); 40 | return result; 41 | } 42 | ); 43 | 44 | // ペアレントオブジェクトから現在ログインしているユーザーのデータを取得 45 | const { user_uuid } = parent; 46 | // 引数からページネーションのoffsetとlimitを取得 47 | const { offset, limit } = args; 48 | // コンテキストからPrismaクライアントと現在ログインしているユーザーのデータを取得 49 | const { prisma, currentUser } = context; 50 | 51 | return await safe(currentUser.user_uuid, user_uuid, prisma, { limit, offset }); 52 | }, 53 | }; 54 | 55 | export { UserTypeResolver }; 56 | -------------------------------------------------------------------------------- /packages/backend/src/schema.ts: -------------------------------------------------------------------------------- 1 | import { createSchema } from "graphql-yoga"; 2 | import { loadFilesSync } from "@graphql-tools/load-files"; 3 | import { resolvers } from "./resolver"; 4 | import { typeDefs as scalarTypeDefs } from "graphql-scalars"; 5 | import { schema_path } from "./env"; 6 | 7 | // graphql-yogaのcreateSchema関数を利用してスキーマを作成 8 | export const schema = createSchema({ 9 | // 型定義 10 | typeDefs: [ 11 | // スカラー型の定義をマージ 12 | ...scalarTypeDefs, 13 | // ファイルからスキーマを読み込み 14 | loadFilesSync(schema_path), 15 | ], 16 | // リゾルバー 17 | resolvers, 18 | }); 19 | -------------------------------------------------------------------------------- /packages/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "strict": true, 5 | "skipLibCheck": true, 6 | "outDir": "dist" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /packages/docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /packages/docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve("@docusaurus/core/lib/babel/preset")], 3 | }; 4 | -------------------------------------------------------------------------------- /packages/docs/docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # callstack とは 6 | 7 | callstack とは、**pnpm workspace** + **turborepo**で構成されたモノレポのボイラープレートです。 8 | 9 | ⚠️ このプロジェクトはまだ **開発中** です。 10 | 11 | # 特徴 12 | 13 | - [pnpm](https://pnpm.io/) と[turborepo](https://turbo.build/) を使ったモノレポのボイラープレート 14 | - [husky](https://github.com/typicode/husky) 等のツールによるコード品質の維持 15 | - devcontainer による開発環境の管理 16 | - [react](https://reactjs.org/) によるフロントエンドの構築 17 | - [graphql-yoga](https://the-guild.dev/graphql/yoga-server) によるバックエンドの構築 18 | - [prisma](https://www.prisma.io/) によるデータベース管理 19 | - docker compose による本番環境のコンテナ管理 20 | - 組み込まれた認証とユーザ管理基盤 21 | - graphql-yoga の認可モジュールによる認可管理 22 | 23 | ## Getting Started 24 | 25 | Get started by **creating a new site**. 26 | 27 | Or **try Docusaurus immediately** with **[docusaurus.new](https://docusaurus.new)**. 28 | 29 | ### What you'll need 30 | 31 | - [Node.js](https://nodejs.org/en/download/) version 16.14 or above: 32 | - When installing Node.js, you are recommended to check all checkboxes related to dependencies. 33 | 34 | ## Generate a new site 35 | 36 | Generate a new Docusaurus site using the **classic template**. 37 | 38 | The classic template will automatically be added to your project after you run the command: 39 | 40 | ```bash 41 | npm init docusaurus@latest my-website classic 42 | ``` 43 | 44 | You can type this command into Command Prompt, Powershell, Terminal, or any other integrated terminal of your code editor. 45 | 46 | The command also installs all necessary dependencies you need to run Docusaurus. 47 | 48 | ## Start your site 49 | 50 | Run the development server: 51 | 52 | ```bash 53 | cd my-website 54 | npm run start 55 | ``` 56 | 57 | The `cd` command changes the directory you're working with. In order to work with your newly created Docusaurus site, you'll need to navigate the terminal there. 58 | 59 | The `npm run start` command builds your website locally and serves it through a development server, ready for you to view at http://localhost:3000/. 60 | 61 | Open `docs/intro.md` (this page) and edit some lines: the site **reloads automatically** and displays your changes. 62 | -------------------------------------------------------------------------------- /packages/docs/docs/tutorial-basics/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Tutorial - Basics", 3 | "position": 2, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "5 minutes to learn the most important Docusaurus concepts." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/docs/docs/tutorial-basics/congratulations.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 6 3 | --- 4 | 5 | # Congratulations! 6 | 7 | You have just learned the **basics of Docusaurus** and made some changes to the **initial template**. 8 | 9 | Docusaurus has **much more to offer**! 10 | 11 | Have **5 more minutes**? Take a look at **[versioning](../tutorial-extras/manage-docs-versions.md)** and **[i18n](../tutorial-extras/translate-your-site.md)**. 12 | 13 | Anything **unclear** or **buggy** in this tutorial? [Please report it!](https://github.com/facebook/docusaurus/discussions/4610) 14 | 15 | ## What's next? 16 | 17 | - Read the [official documentation](https://docusaurus.io/) 18 | - Modify your site configuration with [`docusaurus.config.js`](https://docusaurus.io/docs/api/docusaurus-config) 19 | - Add navbar and footer items with [`themeConfig`](https://docusaurus.io/docs/api/themes/configuration) 20 | - Add a custom [Design and Layout](https://docusaurus.io/docs/styling-layout) 21 | - Add a [search bar](https://docusaurus.io/docs/search) 22 | - Find inspirations in the [Docusaurus showcase](https://docusaurus.io/showcase) 23 | - Get involved in the [Docusaurus Community](https://docusaurus.io/community/support) 24 | -------------------------------------------------------------------------------- /packages/docs/docs/tutorial-basics/create-a-blog-post.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Create a Blog Post 6 | 7 | Docusaurus creates a **page for each blog post**, but also a **blog index page**, a **tag system**, an **RSS** feed... 8 | 9 | ## Create your first Post 10 | 11 | Create a file at `blog/2021-02-28-greetings.md`: 12 | 13 | ```md title="blog/2021-02-28-greetings.md" 14 | --- 15 | slug: greetings 16 | title: Greetings! 17 | authors: 18 | - name: Joel Marcey 19 | title: Co-creator of Docusaurus 1 20 | url: https://github.com/JoelMarcey 21 | image_url: https://github.com/JoelMarcey.png 22 | - name: Sébastien Lorber 23 | title: Docusaurus maintainer 24 | url: https://sebastienlorber.com 25 | image_url: https://github.com/slorber.png 26 | tags: [greetings] 27 | --- 28 | 29 | Congratulations, you have made your first post! 30 | 31 | Feel free to play around and edit this post as much you like. 32 | ``` 33 | 34 | A new blog post is now available at [http://localhost:3000/blog/greetings](http://localhost:3000/blog/greetings). 35 | -------------------------------------------------------------------------------- /packages/docs/docs/tutorial-basics/create-a-document.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Create a Document 6 | 7 | Documents are **groups of pages** connected through: 8 | 9 | - a **sidebar** 10 | - **previous/next navigation** 11 | - **versioning** 12 | 13 | ## Create your first Doc 14 | 15 | Create a Markdown file at `docs/hello.md`: 16 | 17 | ```md title="docs/hello.md" 18 | # Hello 19 | 20 | This is my **first Docusaurus document**! 21 | ``` 22 | 23 | A new document is now available at [http://localhost:3000/docs/hello](http://localhost:3000/docs/hello). 24 | 25 | ## Configure the Sidebar 26 | 27 | Docusaurus automatically **creates a sidebar** from the `docs` folder. 28 | 29 | Add metadata to customize the sidebar label and position: 30 | 31 | ```md title="docs/hello.md" {1-4} 32 | --- 33 | sidebar_label: "Hi!" 34 | sidebar_position: 3 35 | --- 36 | 37 | # Hello 38 | 39 | This is my **first Docusaurus document**! 40 | ``` 41 | 42 | It is also possible to create your sidebar explicitly in `sidebars.js`: 43 | 44 | ```js title="sidebars.js" 45 | module.exports = { 46 | tutorialSidebar: [ 47 | "intro", 48 | // highlight-next-line 49 | "hello", 50 | { 51 | type: "category", 52 | label: "Tutorial", 53 | items: ["tutorial-basics/create-a-document"], 54 | }, 55 | ], 56 | }; 57 | ``` 58 | -------------------------------------------------------------------------------- /packages/docs/docs/tutorial-basics/create-a-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Create a Page 6 | 7 | Add **Markdown or React** files to `src/pages` to create a **standalone page**: 8 | 9 | - `src/pages/index.js` → `localhost:3000/` 10 | - `src/pages/foo.md` → `localhost:3000/foo` 11 | - `src/pages/foo/bar.js` → `localhost:3000/foo/bar` 12 | 13 | ## Create your first React Page 14 | 15 | Create a file at `src/pages/my-react-page.js`: 16 | 17 | ```jsx title="src/pages/my-react-page.js" 18 | import React from "react"; 19 | import Layout from "@theme/Layout"; 20 | 21 | export default function MyReactPage() { 22 | return ( 23 | 24 |

My React page

25 |

This is a React page

26 | 27 | ); 28 | } 29 | ``` 30 | 31 | A new page is now available at [http://localhost:3000/my-react-page](http://localhost:3000/my-react-page). 32 | 33 | ## Create your first Markdown Page 34 | 35 | Create a file at `src/pages/my-markdown-page.md`: 36 | 37 | ```mdx title="src/pages/my-markdown-page.md" 38 | # My Markdown page 39 | 40 | This is a Markdown page 41 | ``` 42 | 43 | A new page is now available at [http://localhost:3000/my-markdown-page](http://localhost:3000/my-markdown-page). 44 | -------------------------------------------------------------------------------- /packages/docs/docs/tutorial-basics/deploy-your-site.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | # Deploy your site 6 | 7 | Docusaurus is a **static-site-generator** (also called **[Jamstack](https://jamstack.org/)**). 8 | 9 | It builds your site as simple **static HTML, JavaScript and CSS files**. 10 | 11 | ## Build your site 12 | 13 | Build your site **for production**: 14 | 15 | ```bash 16 | npm run build 17 | ``` 18 | 19 | The static files are generated in the `build` folder. 20 | 21 | ## Deploy your site 22 | 23 | Test your production build locally: 24 | 25 | ```bash 26 | npm run serve 27 | ``` 28 | 29 | The `build` folder is now served at [http://localhost:3000/](http://localhost:3000/). 30 | 31 | You can now deploy the `build` folder **almost anywhere** easily, **for free** or very small cost (read the **[Deployment Guide](https://docusaurus.io/docs/deployment)**). 32 | -------------------------------------------------------------------------------- /packages/docs/docs/tutorial-basics/markdown-features.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | # Markdown Features 6 | 7 | Docusaurus supports **[Markdown](https://daringfireball.net/projects/markdown/syntax)** and a few **additional features**. 8 | 9 | ## Front Matter 10 | 11 | Markdown documents have metadata at the top called [Front Matter](https://jekyllrb.com/docs/front-matter/): 12 | 13 | ```text title="my-doc.md" 14 | // highlight-start 15 | --- 16 | id: my-doc-id 17 | title: My document title 18 | description: My document description 19 | slug: /my-custom-url 20 | --- 21 | // highlight-end 22 | 23 | ## Markdown heading 24 | 25 | Markdown text with [links](./hello.md) 26 | ``` 27 | 28 | ## Links 29 | 30 | Regular Markdown links are supported, using url paths or relative file paths. 31 | 32 | ```md 33 | Let's see how to [Create a page](/create-a-page). 34 | ``` 35 | 36 | ```md 37 | Let's see how to [Create a page](./create-a-page.md). 38 | ``` 39 | 40 | **Result:** Let's see how to [Create a page](./create-a-page.md). 41 | 42 | ## Images 43 | 44 | Regular Markdown images are supported. 45 | 46 | You can use absolute paths to reference images in the static directory (`static/img/docusaurus.png`): 47 | 48 | ```md 49 | ![Docusaurus logo](/img/docusaurus.png) 50 | ``` 51 | 52 | ![Docusaurus logo](/img/docusaurus.png) 53 | 54 | You can reference images relative to the current file as well. This is particularly useful to colocate images close to the Markdown files using them: 55 | 56 | ```md 57 | ![Docusaurus logo](./img/docusaurus.png) 58 | ``` 59 | 60 | ## Code Blocks 61 | 62 | Markdown code blocks are supported with Syntax highlighting. 63 | 64 | ```jsx title="src/components/HelloDocusaurus.js" 65 | function HelloDocusaurus() { 66 | return ( 67 |

Hello, Docusaurus!

68 | ) 69 | } 70 | ``` 71 | 72 | ```jsx title="src/components/HelloDocusaurus.js" 73 | function HelloDocusaurus() { 74 | return

Hello, Docusaurus!

; 75 | } 76 | ``` 77 | 78 | ## Admonitions 79 | 80 | Docusaurus has a special syntax to create admonitions and callouts: 81 | 82 | :::tip My tip 83 | 84 | Use this awesome feature option 85 | 86 | ::: 87 | 88 | :::danger Take care 89 | 90 | This action is dangerous 91 | 92 | ::: 93 | 94 | :::tip My tip 95 | 96 | Use this awesome feature option 97 | 98 | ::: 99 | 100 | :::danger Take care 101 | 102 | This action is dangerous 103 | 104 | ::: 105 | 106 | ## MDX and React Components 107 | 108 | [MDX](https://mdxjs.com/) can make your documentation more **interactive** and allows using any **React components inside Markdown**: 109 | 110 | ```jsx 111 | export const Highlight = ({children, color}) => ( 112 | { 121 | alert(`You clicked the color ${color} with label ${children}`) 122 | }}> 123 | {children} 124 | 125 | ); 126 | 127 | This is Docusaurus green ! 128 | 129 | This is Facebook blue ! 130 | ``` 131 | 132 | export const Highlight = ({children, color}) => ( 133 | { 142 | alert(`You clicked the color ${color} with label ${children}`); 143 | }}> 144 | {children} 145 | 146 | ); 147 | 148 | This is Docusaurus green ! 149 | 150 | This is Facebook blue ! 151 | -------------------------------------------------------------------------------- /packages/docs/docs/tutorial-extras/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Tutorial - Extras", 3 | "position": 3, 4 | "link": { 5 | "type": "generated-index" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/docs/docs/tutorial-extras/img/docsVersionDropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calloc134/callstack/f54b2e3150c476fd8ac36ad908b4e386ef9c829f/packages/docs/docs/tutorial-extras/img/docsVersionDropdown.png -------------------------------------------------------------------------------- /packages/docs/docs/tutorial-extras/img/localeDropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calloc134/callstack/f54b2e3150c476fd8ac36ad908b4e386ef9c829f/packages/docs/docs/tutorial-extras/img/localeDropdown.png -------------------------------------------------------------------------------- /packages/docs/docs/tutorial-extras/manage-docs-versions.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Manage Docs Versions 6 | 7 | Docusaurus can manage multiple versions of your docs. 8 | 9 | ## Create a docs version 10 | 11 | Release a version 1.0 of your project: 12 | 13 | ```bash 14 | npm run docusaurus docs:version 1.0 15 | ``` 16 | 17 | The `docs` folder is copied into `versioned_docs/version-1.0` and `versions.json` is created. 18 | 19 | Your docs now have 2 versions: 20 | 21 | - `1.0` at `http://localhost:3000/docs/` for the version 1.0 docs 22 | - `current` at `http://localhost:3000/docs/next/` for the **upcoming, unreleased docs** 23 | 24 | ## Add a Version Dropdown 25 | 26 | To navigate seamlessly across versions, add a version dropdown. 27 | 28 | Modify the `docusaurus.config.js` file: 29 | 30 | ```js title="docusaurus.config.js" 31 | module.exports = { 32 | themeConfig: { 33 | navbar: { 34 | items: [ 35 | // highlight-start 36 | { 37 | type: "docsVersionDropdown", 38 | }, 39 | // highlight-end 40 | ], 41 | }, 42 | }, 43 | }; 44 | ``` 45 | 46 | The docs version dropdown appears in your navbar: 47 | 48 | ![Docs Version Dropdown](./img/docsVersionDropdown.png) 49 | 50 | ## Update an existing version 51 | 52 | It is possible to edit versioned docs in their respective folder: 53 | 54 | - `versioned_docs/version-1.0/hello.md` updates `http://localhost:3000/docs/hello` 55 | - `docs/hello.md` updates `http://localhost:3000/docs/next/hello` 56 | -------------------------------------------------------------------------------- /packages/docs/docs/tutorial-extras/translate-your-site.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Translate your site 6 | 7 | Let's translate `docs/intro.md` to French. 8 | 9 | ## Configure i18n 10 | 11 | Modify `docusaurus.config.js` to add support for the `fr` locale: 12 | 13 | ```js title="docusaurus.config.js" 14 | module.exports = { 15 | i18n: { 16 | defaultLocale: "en", 17 | locales: ["en", "fr"], 18 | }, 19 | }; 20 | ``` 21 | 22 | ## Translate a doc 23 | 24 | Copy the `docs/intro.md` file to the `i18n/fr` folder: 25 | 26 | ```bash 27 | mkdir -p i18n/fr/docusaurus-plugin-content-docs/current/ 28 | 29 | cp docs/intro.md i18n/fr/docusaurus-plugin-content-docs/current/intro.md 30 | ``` 31 | 32 | Translate `i18n/fr/docusaurus-plugin-content-docs/current/intro.md` in French. 33 | 34 | ## Start your localized site 35 | 36 | Start your site on the French locale: 37 | 38 | ```bash 39 | npm run start -- --locale fr 40 | ``` 41 | 42 | Your localized site is accessible at [http://localhost:3000/fr/](http://localhost:3000/fr/) and the `Getting Started` page is translated. 43 | 44 | :::caution 45 | 46 | In development, you can only use one locale at a same time. 47 | 48 | ::: 49 | 50 | ## Add a Locale Dropdown 51 | 52 | To navigate seamlessly across languages, add a locale dropdown. 53 | 54 | Modify the `docusaurus.config.js` file: 55 | 56 | ```js title="docusaurus.config.js" 57 | module.exports = { 58 | themeConfig: { 59 | navbar: { 60 | items: [ 61 | // highlight-start 62 | { 63 | type: "localeDropdown", 64 | }, 65 | // highlight-end 66 | ], 67 | }, 68 | }, 69 | }; 70 | ``` 71 | 72 | The locale dropdown now appears in your navbar: 73 | 74 | ![Locale Dropdown](./img/localeDropdown.png) 75 | 76 | ## Build your localized site 77 | 78 | Build your site for a specific locale: 79 | 80 | ```bash 81 | npm run build -- --locale fr 82 | ``` 83 | 84 | Or build your site to include all the locales at once: 85 | 86 | ```bash 87 | npm run build 88 | ``` 89 | -------------------------------------------------------------------------------- /packages/docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Note: type annotations allow type checking and IDEs autocompletion 3 | 4 | const lightCodeTheme = require("prism-react-renderer/themes/github"); 5 | const darkCodeTheme = require("prism-react-renderer/themes/dracula"); 6 | 7 | /** @type {import('@docusaurus/types').Config} */ 8 | const config = { 9 | title: "callstack", 10 | tagline: "pnpm + turborepoで構成されたモノリポ構成のボイラープレート", 11 | favicon: "img/favicon.ico", 12 | 13 | // Set the production url of your site here 14 | url: "https://calloc134.github.io", 15 | // Set the // pathname under which your site is served 16 | // For GitHub pages deployment, it is often '//' 17 | baseUrl: "callstack", 18 | 19 | // GitHub pages deployment config. 20 | // If you aren't using GitHub pages, you don't need these. 21 | organizationName: "calloc134", // Usually your GitHub org/user name. 22 | projectName: "callstack", // Usually your repo name. 23 | 24 | onBrokenLinks: "throw", 25 | onBrokenMarkdownLinks: "warn", 26 | 27 | // Even if you don't use internalization, you can use this field to set useful 28 | // metadata like html lang. For example, if your site is Chinese, you may want 29 | // to replace "en" with "zh-Hans". 30 | i18n: { 31 | defaultLocale: "jp", 32 | locales: ["jp"], 33 | }, 34 | 35 | presets: [ 36 | [ 37 | "classic", 38 | /** @type {import('@docusaurus/preset-classic').Options} */ 39 | ({ 40 | docs: { 41 | sidebarPath: require.resolve("./sidebars.js"), 42 | // Please change this to your repo. 43 | // Remove this to remove the "edit this page" links. 44 | editUrl: "https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/", 45 | }, 46 | theme: { 47 | customCss: require.resolve("./src/css/custom.css"), 48 | }, 49 | }), 50 | ], 51 | ], 52 | 53 | themeConfig: 54 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 55 | ({ 56 | // Replace with your project's social card 57 | image: "img/docusaurus-social-card.jpg", 58 | navbar: { 59 | title: "callstack", 60 | logo: { 61 | alt: "callstack Logo", 62 | src: "img/logo.svg", 63 | }, 64 | items: [ 65 | { 66 | type: "docSidebar", 67 | sidebarId: "tutorialSidebar", 68 | position: "left", 69 | label: "Tutorial", 70 | }, 71 | { 72 | href: "https://github.com/facebook/docusaurus", 73 | label: "GitHub", 74 | position: "right", 75 | }, 76 | ], 77 | }, 78 | footer: { 79 | style: "dark", 80 | links: [ 81 | { 82 | title: "Docs", 83 | items: [ 84 | { 85 | label: "Tutorial", 86 | to: "/docs/intro", 87 | }, 88 | ], 89 | }, 90 | // { 91 | // title: 'Community', 92 | // items: [ 93 | // { 94 | // label: 'Stack Overflow', 95 | // href: 'https://stackoverflow.com/questions/tagged/docusaurus', 96 | // }, 97 | // { 98 | // label: 'Discord', 99 | // href: 'https://discordapp.com/invite/docusaurus', 100 | // }, 101 | // { 102 | // label: 'Twitter', 103 | // href: 'https://twitter.com/docusaurus', 104 | // }, 105 | // ], 106 | // }, 107 | { 108 | title: "More", 109 | items: [ 110 | { 111 | label: "GitHub", 112 | href: "https://github.com/calloc134/callstack", 113 | }, 114 | ], 115 | }, 116 | ], 117 | copyright: `Copyright © ${new Date().getFullYear()} My Project, Inc. Built with Docusaurus.`, 118 | }, 119 | prism: { 120 | theme: lightCodeTheme, 121 | darkTheme: darkCodeTheme, 122 | }, 123 | }), 124 | }; 125 | 126 | module.exports = config; 127 | -------------------------------------------------------------------------------- /packages/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "docbuild": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids" 15 | }, 16 | "dependencies": { 17 | "@docusaurus/core": "2.4.1", 18 | "@docusaurus/preset-classic": "2.4.1", 19 | "@mdx-js/react": "^1.6.22", 20 | "clsx": "^1.2.1", 21 | "prism-react-renderer": "^1.3.5", 22 | "react": "^17.0.2", 23 | "react-dom": "^17.0.2" 24 | }, 25 | "devDependencies": { 26 | "@docusaurus/module-type-aliases": "2.4.1" 27 | }, 28 | "browserslist": { 29 | "production": [ 30 | ">0.5%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | }, 40 | "engines": { 41 | "node": ">=16.14" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{ type: "autogenerated", dirName: "." }], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | 'intro', 23 | 'hello', 24 | { 25 | type: 'category', 26 | label: 'Tutorial', 27 | items: ['tutorial-basics/create-a-document'], 28 | }, 29 | ], 30 | */ 31 | }; 32 | 33 | module.exports = sidebars; 34 | -------------------------------------------------------------------------------- /packages/docs/src/components/HomepageFeatures/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import clsx from "clsx"; 3 | import styles from "./styles.module.css"; 4 | 5 | const FeatureList = [ 6 | { 7 | title: "Easy to Use", 8 | Svg: require("@site/static/img/undraw_docusaurus_mountain.svg").default, 9 | description: <>Docusaurus was designed from the ground up to be easily installed and used to get your website up and running quickly., 10 | }, 11 | { 12 | title: "Focus on What Matters", 13 | Svg: require("@site/static/img/undraw_docusaurus_tree.svg").default, 14 | description: ( 15 | <> 16 | Docusaurus lets you focus on your docs, and we'll do the chores. Go ahead and move your docs into the docs directory. 17 | 18 | ), 19 | }, 20 | { 21 | title: "Powered by React", 22 | Svg: require("@site/static/img/undraw_docusaurus_react.svg").default, 23 | description: <>Extend or customize your website layout by reusing React. Docusaurus can be extended while reusing the same header and footer., 24 | }, 25 | ]; 26 | 27 | function Feature({ Svg, title, description }) { 28 | return ( 29 |
30 |
31 | 32 |
33 |
34 |

{title}

35 |

{description}

36 |
37 |
38 | ); 39 | } 40 | 41 | export default function HomepageFeatures() { 42 | return ( 43 |
44 |
45 |
46 | {FeatureList.map((props, idx) => ( 47 | 48 | ))} 49 |
50 |
51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /packages/docs/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .featureSvg { 9 | height: 200px; 10 | width: 200px; 11 | } 12 | -------------------------------------------------------------------------------- /packages/docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #2e8555; 10 | --ifm-color-primary-dark: #29784c; 11 | --ifm-color-primary-darker: #277148; 12 | --ifm-color-primary-darkest: #205d3b; 13 | --ifm-color-primary-light: #33925d; 14 | --ifm-color-primary-lighter: #359962; 15 | --ifm-color-primary-lightest: #3cad6e; 16 | --ifm-code-font-size: 95%; 17 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); 18 | } 19 | 20 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 21 | [data-theme="dark"] { 22 | --ifm-color-primary: #25c2a0; 23 | --ifm-color-primary-dark: #21af90; 24 | --ifm-color-primary-darker: #1fa588; 25 | --ifm-color-primary-darkest: #1a8870; 26 | --ifm-color-primary-light: #29d5b0; 27 | --ifm-color-primary-lighter: #32d8b4; 28 | --ifm-color-primary-lightest: #4fddbf; 29 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); 30 | } 31 | -------------------------------------------------------------------------------- /packages/docs/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import clsx from "clsx"; 3 | import Link from "@docusaurus/Link"; 4 | import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; 5 | import Layout from "@theme/Layout"; 6 | import HomepageFeatures from "@site/src/components/HomepageFeatures"; 7 | 8 | import styles from "./index.module.css"; 9 | 10 | function HomepageHeader() { 11 | const { siteConfig } = useDocusaurusContext(); 12 | return ( 13 |
14 |
15 |

{siteConfig.title}

16 |

{siteConfig.tagline}

17 |
18 | 19 | チュートリアルを確認する 20 | 21 |
22 |
23 |
24 | ); 25 | } 26 | 27 | export default function Home() { 28 | const { siteConfig } = useDocusaurusContext(); 29 | return ( 30 | 31 | 32 |
33 | 34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /packages/docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .heroBanner { 7 | padding: 4rem 0; 8 | text-align: center; 9 | position: relative; 10 | overflow: hidden; 11 | } 12 | 13 | @media screen and (max-width: 996px) { 14 | .heroBanner { 15 | padding: 2rem; 16 | } 17 | } 18 | 19 | .buttons { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | } 24 | -------------------------------------------------------------------------------- /packages/docs/src/pages/markdown-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown page example 3 | --- 4 | 5 | # Markdown page example 6 | 7 | You don't need React to write simple standalone pages. 8 | -------------------------------------------------------------------------------- /packages/docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calloc134/callstack/f54b2e3150c476fd8ac36ad908b4e386ef9c829f/packages/docs/static/.nojekyll -------------------------------------------------------------------------------- /packages/docs/static/img/docusaurus-social-card.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calloc134/callstack/f54b2e3150c476fd8ac36ad908b4e386ef9c829f/packages/docs/static/img/docusaurus-social-card.jpg -------------------------------------------------------------------------------- /packages/docs/static/img/docusaurus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calloc134/callstack/f54b2e3150c476fd8ac36ad908b4e386ef9c829f/packages/docs/static/img/docusaurus.png -------------------------------------------------------------------------------- /packages/docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calloc134/callstack/f54b2e3150c476fd8ac36ad908b4e386ef9c829f/packages/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /packages/docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .pnpm-store 3 | .git 4 | .gitignore 5 | .github 6 | .vscode 7 | .eslintignore 8 | .eslintrc.js 9 | node_modules -------------------------------------------------------------------------------- /packages/frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:react-hooks/recommended', 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 10 | plugins: ['react-refresh'], 11 | rules: { 12 | 'react-refresh/only-export-components': 'warn', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /packages/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /packages/frontend/.npmrc: -------------------------------------------------------------------------------- 1 | store-dir=node_modules/.pnpm-store 2 | # public-hoist-pattern[]=*@nextui-org/theme* -------------------------------------------------------------------------------- /packages/frontend/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/react-vite"; 2 | 3 | const config: StorybookConfig = { 4 | stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], 5 | addons: [ 6 | "@storybook/addon-links", 7 | "@storybook/addon-essentials", 8 | "@storybook/addon-interactions", 9 | "@storybook/addon-styling", 10 | ], 11 | framework: { 12 | name: "@storybook/react-vite", 13 | options: {}, 14 | }, 15 | docs: { 16 | autodocs: "tag", 17 | }, 18 | }; 19 | export default config; 20 | -------------------------------------------------------------------------------- /packages/frontend/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react'; 2 | import { withThemeByClassName } from '@storybook/addon-styling'; 3 | import '../src/index.css'; 4 | 5 | const preview: Preview = { 6 | parameters: { 7 | actions: { argTypesRegex: '^on[A-Z].*' }, 8 | controls: { 9 | matchers: { 10 | color: /(background|color)$/i, 11 | date: /Date$/, 12 | }, 13 | }, 14 | }, 15 | 16 | decorators: [ 17 | // Adds theme switching support. 18 | // NOTE: requires setting "darkMode" to "class" in your tailwind config 19 | withThemeByClassName({ 20 | themes: { 21 | light: 'light', 22 | dark: 'dark', 23 | }, 24 | defaultTheme: 'light', 25 | }), 26 | ], 27 | }; 28 | 29 | export default preview; 30 | -------------------------------------------------------------------------------- /packages/frontend/codegen.ts: -------------------------------------------------------------------------------- 1 | import type { CodegenConfig } from "@graphql-codegen/cli"; 2 | 3 | const config: CodegenConfig = { 4 | overwrite: true, 5 | schema: process.env.SCHEMA_PATH, 6 | documents: ["./src/features/**/components/**/*.tsx", "./src/features/**/pages/**/*.tsx"], 7 | ignoreNoDocuments: true, 8 | generates: { 9 | "src/lib/generated/": { 10 | preset: "client", 11 | config: { 12 | strictScalars: true, 13 | scalars: { 14 | UUID: "string", 15 | DateTime: "Date", 16 | NonNegativeInt: "number", 17 | NonEmptyString: "string", 18 | HandleString: "string", 19 | ScreenNameString: "string", 20 | BioString: "string", 21 | TitleString: "string", 22 | BodyString: "string", 23 | File: "File", 24 | }, 25 | enumsAsTypes: true, 26 | skipTypename: true, 27 | useTypeImports: true, 28 | // schema: "zod", 29 | // scalarSchemas: { 30 | // UUID: "z.string().uuid()", 31 | // }, 32 | }, 33 | }, 34 | }, 35 | }; 36 | 37 | export default config; 38 | -------------------------------------------------------------------------------- /packages/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | callstack 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "typecheck": "tsc --noEmit", 10 | "lint": "eslint src --ext ts,tsx", 11 | "format": "prettier --write \"src/**/*.{ts,tsx}\"", 12 | "preview": "vite preview", 13 | "storybook": "storybook dev -p 6006", 14 | "build-storybook": "storybook build", 15 | "codegen": "graphql-codegen --config codegen.ts" 16 | }, 17 | "dependencies": { 18 | "@logto/react": "^2.1.1", 19 | "@nextui-org/react": "^2.0.8", 20 | "@nextui-org/system": "^2.0.3", 21 | "@nextui-org/theme": "^2.0.4", 22 | "@storybook/addon-styling": "^1.3.5", 23 | "@tailwindcss/line-clamp": "^0.4.4", 24 | "@tanstack/react-router": "0.0.1-beta.145", 25 | "@tanstack/router-devtools": "0.0.1-beta.147", 26 | "@urql/devtools": "^2.0.3", 27 | "@urql/exchange-auth": "^2.1.6", 28 | "autoprefixer": "^10.4.14", 29 | "class-variance-authority": "^0.6.1", 30 | "clsx": "^1.2.1", 31 | "framer-motion": "^10.15.1", 32 | "graphql": "^16.7.1", 33 | "lucide-react": "^0.264.0", 34 | "react": "^18.2.0", 35 | "react-dom": "^18.2.0", 36 | "react-hot-toast": "^2.4.1", 37 | "storybook": "^7.2.1", 38 | "tabler-icons-react": "^1.56.0", 39 | "tailwind-merge": "^1.14.0", 40 | "tailwindcss": "^3.3.3", 41 | "tailwindcss-animate": "^1.0.6", 42 | "urql": "^4.0.5", 43 | "valibot": "^0.19.0", 44 | "vite-tsconfig-paths": "^4.2.0", 45 | "windy-radix-palette": "^0.6.1", 46 | "zod": "^3.21.4" 47 | }, 48 | "devDependencies": { 49 | "@graphql-codegen/cli": "5.0.0", 50 | "@graphql-codegen/client-preset": "4.1.0", 51 | "@graphql-typed-document-node/core": "^3.2.0", 52 | "@storybook/addon-essentials": "7.0.22", 53 | "@storybook/addon-interactions": "7.0.22", 54 | "@storybook/addon-links": "7.0.22", 55 | "@storybook/blocks": "7.0.22", 56 | "@storybook/react": "7.0.22", 57 | "@storybook/react-vite": "7.0.22", 58 | "@storybook/testing-library": "0.0.14-next.2", 59 | "@types/react": "^18.2.18", 60 | "@types/react-dom": "^18.2.7", 61 | "@typescript-eslint/eslint-plugin": "^5.62.0", 62 | "@typescript-eslint/parser": "^5.62.0", 63 | "@vitejs/plugin-react-swc": "^3.3.2", 64 | "eslint": "^8.46.0", 65 | "eslint-plugin-react-hooks": "^4.6.0", 66 | "eslint-plugin-react-refresh": "^0.3.5", 67 | "graphql-codegen-typescript-validation-schema": "^0.11.1", 68 | "prop-types": "15.8.1", 69 | "typescript": "^5.1.6", 70 | "vite": "^4.4.8" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /packages/frontend/public/callstack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calloc134/callstack/f54b2e3150c476fd8ac36ad908b4e386ef9c829f/packages/frontend/public/callstack.png -------------------------------------------------------------------------------- /packages/frontend/src/404.tsx: -------------------------------------------------------------------------------- 1 | // 404ページ 2 | const NotFoundPage = () => ( 3 | <> 4 |
5 |

404

6 |

ページが見つかりませんでした。

7 |
8 | 9 | ); 10 | 11 | export { NotFoundPage }; 12 | -------------------------------------------------------------------------------- /packages/frontend/src/_document.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Outlet, Link } from "@tanstack/react-router"; 3 | import { 4 | Navbar, 5 | NavbarBrand, 6 | NavbarContent, 7 | NavbarItem, 8 | Button, 9 | Avatar, 10 | Tooltip, 11 | Spacer, 12 | Dropdown, 13 | DropdownMenu, 14 | DropdownItem, 15 | DropdownTrigger, 16 | } from "@nextui-org/react"; 17 | import { Login, Menu2, Sun } from "tabler-icons-react"; 18 | import { useAuthn } from "./lib/provider/authn/useAuthn"; 19 | 20 | // 外枠のコンポーネント 21 | export const Document = () => { 22 | // 認証しているかを取得 23 | const { isAuthenticated } = useAuthn(); 24 | 25 | // ダークモードの設定 26 | const [darkMode, setDarkMode] = useState(false); 27 | 28 | const toggleDarkMode = () => { 29 | setDarkMode(!darkMode); 30 | }; 31 | 32 | return ( 33 |
34 |
35 | 36 | 37 | 38 | callstack 39 | 40 | 41 | 42 | 43 | ユーザ一覧 44 | 45 | 46 | 投稿一覧 47 | 48 | 49 | 50 | <> 51 | {/* 十分に画面サイズが大きい場合 */} 52 | 53 | 54 | 57 | 58 | {isAuthenticated ? ( 59 | 68 | ) : ( 69 | 70 | 75 | 76 | )} 77 | 78 | {/* 画面サイズが小さい場合 */} 79 | 80 | 81 | 82 | 85 | 86 | 87 | 88 | 89 | 92 | 93 | 94 | 95 | {isAuthenticated ? ( 96 | 105 | ) : ( 106 | 107 | 112 | 113 | )} 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 |
122 | {" "} 123 | 124 | {/* ここで内側のコンポーネントを表示 */} 125 | 126 |
127 |
128 |
129 | ); 130 | }; 131 | -------------------------------------------------------------------------------- /packages/frontend/src/callback.tsx: -------------------------------------------------------------------------------- 1 | import { useHandleSignInCallback } from "@logto/react"; 2 | import { useNavigate } from "@tanstack/react-router"; 3 | import { Spinner } from "@nextui-org/react"; 4 | 5 | const CallBackPage = () => { 6 | // ログイン後にリダイレクトする関数をフックより取得 7 | const navigate = useNavigate({ 8 | from: "/auth/callback", 9 | }); 10 | 11 | // ログイン後にリダイレクト 12 | const { isLoading } = useHandleSignInCallback(() => { 13 | navigate({ 14 | to: "/auth/posts", 15 | }); 16 | }); 17 | 18 | // ログインしている状態であれば 19 | if (isLoading) { 20 | return ( 21 |
22 | 23 |
24 | ); 25 | } 26 | }; 27 | 28 | export { CallBackPage }; 29 | -------------------------------------------------------------------------------- /packages/frontend/src/config.ts: -------------------------------------------------------------------------------- 1 | import { LogtoConfig } from "@logto/react"; 2 | import { logto_endpoint, logto_app_id, logto_api_resource } from "./env"; 3 | 4 | // Logtoクライアントの設定 5 | // 環境変数より取得 6 | const logto_config: LogtoConfig = { 7 | // Logtoのエンドポイント 8 | endpoint: logto_endpoint, 9 | // Logtoのサインインに使用するアプリケーションID 10 | appId: logto_app_id, 11 | // Logtoで認可してもらう対象のAPIリソース 12 | resources: [logto_api_resource], 13 | }; 14 | 15 | export { logto_config }; 16 | -------------------------------------------------------------------------------- /packages/frontend/src/env.ts: -------------------------------------------------------------------------------- 1 | // 環境変数を取得し、開発環境かどうかを判定 2 | const is_dev = import.meta.env.MODE === "development"; 3 | 4 | // 開発環境であれば、環境変数からJWTを取得 5 | // 本番環境であれば、空文字を設定 6 | const dev_jwt_token = is_dev ? import.meta.env.VITE_JWT_TOKEN || "" : ""; 7 | 8 | // 開発環境であれば、空文字を設定 9 | // 本番環境であれば、LogtoエンドポイントのURLを設定 10 | const logto_endpoint = is_dev ? "" : import.meta.env.VITE_LOGTO_ENDPOINT || ""; 11 | 12 | // 開発環境であれば、空文字を設定 13 | // 本番環境であれば、LogtoのアプリケーションIDを設定 14 | const logto_app_id = is_dev ? "" : import.meta.env.VITE_LOGTO_APPID || ""; 15 | 16 | // 開発環境であれば、空文字を設定 17 | // 本番環境であれば、Logtoのapiリソースを設定 18 | const logto_api_resource = is_dev ? "" : import.meta.env.VITE_LOGTO_API_RESOURCE || ""; 19 | 20 | // ホスト名を取得 21 | // 開発環境であれば、localhostを設定 22 | // 本番環境であれば、環境変数から取得 23 | const hostname = is_dev ? "localhost:6173" : import.meta.env.VITE_HOSTNAME || ""; 24 | 25 | export { is_dev, dev_jwt_token, logto_endpoint, logto_app_id, logto_api_resource, hostname }; 26 | -------------------------------------------------------------------------------- /packages/frontend/src/features/index/pages/IndexPage.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Image, Spacer } from "@nextui-org/react"; 2 | import { Link } from "@tanstack/react-router"; 3 | 4 | export const RootIndexPage = () => { 5 | return ( 6 | <> 7 |
8 | 9 |

callstack

10 |

callstackボイラープレートのサンプルです。

11 | 12 | 15 |
16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/frontend/src/features/posts/components/PostCard.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@tanstack/react-router"; 2 | import { Card, CardBody, CardFooter, Button, Dropdown, DropdownItem, DropdownTrigger, DropdownMenu, CardHeader, Image } from "@nextui-org/react"; 3 | import { FragmentType, useFragment } from "src/lib/generated"; 4 | import { UserCardForPost } from "./UserCardForPost"; 5 | import { graphql } from "src/lib/generated/gql"; 6 | import { DotsVertical, Heart, MessageChatbot } from "tabler-icons-react"; 7 | 8 | // クエリするフラグメントを定義 9 | const PostFragment = graphql(` 10 | fragment PostFragment on Post { 11 | post_uuid 12 | title 13 | body 14 | user { 15 | ...UserPopupFragment 16 | } 17 | } 18 | `); 19 | 20 | const PostCard = ({ post: post_frag }: { post: FragmentType }) => { 21 | // フラグメントの型を指定して対応するデータを取得 22 | const post = useFragment(PostFragment, post_frag); 23 | 24 | return ( 25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 |

{post.title}

34 |
35 |
36 | 37 |
38 |
39 |

{post.body}

40 |
41 |
42 |
43 | 44 | 10 45 |
46 |
47 | 48 | 10 49 |
50 | 53 |
54 |
55 |
56 | 57 |
58 | 59 | 60 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 80 |
81 |
82 |
83 |
84 | ); 85 | }; 86 | 87 | export { PostCard }; 88 | -------------------------------------------------------------------------------- /packages/frontend/src/features/posts/components/PostDetailCard.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardBody, CardFooter, CardHeader, Button, Image, Spacer } from "@nextui-org/react"; 2 | import { DotsVertical, Heart, MessageChatbot } from "tabler-icons-react"; 3 | import { graphql } from "src/lib/generated/gql"; 4 | import { FragmentType, useFragment } from "src/lib/generated"; 5 | import { UserCard } from "src/features/users/components/UserCard"; 6 | 7 | // クエリするフラグメントを定義 8 | const PostDetailFragment = graphql(` 9 | fragment PostDetailFragment on Post { 10 | post_uuid 11 | title 12 | body 13 | created_at 14 | updated_at 15 | is_public 16 | user { 17 | ...UserFragment 18 | } 19 | } 20 | `); 21 | 22 | const PostDetailCard = ({ post: post_frag }: { post: FragmentType }) => { 23 | // フラグメントの型を指定して対応するデータを取得 24 | const post = useFragment(PostDetailFragment, post_frag); 25 | 26 | return ( 27 |
28 | 29 |
30 | 31 | 32 |
33 |

{post.title}

34 |
35 |
36 | 37 |
38 |
39 |

{post.body}

40 |
41 |
42 |
43 | 44 |
45 | 46 | 47 | 51 | 55 | 58 | 59 | 60 |
61 | 62 | 63 |
64 | ); 65 | }; 66 | 67 | export { PostDetailCard }; 68 | -------------------------------------------------------------------------------- /packages/frontend/src/features/posts/components/UserCardForPost.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@tanstack/react-router"; 2 | import { Card, CardBody, CardFooter, Button } from "@nextui-org/react"; 3 | import { FragmentType, useFragment } from "src/lib/generated"; 4 | import { graphql } from "src/lib/generated/gql"; 5 | 6 | // クエリするフラグメントを定義 7 | const UserPopupFragment = graphql(` 8 | fragment UserPopupFragment on User { 9 | user_uuid 10 | handle 11 | screen_name 12 | bio 13 | } 14 | `); 15 | 16 | const UserCardForPost = ({ user: user_frag }: { user: FragmentType }) => { 17 | // フラグメントの型を指定して対応するデータを取得 18 | const user = useFragment(UserPopupFragment, user_frag); 19 | 20 | return ( 21 | 22 | 23 |
24 |
25 |

{user.screen_name}

26 |

@{user.handle}

27 |
28 |
29 |

{user.bio}

30 |
31 |
32 |
33 | 34 |
35 | 45 |
46 |
47 |
48 | ); 49 | }; 50 | 51 | export { UserCardForPost }; 52 | -------------------------------------------------------------------------------- /packages/frontend/src/features/posts/pages/PostDetailPage.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "urql"; 2 | import { graphql } from "src/lib/generated/gql"; 3 | import { PostDetailCard } from "../components/PostDetailCard"; 4 | import { Spinner } from "@nextui-org/react"; 5 | import { useParams } from "@tanstack/react-router"; 6 | 7 | // クエリするフラグメントを定義 8 | const GetPostDetailQuery = graphql(` 9 | query GetPostDetailQuery($uuid: UUID!) { 10 | getPostByUUID(uuid: $uuid) { 11 | ...PostDetailFragment 12 | } 13 | } 14 | `); 15 | 16 | const PostDetailPage = () => { 17 | // URLパラメータより投稿のUUIDを取得 18 | const post_uuid = useParams({ 19 | from: "/auth/posts/$post_uuid", 20 | })?.post_uuid; 21 | 22 | // クエリを行って投稿の情報を取得 23 | const [result] = useQuery({ 24 | query: GetPostDetailQuery, 25 | variables: { 26 | uuid: post_uuid, 27 | }, 28 | }); 29 | 30 | // クエリの結果を取得 31 | const { data, fetching } = result; 32 | 33 | // ローディング中であれば 34 | if (fetching) 35 | return ( 36 |
37 | 38 |
39 | ); 40 | 41 | return ( 42 |
43 |
{data ? :
投稿が見つかりませんでした
}
44 |
45 | ); 46 | }; 47 | 48 | export { PostDetailPage }; 49 | -------------------------------------------------------------------------------- /packages/frontend/src/features/posts/pages/PostsPage.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "urql"; 2 | import { graphql } from "src/lib/generated/gql"; 3 | import { PostCard } from "../components/PostCard"; 4 | import { Spinner } from "@nextui-org/react"; 5 | 6 | // 利用されるクエリの定義 7 | const GetAllPostsQuery = graphql(` 8 | query GetAllPostsQuery { 9 | getAllPosts(limit: 10) { 10 | ...PostFragment 11 | } 12 | } 13 | `); 14 | 15 | const PostsPage = () => { 16 | // graphqlに対してクエリを実行 17 | const [result] = useQuery({ 18 | query: GetAllPostsQuery, 19 | }); 20 | 21 | // クエリの結果を取得 22 | const { data, fetching } = result; 23 | 24 | // ローディング中であれば 25 | if (fetching) 26 | return ( 27 |
28 | 29 |
30 | ); 31 | 32 | return ( 33 |
34 |
35 | {data?.getAllPosts.map((post, i) => ( 36 | 37 | ))} 38 |
39 |
40 | ); 41 | // graphqlのフラグメントマスキングでやむを得ずmapのkeyでiを使っているので、少し心配 42 | }; 43 | 44 | export { PostsPage }; 45 | -------------------------------------------------------------------------------- /packages/frontend/src/features/users/components/PostCardForUser.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@tanstack/react-router"; 2 | import { Card, CardBody, CardFooter, Button } from "@nextui-org/react"; 3 | import { FragmentType, useFragment } from "src/lib/generated"; 4 | import { graphql } from "src/lib/generated/gql"; 5 | 6 | // クエリするフラグメントを定義 7 | const PostPopupFragment = graphql(` 8 | fragment PostPopupFragment on Post { 9 | post_uuid 10 | title 11 | body 12 | } 13 | `); 14 | 15 | // フラグメントの定義 16 | // ユーザ画面でポップアップとして表示される投稿カード 17 | const PostCardForUser = ({ post: post_frag }: { post: FragmentType }) => { 18 | // フラグメントの型を指定して対応するデータを取得 19 | const post = useFragment(PostPopupFragment, post_frag); 20 | 21 | return ( 22 | 23 | 24 |
25 |
26 |

{post.title}

27 |
28 |
29 |

{post.body}

30 |
31 |
32 |
33 | 34 |
35 | 45 |
46 |
47 |
48 | ); 49 | }; 50 | 51 | export { PostCardForUser }; 52 | -------------------------------------------------------------------------------- /packages/frontend/src/features/users/components/UserCard.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@tanstack/react-router"; 2 | import { Card, CardBody, CardFooter, Button, Image, Spacer } from "@nextui-org/react"; 3 | import { FragmentType, useFragment } from "src/lib/generated"; 4 | import { graphql } from "src/lib/generated/gql"; 5 | 6 | // クエリするフラグメントを定義 7 | const UserFragment = graphql(` 8 | fragment UserFragment on User { 9 | user_uuid 10 | handle 11 | screen_name 12 | bio 13 | image_url 14 | } 15 | `); 16 | 17 | const UserCard = ({ user: user_flag }: { user: FragmentType }) => { 18 | // フラグメントの型を指定して対応するデータを取得 19 | const user = useFragment(UserFragment, user_flag); 20 | 21 | return ( 22 |
23 | 24 | 32 |
33 | 34 | 35 |
36 |
37 |

{user.screen_name}

38 |

@{user.handle}

39 |
40 |
41 |

{user.bio}

42 |
43 |
44 |
45 |
46 | 47 |
48 | 58 |
59 |
60 |
61 |
62 | ); 63 | }; 64 | 65 | export { UserCard }; 66 | -------------------------------------------------------------------------------- /packages/frontend/src/features/users/components/UserDetailBioInput.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import { ModalContent, ModalBody, ModalHeader, ModalFooter, Button, Textarea } from "@nextui-org/react"; 3 | import toast from "react-hot-toast"; 4 | import { useMutation } from "urql"; 5 | import { graphql } from "src/lib/generated/gql"; 6 | 7 | // 自分のプロフィールの自己紹介文を更新するためのミューテーションを定義 8 | const UpdateMyBioMutation = graphql(` 9 | mutation UpdateMyBioMutation($input: UpdateUserInput!) { 10 | updateMyUser(input: $input) { 11 | bio 12 | } 13 | } 14 | `); 15 | 16 | const UserDetailBioInput = ({ bio, onClose }: { bio: string; onClose: () => void }) => { 17 | // フォームの入力値を取得するための参照を取得するフックを実行 18 | const input_ref = useRef(null); 19 | 20 | // 自己紹介文のミューテーション用のフックを実行 21 | const [, update_my_profile] = useMutation(UpdateMyBioMutation); 22 | 23 | // フォームが送信されたときの処理 24 | const handle_submit = async (e: React.FormEvent) => { 25 | e.preventDefault(); 26 | 27 | // 参照が取得できなかった場合はエラーを表示 28 | if (input_ref === null || input_ref.current === null) { 29 | toast.error("エラーが発生しました。"); 30 | return; 31 | } 32 | 33 | // 50文字以上の場合はエラーを表示 34 | if (input_ref.current?.value?.length > 400) { 35 | toast.error("自己紹介文は400文字以内で入力してください。"); 36 | return; 37 | } 38 | 39 | // ミューテーションを実行 40 | const result = await update_my_profile({ 41 | input: { 42 | bio: input_ref.current.value, 43 | }, 44 | }); 45 | 46 | if (result.error) { 47 | toast.error("エラーが発生しました。"); 48 | return; 49 | } 50 | 51 | toast.success("自己紹介文を更新しました。"); 52 | onClose(); 53 | return; 54 | }; 55 | 56 | return ( 57 | 58 |
59 | 自己紹介文を編集 60 | 61 |