/'
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | callstack
2 |
3 |
4 |
5 |
6 | pnpm + turborepoで構成されたモノリポ構成のボイラープレート
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
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 |
--------------------------------------------------------------------------------
/packages/frontend/src/features/users/components/UserDetailCard.tsx:
--------------------------------------------------------------------------------
1 | import { graphql } from "src/lib/generated/gql";
2 | import { Card, CardBody, CardFooter, Image, Spacer, Modal, useDisclosure } from "@nextui-org/react";
3 | import { FragmentType, useFragment } from "src/lib/generated";
4 | import { UserDetailScreenNameInput } from "./UserDetailScreenNameInput";
5 | import { UserDetailHandleInput } from "./UserDetailHandleInput";
6 | import { UserDetailBioInput } from "./UserDetailBioInput";
7 | import { UserDetailProfileImageInput } from "./UserDetailProfileImageInput";
8 |
9 | // クエリするフラグメントを定義
10 | const UserDetailFragment = graphql(`
11 | fragment UserDetailFragment on User {
12 | user_uuid
13 | handle
14 | screen_name
15 | bio
16 | image_url
17 | created_at
18 | updated_at
19 | role
20 | posts {
21 | ...PostPopupFragment
22 | }
23 | }
24 | `);
25 |
26 | const UserDetailCard = ({ my_user_uuid, user_frag }: { my_user_uuid: string; user_frag: FragmentType }) => {
27 | // フラグメントの型を指定して対応するデータを取得
28 | const user = useFragment(UserDetailFragment, user_frag);
29 |
30 | // スクリーンネーム用のモーダル用のフックを実行
31 | const { isOpen: sc_isOpen, onOpen: sc_onOpen, onOpenChange: sc_onOpenChange, onClose: sc_onClose } = useDisclosure();
32 | // ハンドル用のモーダル用のフックを実行
33 | const { isOpen: hd_isOpen, onOpen: hd_onOpen, onOpenChange: hd_onOpenChange, onClose: hd_onClose } = useDisclosure();
34 | // 自己紹介文用のモーダル用のフックを実行
35 | const { isOpen: bio_isOpen, onOpen: bio_onOpen, onOpenChange: bio_onOpenChange, onClose: bio_onClose } = useDisclosure();
36 |
37 | // 現在のログインユーザが自分自身かどうかを判定
38 | const is_myself = my_user_uuid === user.user_uuid;
39 |
40 | return (
41 |
42 |
43 |
44 |
45 |
46 | {is_myself && }
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | {user.screen_name}
56 |
57 |
58 | @{user.handle}
59 |
60 |
61 |
62 |
63 | {user.bio}
64 |
65 |
66 |
67 |
68 |
69 |
70 | {is_myself && (
71 | <>
72 |
73 |
74 |
75 | >
76 | )}
77 | {is_myself && (
78 | <>
79 |
80 |
81 |
82 | >
83 | )}
84 | {is_myself && (
85 | <>
86 |
87 |
88 |
89 | >
90 | )}
91 |
92 |
93 |
94 |
95 | );
96 | };
97 |
98 | export { UserDetailCard };
99 |
--------------------------------------------------------------------------------
/packages/frontend/src/lib/provider/urql/UrqlProvider.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, useState, useCallback } from "react";
2 | import { Provider, Client, cacheExchange, fetchExchange, mapExchange, MapExchangeOpts } from "urql";
3 | import { authExchange, AuthUtilities, AuthConfig } from "@urql/exchange-auth";
4 | import toast from "react-hot-toast";
5 | import { devtoolsExchange } from "@urql/devtools";
6 |
7 | import { is_dev, hostname, logto_api_resource } from "src/env";
8 | import { useAuthn } from "src/lib/provider/authn/useAuthn";
9 |
10 | // TODO: エラーハンドリングをMapExchangeで行う
11 | const UrqlProvider = ({ children }: { children: ReactNode }) => {
12 | // urqlクライアントの設定
13 | const { isAuthenticated, getAccessToken, signIn } = useAuthn();
14 | // jwtの状態
15 | const [jwt, setJwt] = useState("");
16 | // フェッチしているかを判定するフラグ
17 | const [isFetching, setIsFetching] = useState(false);
18 |
19 | // urqlのauth Exchange用の設定
20 | const authInit: (utilities: AuthUtilities) => Promise = useCallback(
21 | async (utils) => {
22 | return {
23 | willAuthError() {
24 | return jwt === "";
25 | },
26 | didAuthError(error) {
27 | // GraphQLのエラー
28 | // isAuthenticaedがtrueなのはAuthnProviderで認証済みと判定されているため
29 | // jwtが空文字かつauthz_not_logged_inのエラーがある場合は未認証とみなす
30 | // また、invalid_tokenのエラーがある場合も未認証とみなす
31 | // jwtが空文字でなく、authz_not_logged_inのエラーがある場合はまだjwtが反映されていないとしてスルー
32 |
33 | return (
34 | (isAuthenticated && jwt === "" && error.graphQLErrors.some((e) => e.extensions?.code === "authz_not_logged_in")) ||
35 | error.graphQLErrors.some((e) => e.extensions?.code === "jwt_expired") ||
36 | error.graphQLErrors.some((e) => e.extensions?.code === "jwt_invalid_signature") ||
37 | error.graphQLErrors.some((e) => e.extensions?.code === "jwt_not_before") ||
38 | error.graphQLErrors.some((e) => e.extensions?.code === "jwt_web_token_error")
39 | );
40 | },
41 | async refreshAuth() {
42 | if (!isAuthenticated) {
43 | // 未認証もしくは認証済みでもロード中の場合は何もしない
44 | return;
45 | }
46 |
47 | // もしフェッチ中であれば何もしない
48 | if (isFetching) {
49 | return;
50 | }
51 |
52 | // useStateをフラグとして利用
53 | setIsFetching(true);
54 |
55 | // トークンの取得
56 | const jwt = await getAccessToken(is_dev ? "" : logto_api_resource);
57 |
58 | if (jwt === undefined) {
59 | // トークンがundefinedの場合はサインインしなおす
60 | signIn(`https://${hostname}/auth/callback`);
61 | return;
62 | }
63 |
64 | setJwt(jwt);
65 |
66 | // フェッチが終わったのでフラグを下ろす
67 | setIsFetching(false);
68 | },
69 | addAuthToOperation(operation) {
70 | // 処理前に渡されていた分のfetchOptionsの適用
71 | const fetchOptions = typeof operation.context.fetchOptions === "function" ? operation.context.fetchOptions() : operation.context.fetchOptions || {};
72 |
73 | // ヘッダのオブジェクトの作成
74 | const headers = new Headers(fetchOptions.headers);
75 | // fetchOptionsによって既にヘッダがある場合は上書きしない
76 | if (headers.get("Authorization")) return operation;
77 |
78 | // 認証済みかつjwtがあり、jwtが空文字でない場合はヘッダに追加
79 | if (isAuthenticated && jwt !== "") {
80 | return utils.appendHeaders(operation, {
81 | // ヘッダの設定
82 | Authorization: `Bearer ${jwt}`,
83 | });
84 | }
85 | return operation;
86 | },
87 | };
88 | },
89 | [isAuthenticated, isFetching, jwt]
90 | );
91 |
92 | const mapInit: MapExchangeOpts = {
93 | onResult(result) {
94 | // エラーの場合はトーストを表示
95 | if (result.error) {
96 | toast.error(result.error.message, {
97 | icon: "❌",
98 | });
99 | }
100 | },
101 | };
102 |
103 | const urql_client = new Client({
104 | // 開発環境であればhttp、本番環境であればhttpsを使う
105 | // ホストネームよりフェッチ先のURLを生成
106 | url: `${is_dev ? "http" : "https"}://${hostname}/api/graphql`,
107 | exchanges: [
108 | // 開発環境であればdevtoolsを使う
109 | ...(is_dev ? [devtoolsExchange] : []),
110 | cacheExchange,
111 | authExchange(authInit),
112 | mapExchange(mapInit),
113 | fetchExchange,
114 | ],
115 | });
116 |
117 | return (
118 | // urql用のprovider
119 | {children}
120 | );
121 | };
122 |
123 | export { UrqlProvider };
124 |
--------------------------------------------------------------------------------
/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/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/docs/static/img/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------