diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..42bf91f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 0000000..48d5f81 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,10 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: '' +assignees: '' + +--- + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..7320910 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,101 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '31 23 * * 4' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: go + build-mode: autobuild + - language: javascript-typescript + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - name: Run manual build steps + if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/hadolint.yml b/.github/workflows/hadolint.yml new file mode 100644 index 0000000..d666c82 --- /dev/null +++ b/.github/workflows/hadolint.yml @@ -0,0 +1,47 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# hadoint is a Dockerfile linter written in Haskell +# that helps you build best practice Docker images. +# More details at https://github.com/hadolint/hadolint + +name: Hadolint + +on: + push: + branches: [ "main", "gitbot" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main" ] + schedule: + - cron: '32 15 * * 0' + +permissions: + contents: read + +jobs: + hadolint: + name: Run hadolint scanning + runs-on: ubuntu-latest + permissions: + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run hadolint + uses: hadolint/hadolint-action@f988afea3da57ee48710a9795b6bb677cc901183 + with: + dockerfile: ./Dockerfile + format: sarif + output-file: hadolint-results.sarif + no-fail: true + + - name: Upload analysis results to GitHub + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: hadolint-results.sarif + wait-for-processing: true diff --git a/.github/workflows/nextjs.yml b/.github/workflows/nextjs.yml new file mode 100644 index 0000000..d1837be --- /dev/null +++ b/.github/workflows/nextjs.yml @@ -0,0 +1,93 @@ +# Sample workflow for building and deploying a Next.js site to GitHub Pages +# +# To get started with Next.js see: https://nextjs.org/docs/getting-started +# +name: Deploy Next.js site to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Detect package manager + id: detect-package-manager + run: | + if [ -f "${{ github.workspace }}/yarn.lock" ]; then + echo "manager=yarn" >> $GITHUB_OUTPUT + echo "command=install" >> $GITHUB_OUTPUT + echo "runner=yarn" >> $GITHUB_OUTPUT + exit 0 + elif [ -f "${{ github.workspace }}/package.json" ]; then + echo "manager=npm" >> $GITHUB_OUTPUT + echo "command=ci" >> $GITHUB_OUTPUT + echo "runner=npx --no-install" >> $GITHUB_OUTPUT + exit 0 + else + echo "Unable to determine package manager" + exit 1 + fi + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: ${{ steps.detect-package-manager.outputs.manager }} + - name: Setup Pages + uses: actions/configure-pages@v5 + with: + # Automatically inject basePath in your Next.js configuration file and disable + # server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized). + # + # You may remove this line if you want to manage the configuration yourself. + static_site_generator: next + - name: Restore cache + uses: actions/cache@v4 + with: + path: | + .next/cache + # Generate a new cache whenever packages or source files change. + key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} + # If source files changed but packages didn't, rebuild from a prior cache. + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}- + - name: Install dependencies + run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} + - name: Build with Next.js + run: ${{ steps.detect-package-manager.outputs.runner }} next build + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./out + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/policy-validator-tf.yml b/.github/workflows/policy-validator-tf.yml new file mode 100644 index 0000000..55f8b3d --- /dev/null +++ b/.github/workflows/policy-validator-tf.yml @@ -0,0 +1,101 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# This workflow will validate the IAM policies in the terraform (TF) templates with using the standard and custom checks in AWS IAM Access Analyzer +# To use this workflow, you will need to complete the following set up steps before start using it: +# 1. Configure an AWS IAM role to use the Access Analyzer's ValidatePolicy, CheckNoNewAccess and CheckAccessNotGranted. This IAM role must be configured to call from the GitHub Actions, use the following [doc](https://aws.amazon.com/blogs/security/use-iam-roles-to-connect-github-actions-to-actions-in-aws/) for steps. +# 2. If you're using CHECK_NO_NEW_ACCESS policy-check-type, you need to create a reference policy. Use the guide [here](https://github.com/aws-samples/iam-access-analyzer-custom-policy-check-samples?tab=readme-ov-file#how-do-i-write-my-own-reference-policies) and store it your GitHub repo. +# 3. If you're using the CHECK_ACCESS_NOT_GRANTED policy-check-type, identify the list of critical actions that shouldn't be granted access by the policies in the TF templates. +# 4. Start using the GitHub actions by generating the GitHub events matching the defined criteria in your workflow. + +name: Validate AWS IAM policies in Terraform templates using Policy Validator +on: + push: + branches: ["main", "gitbot"] + pull_request: + # The branches below must be a subset of the branches above + branches: ["main"] +env: + AWS_ROLE: MY_ROLE # set this with the role ARN which has permissions to invoke access-analyzer:ValidatePolicy,access-analyzer:CheckNoNewAccess, access-analyzer:CheckAccessNotGranted and can be used in GitHub actions + REGION: MY_AWS_REGION # set this to your preferred AWS region where you plan to deploy your policies, e.g. us-west-1 + TEMPLATE_PATH: FILE_PATH_TO_THE_TF_PLAN # set this to the file path to the terraform plan in JSON + ACTIONS: MY_LIST_OF_ACTIONS # set to pass list of actions in the format action1, action2,.. One of `ACTIONS` or `RESOURCES` is required if you are using `CHECK_ACCESS_NOT_GRANTED` policy-check-type. + RESOURCES: MY_LIST_OF_RESOURCES # set to pass list of resource ARNs in the format resource1, resource2,.. One of `ACTIONS` or `RESOURCES` is required if you are using `CHECK_ACCESS_NOT_GRANTED` policy-check-type. + REFERENCE_POLICY: REFERENCE_POLICY # set to pass a JSON formatted file that specifies the path to the reference policy that is used for a permissions comparison. For example, if you stored such path in a GitHub secret with name REFERENCE_IDENTITY_POLICY , you can pass ${{ secrets.REFERENCE_IDENTITY_POLICY }}. If not you have the reference policy in the repository, you can directly pass it's path. This is required if you are using `CHECK_NO_NEW_ACCESS_CHECK` policy-check-type. + REFERENCE_POLICY_TYPE: TYPE_OF_REFERENCE_POLICY # set to pass the policy type associated with the IAM policy under analysis and the reference policy. This is required if you are using `CHECK_NO_NEW_ACCESS_CHECK` policy-check-type. + +jobs: + policy-validator: + runs-on: ubuntu-latest # Virtual machine to run the workflow (configurable) + #https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services#updating-your-github-actions-workflow + #https://aws.amazon.com/blogs/security/use-iam-roles-to-connect-github-actions-to-actions-in-aws/ + permissions: + id-token: write # This is required for requesting the JWT + contents: read # This is required for actions/checkout + # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners + name: Policy Validator checks for AWS IAM policies + steps: + # checkout the repo for workflow to access the contents + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + # Configure AWS Credentials. More configuration details here- https://github.com/aws-actions/configure-aws-credentials + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 + with: + role-to-assume: ${{ env.AWS_ROLE }} + aws-region: ${{ env.REGION }} + # Run the VALIDATE_POLICY check. More configuration details here - https://github.com/aws-actions/terraform-aws-iam-policy-validator + - name: Run AWS AccessAnalyzer ValidatePolicy check + id: run-aws-validate-policy + uses: aws-actions/terraform-aws-iam-policy-validator@26797c40250bf1ee50af8996a2475b9b5a8b8927 #v1.0.2 + with: + policy-check-type: "VALIDATE_POLICY" + template-path: ${{ env.TEMPLATE_PATH }} + region: ${{ env.REGION }} + # Print result from VALIDATE_POLICY check + - name: Print the result for ValidatePolicy check + if: success() || failure() + run: echo "${{ steps.run-aws-validate-policy.outputs.result }}" + # Run the CHECK_ACCESS_NOT_GRANTED check. More configuration details here - https://github.com/aws-actions/terraform-aws-iam-policy-validator + - name: Run AWS AccessAnalyzer CheckAccessNotGranted check + id: run-aws-check-access-not-granted + uses: aws-actions/terraform-aws-iam-policy-validator@26797c40250bf1ee50af8996a2475b9b5a8b8927 #v1.0.2 + with: + policy-check-type: "CHECK_ACCESS_NOT_GRANTED" + template-path: ${{ env.TEMPLATE_PATH }} + actions: ${{ env.ACTIONS }} + resources: ${{ env.RESOURCES }} + region: ${{ env.REGION }} + # Print result from CHECK_ACCESS_NOT_GRANTED check + - name: Print the result for CheckAccessNotGranted check + if: success() || failure() + run: echo "${{ steps.run-aws-check-access-not-granted.outputs.result }}" + # Run the CHECK_NO_NEW_ACCESS check. More configuration details here - https://github.com/aws-actions/terraform-aws-iam-policy-validator + # reference-policy is stored in GitHub secrets + - name: Run AWS AccessAnalyzer CheckNoNewAccess check + id: run-aws-check-no-new-access + uses: aws-actions/terraform-aws-iam-policy-validator@26797c40250bf1ee50af8996a2475b9b5a8b8927 #v1.0.2 + with: + policy-check-type: "CHECK_NO_NEW_ACCESS" + template-path: ${{ env.TEMPLATE_PATH }} + reference-policy: ${{ env.REFERENCE_POLICY }} + reference-policy-type: ${{ env.REFERENCE_POLICY_TYPE }} + region: ${{ env.REGION }} + # Print result from CHECK_NO_NEW_ACCESS check + - name: Print the result CheckNoNewAccess check + if: success() || failure() + run: echo "${{ steps.run-aws-check-no-new-access.outputs.result }}" + # Run the CHECK_NO_PUBLIC_ACCESS check. More configuration details here - https://github.com/aws-actions/terraform-aws-iam-policy-validator + - name: Run AWS AccessAnalyzer CheckNoPublicAccess check + id: run-aws-check-no-public-access + uses: aws-actions/terraform-aws-iam-policy-validator@26797c40250bf1ee50af8996a2475b9b5a8b8927 #v1.0.2 + with: + policy-check-type: "CHECK_NO_PUBLIC_ACCESS" + template-path: ${{ env.TEMPLATE_PATH }} + region: ${{ env.REGION }} + # Print result from CHECK_NO_PUBLIC_ACCESS check + - name: Print the result for CheckNoPublicAccess check + if: success() || failure() + run: echo "${{ steps.run-aws-check-no-public-access.outputs.result }}" diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b242572 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "githubPullRequests.ignoredPullRequestBranches": [ + "main" + ] +} \ No newline at end of file diff --git "a/Ch\341\273\215n gi\341\272\245y ph\303\251p m\303\243 ngu\341\273\223n m\341\273\237 _ Ch\341\273\215n gi\341\272\245y ph\303\251p (1)" "b/Ch\341\273\215n gi\341\272\245y ph\303\251p m\303\243 ngu\341\273\223n m\341\273\237 _ Ch\341\273\215n gi\341\272\245y ph\303\251p (1)" new file mode 100644 index 0000000..817981c --- /dev/null +++ "b/Ch\341\273\215n gi\341\272\245y ph\303\251p m\303\243 ngu\341\273\223n m\341\273\237 _ Ch\341\273\215n gi\341\272\245y ph\303\251p (1)" @@ -0,0 +1,1017 @@ + From: +Snapshot-Content-Location: https://choosealicense.com/ +Subject: =?utf-8?Q?Ch=E1=BB=8Dn=20gi=E1=BA=A5y=20ph=C3=A9p=20m=C3=A3=20ngu=E1=BB?= + =?utf-8?Q?=93n=20m=E1=BB=9F=20|=20Ch=E1=BB=8Dn=20gi=E1=BA=A5y=20ph=C3=A9p?= +Date: Tue, 16 Jun 2026 13:08:20 +0700 +MIME-Version: 1.0 +Content-Type: multipart/related; + type="text/html"; + boundary="----MultipartBoundary--kxwvuFwyuMYcePAR51VZyTenwsfxKEx0HaVbZWicZr----" + + +------MultipartBoundary--kxwvuFwyuMYcePAR51VZyTenwsfxKEx0HaVbZWicZr---- +Content-Type: text/html +Content-ID: +Content-Transfer-Encoding: binary +Content-Location: https://choosealicense.com/ + + + + + + + + + + +Chọn giấy phép mã nguồn mở | Chọn giấy phép + + + + + + + + + + + + + + + + + +
+ + + + + +

Chọn giấy phép mã nguồn mở

+ + + +

Giấy phép mã nguồn mở bảo vệ cả người đóng góp và người dùng. Các doanh nghiệp và nhà phát triển am hiểu sẽ không tham gia vào một dự án nào nếu không có sự bảo vệ này.

+ +

+ { + Tình huống của bạn được mô tả chính xác nhất bằng các lựa chọn sau đây? + } +

+ +

+ { + Nếu không có giải pháp nào trong số này hiệu quả với tôi thì sao? + } +

+ + + + + +
Văn bản gốc
Đánh giá bản dịch này
Ý kiến phản hồi của bạn sẽ được dùng để góp phần cải thiện Google Dịch
+ + + + + + +------MultipartBoundary--kxwvuFwyuMYcePAR51VZyTenwsfxKEx0HaVbZWicZr---- +Content-Type: image/svg+xml +Content-Transfer-Encoding: binary +Content-Location: https://fonts.gstatic.com/s/i/productlogos/translate/v14/24px.svg + + +------MultipartBoundary--kxwvuFwyuMYcePAR51VZyTenwsfxKEx0HaVbZWicZr---- +Content-Type: text/css +Content-Transfer-Encoding: binary +Content-Location: https://www.gstatic.com/_/translate_http/_/ss/k=translate_http.tr.zZZZhVqDDCw.L.W.O/am=BBCABg/d=0/rs=AN8SPfrkE3FGhJXryfBRpx1gYoqebAENWQ/m=el_main_css + +@charset "utf-8"; + +.VIpgJd-ZVi9od-ORHb-OEVmcd { left: 0px; top: 0px; height: 39px; width: 100%; z-index: 10000001; position: fixed; border-width: medium medium 1px; border-style: none none solid; border-color: currentcolor currentcolor rgb(107, 144, 218); border-image: initial; margin: 0px; box-shadow: rgb(153, 153, 153) 0px 0px 8px 1px; } + +.VIpgJd-ZVi9od-xl07Ob-OEVmcd { z-index: 10000002; border-width: medium; border-style: none; border-color: currentcolor; border-image: initial; position: fixed; box-shadow: rgb(153, 153, 153) 0px 3px 8px 2px; } + +.VIpgJd-ZVi9od-SmfZ-OEVmcd { z-index: 10000000; border-width: medium; border-style: none; border-color: currentcolor; border-image: initial; margin: 0px; } + +.goog-te-gadget { font-family: arial; font-size: 11px; color: rgb(102, 102, 102); white-space: nowrap; } + +.goog-te-gadget img { vertical-align: middle; border-width: medium; border-style: none; border-color: currentcolor; border-image: initial; } + +.goog-te-gadget-simple { background-color: rgb(255, 255, 255); border-width: 1px; border-style: solid; border-color: rgb(155, 155, 155) rgb(213, 213, 213) rgb(232, 232, 232); font-size: 10pt; display: inline-block; padding-top: 1px; padding-bottom: 2px; cursor: pointer; } + +.goog-te-gadget-icon { margin-left: 2px; margin-right: 2px; width: 19px; height: 19px; border-width: medium; border-style: none; border-color: currentcolor; border-image: initial; vertical-align: middle; } + +.goog-te-combo { margin-left: 4px; margin-right: 4px; vertical-align: baseline; } + +.goog-te-gadget .goog-te-combo { margin: 4px 0px; } + +.VIpgJd-ZVi9od-l4eHX-hSRGPd, .VIpgJd-ZVi9od-l4eHX-hSRGPd:active, .VIpgJd-ZVi9od-l4eHX-hSRGPd:hover, .VIpgJd-ZVi9od-l4eHX-hSRGPd:link, .VIpgJd-ZVi9od-l4eHX-hSRGPd:visited { font-size: 12px; font-weight: 700; color: rgb(68, 68, 68); text-decoration: none; } + +.VIpgJd-ZVi9od-ORHb .VIpgJd-ZVi9od-l4eHX-hSRGPd, .VIpgJd-ZVi9od-TvD9Pc-hSRGPd { display: block; margin: 0px 10px; } + +.VIpgJd-ZVi9od-ORHb .VIpgJd-ZVi9od-l4eHX-hSRGPd { padding-top: 2px; padding-left: 4px; } + +.VIpgJd-ZVi9od-ORHb *, .VIpgJd-ZVi9od-SmfZ *, .VIpgJd-ZVi9od-l9xktf *, .VIpgJd-ZVi9od-vH1Gmf *, .VIpgJd-ZVi9od-xl07Ob *, .goog-te-combo { font-family: arial; font-size: 10pt; } + +.VIpgJd-ZVi9od-ORHb { margin: 0px; background-color: rgb(228, 239, 251); overflow: hidden; } + +.VIpgJd-ZVi9od-ORHb img { border-width: medium; border-style: none; border-color: currentcolor; border-image: initial; } + +.VIpgJd-ZVi9od-ORHb-bN97Pc { color: rgb(0, 0, 0); } + +.VIpgJd-ZVi9od-ORHb-bN97Pc img { vertical-align: middle; } + +.VIpgJd-ZVi9od-ORHb-Tswv1b { color: rgb(102, 102, 102); vertical-align: top; margin-top: 0px; font-size: 7pt; } + +.VIpgJd-ZVi9od-ORHb-KE6vqe { width: 8px; } + +.VIpgJd-ZVi9od-LgbsSe { border-color: rgb(231, 231, 231); border-style: none solid solid none; border-width: 0px 1px 1px 0px; } + +.VIpgJd-ZVi9od-LgbsSe div { border-color: rgb(204, 204, 204) rgb(153, 153, 153) rgb(153, 153, 153) rgb(204, 204, 204); border-style: solid; border-width: 1px; height: 20px; } + +.VIpgJd-ZVi9od-LgbsSe button { background: transparent; border-width: medium; border-style: none; border-color: currentcolor; border-image: initial; cursor: pointer; height: 20px; overflow: hidden; margin: 0px; vertical-align: top; white-space: nowrap; } + +.VIpgJd-ZVi9od-LgbsSe button:active { background: none 0px 0px repeat scroll rgb(204, 204, 204); } + +.VIpgJd-ZVi9od-SmfZ { margin: 0px; background-color: rgb(255, 255, 255); white-space: nowrap; } + +.VIpgJd-ZVi9od-SmfZ-hSRGPd { text-decoration: none; font-weight: 700; font-size: 10pt; border: 1px outset rgb(136, 136, 136); padding: 6px 10px; white-space: nowrap; position: absolute; left: 0px; top: 0px; } + +.VIpgJd-ZVi9od-SmfZ-hSRGPd img { margin-left: 2px; margin-right: 2px; width: 19px; height: 19px; border-width: medium; border-style: none; border-color: currentcolor; border-image: initial; vertical-align: middle; } + +.VIpgJd-ZVi9od-SmfZ-hSRGPd span { text-decoration: underline; margin-left: 2px; margin-right: 2px; vertical-align: middle; } + +.goog-te-float-top .VIpgJd-ZVi9od-SmfZ-hSRGPd { padding: 2px; border-top-width: 0px; } + +.goog-te-float-bottom .VIpgJd-ZVi9od-SmfZ-hSRGPd { padding: 2px; border-bottom-width: 0px; } + +.VIpgJd-ZVi9od-xl07Ob-lTBxed { text-decoration: none; color: rgb(0, 0, 204); white-space: nowrap; margin-left: 4px; margin-right: 4px; } + +.VIpgJd-ZVi9od-xl07Ob-lTBxed span { text-decoration: underline; } + +.VIpgJd-ZVi9od-xl07Ob-lTBxed img { margin-left: 2px; margin-right: 2px; } + +.goog-te-gadget-simple .VIpgJd-ZVi9od-xl07Ob-lTBxed { color: rgb(0, 0, 0); } + +.goog-te-gadget-simple .VIpgJd-ZVi9od-xl07Ob-lTBxed span { text-decoration: none; } + +.VIpgJd-ZVi9od-xl07Ob { background-color: rgb(255, 255, 255); text-decoration: none; border: 2px solid rgb(195, 217, 255); overflow: hidden scroll; position: absolute; left: 0px; top: 0px; } + +.VIpgJd-ZVi9od-xl07Ob-ibnC6b { padding: 3px; text-decoration: none; } + +.VIpgJd-ZVi9od-xl07Ob-ibnC6b, .VIpgJd-ZVi9od-xl07Ob-ibnC6b:link { color: rgb(0, 0, 204); background: rgb(255, 255, 255); } + +.VIpgJd-ZVi9od-xl07Ob-ibnC6b:visited { color: rgb(85, 26, 139); } + +.VIpgJd-ZVi9od-xl07Ob-ibnC6b:hover { background: rgb(195, 217, 255); } + +.VIpgJd-ZVi9od-xl07Ob-ibnC6b:active { color: rgb(0, 0, 204); } + +.VIpgJd-ZVi9od-vH1Gmf { background-color: rgb(255, 255, 255); text-decoration: none; border: 1px solid rgb(107, 144, 218); overflow: hidden; padding: 4px; } + +.VIpgJd-ZVi9od-vH1Gmf-KrhPNb { width: 16px; } + +.VIpgJd-ZVi9od-vH1Gmf-hgDUwe { margin: 6px 0px; height: 1px; background-color: rgb(170, 170, 170); overflow: hidden; } + +.VIpgJd-ZVi9od-vH1Gmf-ibnC6b div, .VIpgJd-ZVi9od-vH1Gmf-ibnC6b-gk6SMd div { padding: 4px; } + +.VIpgJd-ZVi9od-vH1Gmf-ibnC6b .uDEFge { display: none; } + +.VIpgJd-ZVi9od-vH1Gmf-ibnC6b-gk6SMd .fmcmS { padding-left: 4px; padding-right: 4px; } + +.VIpgJd-ZVi9od-vH1Gmf-ibnC6b, .VIpgJd-ZVi9od-vH1Gmf-ibnC6b-gk6SMd { text-decoration: none; } + +.VIpgJd-ZVi9od-vH1Gmf-ibnC6b div, .VIpgJd-ZVi9od-vH1Gmf-ibnC6b:active div, .VIpgJd-ZVi9od-vH1Gmf-ibnC6b:link div, .VIpgJd-ZVi9od-vH1Gmf-ibnC6b:visited div { color: rgb(0, 0, 204); background: rgb(255, 255, 255); } + +.VIpgJd-ZVi9od-vH1Gmf-ibnC6b:hover div { color: rgb(255, 255, 255); background: rgb(51, 102, 204); } + +.VIpgJd-ZVi9od-vH1Gmf-ibnC6b-gk6SMd div, .VIpgJd-ZVi9od-vH1Gmf-ibnC6b-gk6SMd:active div, .VIpgJd-ZVi9od-vH1Gmf-ibnC6b-gk6SMd:hover div, .VIpgJd-ZVi9od-vH1Gmf-ibnC6b-gk6SMd:link div, .VIpgJd-ZVi9od-vH1Gmf-ibnC6b-gk6SMd:visited div { color: rgb(0, 0, 0); font-weight: 700; } + +.VIpgJd-ZVi9od-l9xktf { background-color: rgb(255, 255, 255); overflow: hidden; padding: 8px; border-width: medium; border-style: none; border-color: currentcolor; border-image: initial; border-radius: 10px; } + +.VIpgJd-ZVi9od-l9xktf-OEVmcd { background-color: rgb(255, 255, 255); border: 1px solid rgb(107, 144, 218); box-shadow: rgb(153, 153, 153) 0px 3px 8px 2px; border-radius: 8px; } + +.VIpgJd-ZVi9od-l9xktf img { border-width: medium; border-style: none; border-color: currentcolor; border-image: initial; } + +.VIpgJd-ZVi9od-l9xktf-fmcmS { margin-top: 6px; } + +.VIpgJd-ZVi9od-l9xktf-VgwJlc { margin-top: 6px; white-space: nowrap; } + +.VIpgJd-ZVi9od-l9xktf-VgwJlc * { vertical-align: middle; } + +.VIpgJd-ZVi9od-l9xktf-VgwJlc .DUGJie { background-image: url("https://www&google.com/images/zippy_minus_sm.gif"); } + +.VIpgJd-ZVi9od-l9xktf-VgwJlc .TdyTDe { background-image: url("https://www&google.com/images/zippy_plus_sm.gif"); } + +.VIpgJd-ZVi9od-l9xktf-VgwJlc span { color: rgb(0, 0, 204); text-decoration: underline; cursor: pointer; margin: 0px 4px; } + +.VIpgJd-ZVi9od-l9xktf-I9GLp { margin: 6px 0px 0px; } + +.VIpgJd-ZVi9od-l9xktf-I9GLp form { margin: 0px; } + +.VIpgJd-ZVi9od-l9xktf-I9GLp form textarea { margin-bottom: 4px; width: 100%; } + +.VIpgJd-ZVi9od-l9xktf-yePe5c { margin: 6px 0px 4px; } + +.VIpgJd-ZVi9od-aZ2wEe-wOHMyf { z-index: 1000; position: fixed; transition-delay: 0.6s; left: -1000px; top: -1000px; } + +.VIpgJd-ZVi9od-aZ2wEe-wOHMyf-ti6hGc { transition-delay: 0s; left: -14px; top: -14px; } + +.VIpgJd-ZVi9od-aZ2wEe-OiiCO { display: flex; -webkit-box-align: center; align-items: center; -webkit-box-pack: center; justify-content: center; width: 104px; height: 104px; border-radius: 50px; background: url("https://www.gstatic.com/images/branding/product/2x/translate_24dp.png") 50% 50% no-repeat rgb(255, 255, 255); transition: 0.6s ease-in-out; transform: scale(0.4); opacity: 0; } + +.VIpgJd-ZVi9od-aZ2wEe-OiiCO-ti6hGc { transform: scale(0.5); opacity: 1; } + +.VIpgJd-ZVi9od-aZ2wEe { margin: 2px 0px 0px 2px; animation: 1.4s linear 0s infinite normal none running spinner-rotator; } + +@-webkit-keyframes spinner-rotator { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(270deg); } +} + +@keyframes spinner-rotator { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(270deg); } +} + +.VIpgJd-ZVi9od-aZ2wEe-Jt5cK { stroke-dasharray: 187; stroke-dashoffset: 0; stroke: rgb(66, 133, 244); transform-origin: center center; animation: 1.4s ease-in-out 0s infinite normal none running spinner-dash; } + +@-webkit-keyframes spinner-dash { + 0% { stroke-dashoffset: 187; } + 50% { stroke-dashoffset: 46.75; transform: rotate(135deg); } + 100% { stroke-dashoffset: 187; transform: rotate(450deg); } +} + +@keyframes spinner-dash { + 0% { stroke-dashoffset: 187; } + 50% { stroke-dashoffset: 46.75; transform: rotate(135deg); } + 100% { stroke-dashoffset: 187; transform: rotate(450deg); } +} + +.VIpgJd-yAWNEb-L7lbkb a, .VIpgJd-yAWNEb-L7lbkb body, .VIpgJd-yAWNEb-L7lbkb div, .VIpgJd-yAWNEb-L7lbkb form, .VIpgJd-yAWNEb-L7lbkb h1, .VIpgJd-yAWNEb-L7lbkb h2, .VIpgJd-yAWNEb-L7lbkb h3, .VIpgJd-yAWNEb-L7lbkb h4, .VIpgJd-yAWNEb-L7lbkb h5, .VIpgJd-yAWNEb-L7lbkb h6, .VIpgJd-yAWNEb-L7lbkb html, .VIpgJd-yAWNEb-L7lbkb iframe, .VIpgJd-yAWNEb-L7lbkb img, .VIpgJd-yAWNEb-L7lbkb li, .VIpgJd-yAWNEb-L7lbkb ol, .VIpgJd-yAWNEb-L7lbkb p, .VIpgJd-yAWNEb-L7lbkb span, .VIpgJd-yAWNEb-L7lbkb table, .VIpgJd-yAWNEb-L7lbkb tbody, .VIpgJd-yAWNEb-L7lbkb td, .VIpgJd-yAWNEb-L7lbkb tr, .VIpgJd-yAWNEb-L7lbkb ul { color-scheme: unset; forced-color-adjust: unset; math-depth: unset; position: unset; position-anchor: unset; text-size-adjust: unset; appearance: unset; color: unset; font-family: unset; font-feature-settings: unset; font-kerning: unset; font-language-override: unset; font-optical-sizing: unset; font-palette: unset; font-size-adjust: unset; font-stretch: unset; font-style: unset; font-synthesis: unset; font-variant: unset; font-variation-settings: unset; font-weight: unset; position-area: unset; text-orientation: unset; text-rendering: unset; text-spacing-trim: unset; -webkit-font-smoothing: unset; -webkit-locale: unset; -webkit-text-orientation: unset; -webkit-writing-mode: unset; writing-mode: unset; zoom: unset; accent-color: unset; place-content: unset; place-items: unset; place-self: unset; alignment-baseline: unset; anchor-name: unset; anchor-scope: unset; animation-composition: unset; animation: unset; animation-trigger: unset; app-region: unset; aspect-ratio: unset; backdrop-filter: unset; backface-visibility: unset; background: unset; background-blend-mode: unset; baseline-shift: unset; baseline-source: unset; block-size: unset; border-block: unset; border: unset; border-radius: unset; border-collapse: unset; border-end-end-radius: unset; border-end-start-radius: unset; border-inline: unset; border-shape: unset; border-start-end-radius: unset; border-start-start-radius: unset; inset: unset; box-decoration-break: unset; box-shadow: unset; box-sizing: unset; break-after: unset; break-before: unset; break-inside: unset; buffered-rendering: unset; caption-side: unset; caret-animation: unset; caret-color: unset; caret-shape: unset; clear: unset; clip: unset; clip-path: unset; clip-rule: unset; color-interpolation: unset; color-interpolation-filters: unset; color-rendering: unset; columns: unset; column-fill: unset; gap: unset; rule-break: unset; rule: unset; rule-inset: unset; rule-visibility-items: unset; column-span: unset; contain: unset; contain-intrinsic-block-size: unset; contain-intrinsic-size: unset; contain-intrinsic-inline-size: unset; container: unset; content: unset; content-visibility: unset; corner-shape: unset; corner-block-end-shape: unset; corner-block-start-shape: unset; counter-increment: unset; counter-reset: unset; counter-set: unset; cursor: unset; cx: unset; cy: unset; d: unset; display: unset; dominant-baseline: unset; dynamic-range-limit: unset; empty-cells: unset; field-sizing: unset; fill: unset; fill-opacity: unset; fill-rule: unset; filter: unset; flex: unset; flex-flow: unset; float: unset; flood-color: unset; flood-opacity: unset; grid: unset; grid-area: unset; height: unset; hyphenate-character: unset; hyphenate-limit-chars: unset; hyphens: unset; image-orientation: unset; image-rendering: unset; initial-letter: unset; inline-size: unset; inset-block: unset; inset-inline: unset; interactivity: unset; interest-delay: unset; interpolate-size: unset; isolation: unset; letter-spacing: unset; lighting-color: unset; line-break: unset; list-style: unset; margin-block: unset; margin: unset; margin-inline: unset; marker: unset; mask: unset; mask-type: unset; math-shift: unset; math-style: unset; max-block-size: unset; max-height: unset; max-inline-size: unset; max-width: unset; min-block-size: unset; min-height: unset; min-inline-size: unset; min-width: unset; mix-blend-mode: unset; object-fit: unset; object-position: unset; object-view-box: unset; offset: unset; opacity: unset; order: unset; orphans: unset; outline: unset; outline-offset: unset; overflow-anchor: unset; overflow-block: unset; overflow-clip-margin: unset; overflow-inline: unset; overflow-wrap: unset; overflow: unset; overlay: unset; overscroll-behavior-block: unset; overscroll-behavior-inline: unset; overscroll-behavior: unset; padding-block: unset; padding: unset; padding-inline: unset; page: unset; page-orientation: unset; paint-order: unset; perspective: unset; perspective-origin: unset; pointer-events: unset; position-try: unset; position-visibility: unset; print-color-adjust: unset; quotes: unset; r: unset; reading-flow: unset; reading-order: unset; resize: unset; rotate: unset; ruby-align: unset; ruby-position: unset; rule-overlap: unset; rx: unset; ry: unset; scale: unset; scroll-behavior: unset; scroll-initial-target: unset; scroll-margin-block: unset; scroll-margin: unset; scroll-margin-inline: unset; scroll-marker-group: unset; scroll-padding-block: unset; scroll-padding: unset; scroll-padding-inline: unset; scroll-snap-align: unset; scroll-snap-stop: unset; scroll-snap-type: unset; scroll-target-group: unset; scroll-timeline: unset; scrollbar-color: unset; scrollbar-gutter: unset; scrollbar-width: unset; shape-image-threshold: unset; shape-margin: unset; shape-outside: unset; shape-rendering: unset; size: unset; speak: unset; stop-color: unset; stop-opacity: unset; stroke: unset; stroke-dasharray: unset; stroke-dashoffset: unset; stroke-linecap: unset; stroke-linejoin: unset; stroke-miterlimit: unset; stroke-opacity: unset; stroke-width: unset; tab-size: unset; table-layout: unset; text-align: unset; text-align-last: unset; text-anchor: unset; text-autospace: unset; text-box: unset; text-combine-upright: unset; text-decoration: unset; text-decoration-skip-ink: unset; text-emphasis: unset; text-emphasis-position: unset; text-indent: unset; text-justify: unset; text-overflow: unset; text-shadow: unset; text-transform: unset; text-underline-offset: unset; text-underline-position: unset; text-wrap: unset; timeline-scope: unset; timeline-trigger: unset; touch-action: unset; transform: unset; transform-box: unset; transform-origin: unset; transform-style: unset; transition: unset; translate: unset; trigger-scope: unset; user-select: unset; vector-effect: unset; vertical-align: unset; view-timeline: unset; view-transition-class: unset; view-transition-group: unset; view-transition-name: unset; view-transition-scope: unset; visibility: unset; border-spacing: unset; -webkit-box-align: unset; -webkit-box-decoration-break: unset; -webkit-box-direction: unset; -webkit-box-flex: unset; -webkit-box-ordinal-group: unset; -webkit-box-orient: unset; -webkit-box-pack: unset; -webkit-box-reflect: unset; -webkit-line-break: unset; -webkit-line-clamp: unset; -webkit-mask-box-image: unset; -webkit-rtl-ordering: unset; -webkit-ruby-position: unset; -webkit-tap-highlight-color: unset; -webkit-text-combine: unset; -webkit-text-decorations-in-effect: unset; -webkit-text-fill-color: unset; -webkit-text-security: unset; -webkit-text-stroke: unset; -webkit-user-drag: unset; white-space-collapse: unset; widows: unset; width: unset; will-change: unset; word-break: unset; word-spacing: unset; x: unset; y: unset; z-index: unset; font-size: 100%; line-height: normal; } + +.VIpgJd-yAWNEb-L7lbkb ol, .VIpgJd-yAWNEb-L7lbkb ul { list-style: none; } + +.VIpgJd-yAWNEb-L7lbkb table { border-collapse: collapse; border-spacing: 0px; } + +.VIpgJd-yAWNEb-L7lbkb caption, .VIpgJd-yAWNEb-L7lbkb td, .VIpgJd-yAWNEb-L7lbkb th { text-align: left; font-weight: 400; } + +div > .VIpgJd-yAWNEb-L7lbkb { padding: 10px 14px; } + +.VIpgJd-yAWNEb-L7lbkb { color: rgb(34, 34, 34); background-color: rgb(255, 255, 255); border: 1px solid rgb(238, 238, 238); box-shadow: rgba(0, 0, 0, 0.2) 0px 4px 16px; display: none; font-family: arial; font-size: 10pt; width: 420px; padding: 12px; position: absolute; z-index: 10000; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-nVMfcd-fmcmS, .VIpgJd-yAWNEb-yAWNEb-Vy2Aqc-pbTTYe { clear: both; font-size: 10pt; position: relative; text-align: justify; width: 100%; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-r4nke { color: rgb(153, 153, 153); font-family: arial, sans-serif; margin: 4px 0px; text-align: left; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-TvD9Pc-LgbsSe { display: none; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-l4eHX { float: left; margin: 0px; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-Z0Arqf-PLDbbf { display: inline-block; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-fw42Ze-Z0Arqf-haAclf { display: none; width: 100%; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-Z0Arqf-H9tDt { margin-top: 20px; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-LK5yu { float: left; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-qwU8Me { float: right; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-cGMI2b { min-height: 15px; position: relative; height: 1%; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-jOfkMb-Ne3sFf { background: rgb(41, 145, 13); border-radius: 4px; box-shadow: rgb(30, 102, 9) 0px 2px 2px inset; color: white; font-size: 9pt; font-weight: bolder; margin-top: 12px; padding: 6px; text-shadow: rgb(30, 102, 9) 1px 1px 1px; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-Z0Arqf-hSRGPd { color: rgb(17, 85, 204); cursor: pointer; font-family: arial; font-size: 11px; margin-right: 15px; text-decoration: none; } + +.VIpgJd-yAWNEb-L7lbkb > textarea { font-family: arial; resize: vertical; width: 100%; margin-bottom: 10px; border-radius: 1px; border-width: 1px; border-style: solid; border-color: silver rgb(217, 217, 217) rgb(217, 217, 217); border-image: initial; font-size: 13px; height: auto; overflow-y: auto; padding: 1px; } + +.VIpgJd-yAWNEb-L7lbkb textarea:focus { box-shadow: rgba(0, 0, 0, 0.3) 0px 1px 2px inset; border: 1px solid rgb(77, 144, 254); outline: none; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-Z0Arqf-IbE0S { margin-right: 10px; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp { min-height: 25px; vertical-align: middle; padding-top: 8px; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp { margin-bottom: 0px; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input { display: inline-block; min-width: 54px; border: 1px solid rgba(0, 0, 0, 0.1); text-align: center; color: rgb(68, 68, 68); font-size: 11px; font-weight: 700; height: 27px; outline: 0px; padding: 0px 8px; vertical-align: middle; line-height: 27px; margin: 0px 16px 0px 0px; box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 2px; border-radius: 2px; transition: 0.218s; background-color: rgb(245, 245, 245); background-image: -webkit-linear-gradient(top, rgb(245, 245, 245), rgb(241, 241, 241)); user-select: none; cursor: default; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input:hover { border: 1px solid rgb(198, 198, 198); color: rgb(34, 34, 34); transition: all; background-color: rgb(248, 248, 248); background-image: -webkit-linear-gradient(top, rgb(248, 248, 248), rgb(241, 241, 241)); } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input:active { border: 1px solid rgb(198, 198, 198); color: rgb(51, 51, 51); background-color: rgb(246, 246, 246); background-image: -webkit-linear-gradient(top, rgb(246, 246, 246), rgb(241, 241, 241)); } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input:focus .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input.AHmuwe .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input:active, .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input:focus:active { box-shadow: rgba(255, 255, 255, 0.5) 0px 0px 0px 1px inset; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input.AHmuwe, .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input:focus { outline: none; border: 1px solid rgb(77, 144, 254); z-index: 4 !important; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input.gk6SMd { background-color: rgb(238, 238, 238); background-image: -webkit-linear-gradient(top, rgb(238, 238, 238), rgb(224, 224, 224)); box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 2px inset; border: 1px solid rgb(204, 204, 204); color: rgb(51, 51, 51); } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input .VIpgJd-yAWNEb-Z0Arqf-sFeBqf { color: white; border-color: rgb(48, 121, 237); background-color: rgb(77, 144, 254); background-image: -webkit-linear-gradient(top, rgb(77, 144, 254), rgb(71, 135, 237)); } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input .VIpgJd-yAWNEb-Z0Arqf-sFeBqf.AHmuwe .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input .VIpgJd-yAWNEb-Z0Arqf-sFeBqf:active, .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input .VIpgJd-yAWNEb-Z0Arqf-sFeBqf:hover .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input .VIpgJd-yAWNEb-Z0Arqf-sFeBqf:focus { border-color: rgb(48, 121, 237); background-color: rgb(53, 122, 232); background-image: -webkit-linear-gradient(top, rgb(77, 144, 254), rgb(53, 122, 232)); } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input .VIpgJd-yAWNEb-Z0Arqf-sFeBqf:hover { box-shadow: rgb(255, 255, 255) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0.1) 0px 1px 1px; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input .VIpgJd-yAWNEb-Z0Arqf-sFeBqf.AHmuwe, .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input .VIpgJd-yAWNEb-Z0Arqf-sFeBqf:active, .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input .VIpgJd-yAWNEb-Z0Arqf-sFeBqf:focus, .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input .VIpgJd-yAWNEb-Z0Arqf-sFeBqf:hover, .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input.AHmuwe, .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input:active, .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input:focus, .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-L4Nn5e-I9GLp .VIpgJd-yAWNEb-Z0Arqf-I9GLp input:hover { border-color: rgb(48, 121, 237); } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-mrxPge { color: rgb(153, 153, 153); font-family: arial, sans-serif; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-IFdKyd-W0vJo-fmcmS { color: rgb(153, 153, 153); font-size: 11px; font-family: arial, sans-serif; margin: 15px 0px 5px; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-IFdKyd-u0pjoe-fmcmS { color: rgb(136, 0, 0); display: none; font-size: 9pt; } + +.VIpgJd-yAWNEb-VIpgJd-fmcmS-sn54Q { background-color: rgb(201, 215, 241); box-shadow: rgb(153, 153, 170) 2px 2px 4px; box-sizing: border-box; position: relative; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-IFdKyd-xl07Ob .VIpgJd-yAWNEb-VIpgJd-xl07Ob { background: rgb(255, 255, 255); border: 1px solid rgb(221, 221, 221); box-shadow: rgb(153, 153, 170) 0px 2px 4px; min-width: 0px; outline: none; padding: 0px; position: absolute; z-index: 2000; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-IFdKyd-xl07Ob .VIpgJd-yAWNEb-VIpgJd-j7LFlb { cursor: pointer; padding: 2px 5px 5px; margin-right: 0px; border-style: none; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-IFdKyd-xl07Ob .VIpgJd-yAWNEb-VIpgJd-j7LFlb:hover { background: rgb(221, 221, 221); } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-IFdKyd-xl07Ob .VIpgJd-yAWNEb-VIpgJd-j7LFlb h1 { font-size: 100%; font-weight: 700; margin: 4px 0px; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-IFdKyd-xl07Ob .VIpgJd-yAWNEb-VIpgJd-j7LFlb strong { color: rgb(52, 90, 173); } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-VIpgJd-eKm5Fc-hFsbo { text-align: right; position: absolute; right: 0px; left: auto; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-VIpgJd-j7LFlb-SIsrTd .VIpgJd-yAWNEb-VIpgJd-eKm5Fc-hFsbo { text-align: left; position: absolute; left: 0px; right: auto; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-TVLw9c-ppHlrf-sn54Q, .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-yAWNEb-Vy2Aqc-fmcmS { background-color: rgb(241, 234, 0); border-radius: 4px; box-shadow: rgba(0, 0, 0, 0.5) 3px 3px 4px; box-sizing: border-box; color: rgb(241, 234, 0); cursor: pointer; margin: -2px -2px -2px -3px; padding: 2px 2px 2px 3px; position: relative; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-TVLw9c-ppHlrf-sn54Q { color: rgb(34, 34, 34); } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-yAWNEb-Vy2Aqc-pbTTYe { color: white; position: absolute !important; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-TVLw9c-ppHlrf, .VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-TVLw9c-ppHlrf .VIpgJd-yAWNEb-TVLw9c-ppHlrf-sn54Q { background-color: rgb(201, 215, 241); border-radius: 4px 4px 0px 0px; box-shadow: rgba(0, 0, 0, 0.5) 3px 3px 4px; box-sizing: border-box; cursor: pointer; margin: -2px -2px -2px -3px; padding: 2px 2px 3px 3px; position: relative; } + +.VIpgJd-yAWNEb-L7lbkb span:focus { outline: none; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-TVLw9c-DyVDA { background-color: transparent; border: 1px solid rgb(77, 144, 254); border-radius: 0px; margin: -2px; padding: 1px; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-yAWNEb-TVLw9c-sn54Q-LzX3ef { border-left: 2px solid red; margin-left: -2px; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-yAWNEb-TVLw9c-sn54Q-YIAiIb { border-right: 2px solid red; margin-right: -2px; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-IFdKyd-YPqjbf { padding: 2px; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-IFdKyd-YPqjbf-fmcmS { font-size: 11px; padding: 2px 2px 3px; margin: 0px; background-color: rgb(255, 255, 255); color: rgb(51, 51, 51); border-width: 1px; border-style: solid; border-color: rgb(192, 192, 192) rgb(217, 217, 217) rgb(217, 217, 217); border-image: initial; display: inline-block; vertical-align: top; height: 21px; box-sizing: border-box; border-radius: 1px; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-IFdKyd-YPqjbf-fmcmS:hover { border-width: 1px; border-style: solid; border-color: rgb(160, 160, 160) rgb(185, 185, 185) rgb(185, 185, 185); border-image: initial; box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 2px inset; } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-IFdKyd-YPqjbf-fmcmS:focus { box-shadow: rgba(0, 0, 0, 0.3) 0px 1px 2px inset; outline: none; border: 1px solid rgb(77, 144, 254); } + +.VIpgJd-yAWNEb-L7lbkb .VIpgJd-yAWNEb-IFdKyd-YPqjbf-sFeBqf { font-size: 11px; padding: 2px 6px 3px; margin: 0px 0px 0px 2px; height: 21px; } + +.VIpgJd-yAWNEb-L7lbkb > div { display: flex; -webkit-box-orient: vertical; -webkit-box-direction: normal; flex-direction: column; font-family: "Google Sans", Arial, sans-serif; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-Ud7fr { display: flex; -webkit-box-align: end; align-items: end; margin: 14px; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-IuizWc-SIsrTd { margin-right: 14px; color: rgb(116, 119, 117); font-size: 14px; font-weight: 500; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-IuizWc-i3jM8c { margin-left: 14px; color: rgb(116, 119, 117); font-size: 14px; font-weight: 500; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-k77Iif { margin: 0px 16px 16px; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-axAV1 { width: auto; color: rgb(31, 31, 31); font-size: 16px; text-align: initial; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-axAV1 .VIpgJd-yAWNEb-SIsrTd { text-align: right; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-N7Eqid { border-radius: 0px 0px 12px 12px; margin: 0px; background: rgb(241, 244, 249); position: relative; min-height: 50px; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-N7Eqid .VIpgJd-yAWNEb-SIsrTd { text-align: right; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-N7Eqid-B7I4Od { display: flex; -webkit-box-orient: vertical; -webkit-box-direction: normal; flex-direction: column; width: 77%; padding: 12px; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-N7Eqid-B7I4Od .VIpgJd-yAWNEb-SIsrTd { text-align: right; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-UTujCb { color: rgb(31, 31, 31); font-size: 12px; font-weight: 500; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-N7Eqid-B7I4Od .VIpgJd-yAWNEb-SIsrTd .VIpgJd-yAWNEb-hvhgNd-UTujCb { text-align: right; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-eO9mKe { color: rgb(68, 71, 70); font-size: 12px; padding-top: 4px; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-N7Eqid-B7I4Od .VIpgJd-yAWNEb-SIsrTd .VIpgJd-yAWNEb-hvhgNd-eO9mKe { text-align: right; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-xgov5 { position: absolute; top: 10px; right: 5px; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-xgov5 .VIpgJd-yAWNEb-SIsrTd { left: 5px; right: auto; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-THI6Vb { fill: rgb(11, 87, 208); } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-bgm6sf { margin: -4px 2px 0px 0px; padding: 2px 0px 0px; width: 48px; height: 48px; border-width: medium; border-style: none; border-color: currentcolor; border-image: initial; border-radius: 24px; cursor: pointer; background: none; } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-bgm6sf:hover { background: rgb(232, 235, 236); } + +.VIpgJd-yAWNEb-hvhgNd .VIpgJd-yAWNEb-hvhgNd-aXYTce { display: none; } + +sentinel { } +------MultipartBoundary--kxwvuFwyuMYcePAR51VZyTenwsfxKEx0HaVbZWicZr---- +Content-Type: image/png +Content-Transfer-Encoding: binary +Content-Location: https://choosealicense.com/assets/img/home-sprite@2x.png + +�PNG + + +IHDR�l��\�PLTE�����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������ҋ(�tRNS@��fbKGD�H pHYs  ��tIME��s���IDATx��ۿ�UG�sN��jn%lq��j�m\Xa�B1�"q ���`%h�F+�X($&�H�Xjlbggg���?&?��ݽw�93gf�k��vߝ�>���ߗ�'~%�x%� %�,��e{"�����<��Gq����OZ{^Ȯx��sZ��w`��<�e0N���]�Ѹ���D�e}�A�ƕڞ�2�zN�d<󈜪�9/�8_�sV�q��縨�x +�a1����b�;�=w�G�z��@�<"�yHR<,�y(��u�!��8Ѵ�?:0�As���A ���PhI�5 ZУ�3�� ZοdK����Z�u>7�W^���)���:��~��<�Y>��g��M�z�Z�o:f9��m�,t�L�:K�j����ȧ�ʀ.FA�[�\ � I�]� �M���X�<�D~s�E��IOТ���'��%/Џ���L �G �[�o�$G�A��4&҃��u-H�灴dٙr1R�^(�wuyt1Z�t�����@'~��T�Չ{�]�<�g�@7'��n���/�����[����Q��A���� +z�lW��Me���tL�*��@����1h��oY��:C?k�?І�'��u�����-c��d󼫯hG~��`K�̣���A7�+[�r�xoAh%i��1 +J��%J��=/�:}��ڔhth���9 k�y��@ϷyK!��8�����x�kc������c1� (���cMlc<�P��jiOTd��� +�|<���d��\=D+��ϽןW�<_�����y�DELJ�C~6����,|� � ����#����+��ߕA��x�l� ��c�}���A������g��ȧ-���+���Q�K���#[z�N������"ru��J����ЇcE*��K�Y|p�r���=@^D��W?�#p���n��\. �Ya}5�D�8n��q��2��Pd"��z�h��~� +�ҳ�~m�Nġ.(j��;���A�E���\��D�w��nK��r��D���M����,�M���A�{�v�B�ޞ��II�a���,k�CH���)Z�4sRa�'�٢����>t����3���5X�/�I%����_`�������׏I +`(X��k[J�\���/*�14���) ӓ�>�O�X{׆8$���%ꇜ�\�ڞ����w��@)b�ո`�$���\.�rL܇Z"Ž�s��wb�1q֥Wv�C��Yug�E?�&��OQ�Cx�8s���1�}`��W�3A�)4�b��x� b�鉯�:�z�l�[h8��;0�s]��t�Asо�h B�|� &0}� B�� +�ݢ�8��=� a { text-decoration: none; } + +.triptych h3 { font-size: 1.375rem; margin: 0px auto 20px; width: 220px; } + +.situations .existing { margin-top: 16px; } + +.situations .whatever { margin-top: 16px; } + +.situations .copyleft { margin-top: 3px; } + +.situations .button { margin-top: 20px; } + +.triptych-sprite { background-image: url("../img/home-sprite.png"); background-repeat: no-repeat; display: inline-block; } + +.three-arrows { background-position: 0px 0px; width: 72px; height: 56px; } + +.community { background-position: 0px -57px; width: 72px; height: 56px; } + +.circular { background-position: 0px -115px; width: 72px; height: 68px; } + +.license-overview { clear: both; margin-bottom: 40px; } + +.license-overview-heading { float: left; width: 35%; } + +.license-details { box-sizing: border-box; float: left; width: 55%; padding-left: 20px; } + +.license-page-details { box-sizing: border-box; } + +.license-overview-name { font-size: 1.75rem; margin-top: 5px; } + +.license-overview-description { color: rgb(85, 85, 85); } + +.license-rules { border-bottom: 1px solid rgb(221, 221, 221); font-size: 0.8125rem; line-height: 1.3; margin-bottom: 12px; width: 100%; } + +.license-rules th, .license-rules td { width: 33%; } + +.license-rules th { font-size: 0.9375rem; padding: 5px 10px 5px 0px; vertical-align: bottom; } + +.license-rules th.summary { line-height: 1.4; } + +.license-rules .name { box-sizing: content-box; border-right: 1px solid rgb(221, 221, 221); padding-left: 0px; width: 280px; } + +.license-rules .name a { font-size: 1.75rem; } + +.license-rules .name small a { font-size: 1rem; } + +.license-rules td { padding: 4px 0px 12px; vertical-align: top; } + +.license-rules .label { font-weight: bold; } + +.license-rules li { position: relative; padding-left: 18px; margin-right: 15px; margin-bottom: 5px; } + +.license-rules li:hover { color: rgb(68, 68, 68); } + +.source-marker { display: inline-block; width: 16px; text-align: center; margin-right: 4px; font-size: 0.875rem; line-height: 1; } + +.license-marker { display: inline-block; width: 12px; text-align: center; font-weight: 700; line-height: 1; } + +.license-rules li .license-marker { position: absolute; left: 0px; top: 0.1em; } + +.license-types td .license-marker { display: block; margin: 0px auto; } + +.license-types td .license-marker, .license-types dd .license-marker { font-size: 0.8125rem; } + +.license-permissions .license-marker { color: rgb(41, 134, 37); } + +.license-conditions .license-marker { color: rgb(13, 100, 138); } + +.license-limitations .license-marker { color: rgb(129, 42, 40); } + +.license-permissions .lite .license-marker { color: rgba(41, 134, 37, 0.5); } + +.license-conditions .lite .license-marker { color: rgba(13, 100, 138, 0.5); } + +.license-limitations .lite .license-marker { color: rgba(129, 42, 40, 0.5); } + +.license-rules-sidebar li { float: none; padding-bottom: 3px; } + +.license-body { font-size: 0.9375rem; float: left; width: 700px; } + +.license-body pre { font-family: Consolas, Monaco, Courier, monospace; font-size: 0.875rem; background-color: rgb(255, 255, 255); border: 1px solid rgb(238, 238, 238); border-radius: 3px; padding: 20px; } + +.license-nickname { margin-top: 0px; } + +.sidebar { float: right; width: 220px; font-size: 0.75rem; } + +.sidebar a.button { margin-top: -110px; width: 100%; } + +.sidebar input#repository-url { width: 100%; padding: 5px 20px 5px 10px; box-sizing: border-box; margin-right: 12px; } + +.input-wrapper { position: relative; } + +.input-wrapper .status-indicator { position: absolute; right: 5px; top: 6px; height: 8px; width: 8px; border-radius: 50%; background: rgb(255, 255, 255); animation: auto ease 0s 1 normal none running none; border: 4px solid rgb(221, 221, 221); } + +.input-wrapper .status-indicator.fetching { border-right-color: transparent; animation: 0.8s linear 0s infinite normal none running rotate; } + +.input-wrapper .status-indicator.error { border: 4px solid rgb(198, 64, 61); } + +@keyframes rotate { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.note { color: rgb(104, 112, 114); } + +.button { border-radius: 3px; box-sizing: border-box; border-color: rgb(204, 204, 204) rgb(187, 187, 187) rgb(170, 170, 170); border-style: solid; border-width: 1px; color: rgb(68, 68, 68); box-shadow: rgba(0, 0, 0, 0.1) 0px 2px 4px, rgba(255, 255, 255, 0.8) 0px 1px 0px 0px inset; background-image: linear-gradient(rgb(238, 238, 238), rgb(221, 221, 221)); display: inline-block; font-size: 0.75rem; font-weight: normal; padding: 5px 10px; text-align: center; vertical-align: middle; } + +.button:hover { text-decoration: none; background-image: linear-gradient(rgb(238, 238, 238), rgb(204, 204, 204)); } + +.projects-with-license li { padding-bottom: 3px; } + +.site-footer { border-top: 1px solid rgb(238, 238, 238); margin-top: 30px; padding-top: 20px; color: rgb(85, 85, 85); font-size: 0.75rem; text-align: left; line-height: 1.5; } + +.site-footer a { color: rgb(68, 68, 68); font-weight: normal; } + +.site-footer p { float: left; margin-top: 0px; } + +.site-footer nav { float: right; } + +.site-footer nav a { display: inline-block; margin-left: 20px; } + +.bullets { list-style-type: disc; } + +.bullets > li { margin-left: 2em; } + +.small { font-size: 90%; } + +.override-hint-inline { display: block; } + +[class*="hint--"][aria-label]::after { font-size: 0.8rem; font-weight: 400; text-shadow: none; } + +.tooltip--permissions.hint--bottom::before { border-bottom-color: rgb(41, 134, 37); } + +.tooltip--conditions.hint--bottom::before { border-bottom-color: rgb(13, 100, 138); } + +.tooltip--error.hint--bottom::before, .tooltip--limitations.hint--bottom::before { border-bottom-color: rgb(129, 42, 40); } + +.tooltip--permissions::after { background-color: rgb(216, 244, 215); color: rgb(26, 88, 24); border-color: rgb(61, 198, 55); } + +.tooltip--conditions::after { background-color: rgb(208, 235, 246); border-color: rgb(20, 154, 212); color: rgb(13, 100, 138); } + +.tooltip--error::after, .tooltip--limitations::after { background-color: rgb(244, 217, 216); border-color: rgb(198, 64, 61); color: rgb(129, 42, 40); } + +.clearfix::before, .clearfix::after { content: ""; display: table; } + +.clearfix::after { clear: both; } + +.with-love { float: right; clear: right; } + +@media only screen and (-webkit-min-device-pixel-ratio: 2), only screen and (min--moz-device-pixel-ratio: 2), only screen and (-o-min-device-pixel-ratio: 200 / 100), only screen and (min-device-pixel-ratio: 2) { + .three-arrows, .community, .circular { background-image: url("../img/home-sprite@2x.png"); background-size: 72px 182px; } +} + +@media only screen and (max-width: 1050px) { + .container { width: 100%; padding: 0px 20px; box-sizing: border-box; } + .triptych { width: 100%; } + .triptych li { width: 33%; margin-left: 0px; padding: 0px 20px; box-sizing: border-box; } + .triptych h3 { width: auto; padding: 0px 30px; } + .home h2 { line-height: 30px; padding: 20px 100px; position: relative; } + .home h2 span { position: absolute; display: block; float: left; top: 50%; margin-top: -22px; } + .home h2 span:first-child { left: 65px; } + .home h2 span:last-child { right: 65px; } + .sidebar a.button { margin: 14px 0px 20px; } + .license-body { width: calc(100% - 250px); } + .hint--large::after { width: 150px; } +} + +@media only screen and (max-width: 800px), (max-device-width: 1050px) and (orientation: portrait) { + .triptych h3 { width: auto; padding: 0px 10px; margin-bottom: 10px; } + .sidebar { float: left; width: 50%; } + .license-body { width: 100%; } + .license-rules { border-bottom-width: medium; border-bottom-style: none; border-bottom-color: currentcolor; } + .license-details { width: 65%; } +} + +@media only screen and (max-width: 680px) { + .triptych li { float: none; width: 100%; margin-bottom: 50px; } + .home h2 { padding: 20px 70px; } + .home h2 span:first-child { left: 35px; } + .home h2 span:last-child { right: 35px; } + .license-overview { margin-bottom: 20px; } + .license-overview-heading { float: none; width: 100%; } + .license-details { float: none; width: 100%; padding-left: 0px; } + .site-footer { text-align: center; } + .site-footer nav, .site-footer p, .with-love { float: none; } + .site-footer nav { margin: 0px auto 10px; } +} + +@media only screen and (max-width: 481px) { + h1 { font-size: 30px; margin-bottom: 15px; } + .home h1 { font-size: 33px; } + .home h2 { font-size: 17px; line-height: 20px; padding: 20px 45px; } + .home h2 span:first-child { left: 0px; } + .home h2 span:last-child { right: 0px; } + .home h2 span { margin-top: -16px; } + .sidebar { width: 100%; } + .license-body { overflow-wrap: break-word; } + .license-body pre { font-size: 10px; } + .license-rules:not(.license-rules-sidebar) li { padding-left: 14px; margin-right: 5px; font-size: 10px; text-size-adjust: none; } + .license-rules:not(.license-rules-sidebar) .license-marker { width: 10px; font-size: 10px; left: 0px; top: 1px; position: absolute; } + .hint--large::after { width: 80px; } +} + +@media only screen and (max-width: 321px) { + .container { padding: 0px 10px; } +} +------MultipartBoundary--kxwvuFwyuMYcePAR51VZyTenwsfxKEx0HaVbZWicZr---- +Content-Type: text/css +Content-Transfer-Encoding: binary +Content-Location: https://fonts.googleapis.com/css?family=Chivo:900 + +@charset "utf-8"; + +@font-face { font-family: Chivo; font-style: normal; font-weight: 900; src: url("https://fonts.gstatic.com/s/chivo/v21/va9b4kzIxd1KFppkaRKvDRPJVDf_FRjen2rTN3vAiIsJ.woff2") format("woff2"); unicode-range: U+102-103, U+110-111, U+128-129, U+168-169, U+1A0-1A1, U+1AF-1B0, U+300-301, U+303-304, U+308-309, U+323, U+329, U+1EA0-1EF9, U+20AB; } + +@font-face { font-family: Chivo; font-style: normal; font-weight: 900; src: url("https://fonts.gstatic.com/s/chivo/v21/va9b4kzIxd1KFppkaRKvDRPJVDf_FRjenmrTN3vAiIsJ.woff2") format("woff2"); unicode-range: U+100-2BA, U+2BD-2C5, U+2C7-2CC, U+2CE-2D7, U+2DD-2FF, U+304, U+308, U+329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } + +@font-face { font-family: Chivo; font-style: normal; font-weight: 900; src: url("https://fonts.gstatic.com/s/chivo/v21/va9b4kzIxd1KFppkaRKvDRPJVDf_FRjekGrTN3vAiA.woff2") format("woff2"); unicode-range: U+0-FF, U+131, U+152-153, U+2BB-2BC, U+2C6, U+2DA, U+2DC, U+304, U+308, U+329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } +------MultipartBoundary--kxwvuFwyuMYcePAR51VZyTenwsfxKEx0HaVbZWicZr---- +Content-Type: text/html +Content-ID: +Content-Transfer-Encoding: binary + + +------MultipartBoundary--kxwvuFwyuMYcePAR51VZyTenwsfxKEx0HaVbZWicZr------ diff --git a/README.md b/README.md index 7cd8942..0126b3f 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,102 @@ -
-GHBanner -
-# Run and deploy your AI Studio app +

+ GitBot logo +

-This contains everything you need to run your app locally. +

GitBot

-View your app in AI Studio: https://ai.studio/apps/a18fb367-cc88-4c45-ad81-3a3786f4f2ce +

+ Nền tảng tự động hóa Pull Request, CI/CD và tài liệu vận hành dành cho đội kỹ thuật hiện đại. +

-## Run Locally +

+ Tổng quan · + Tính năng · + Kiến trúc · + Chạy cục bộ · + Vercel +

-**Prerequisites:** Node.js +--- +## ✨ Tổng quan -1. Install dependencies: - `npm install` -2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key -3. Run the app: - `npm run dev` +**GitBot** là giao diện quản trị giúp nhóm phát triển theo dõi toàn bộ vòng đời Pull Request trong một workspace thống nhất: review diff, bình luận theo dòng, quan sát pipeline, đọc wiki nội bộ và kiểm tra readiness trước khi deploy. + +Thiết kế dự án tập trung vào ba nguyên tắc: + +- **Tối giản:** thông tin quan trọng được ưu tiên, ít nhiễu thị giác. +- **Hiện đại:** dark UI cho app vận hành, wiki sáng sạch theo phong cách documentation hub. +- **Thực dụng:** mọi màn hình đều phục vụ trực tiếp cho review, CI/CD, bảo mật và rollout. + +## 🚀 Tính năng chính + +| Khu vực | Mô tả | +| --- | --- | +| Wiki | Trang giới thiệu tài liệu, mục lục, hướng dẫn CI/CD và checklist release. | +| Dashboard | KPI repository, PR, pipeline, diff stats và tín hiệu hệ thống. | +| Code Review | Xem diff, chọn file, gửi bình luận theo ngữ cảnh PR. | +| Pipeline | Theo dõi stage lint/test/build/deploy và trạng thái workflow. | +| Mobile Simulator | Kiểm tra trải nghiệm GitBot trên khung di động mô phỏng. | +| Command Palette | Điều hướng nhanh bằng `⌘K` / `Ctrl K`. | + +## 🧭 Kiến trúc + +```mermaid +flowchart LR + GitHub[GitHub Webhook] --> API[GitBot API] + API --> Runner[GitBot Runner] + API --> DB[(PostgreSQL)] + Runner --> CI[CI/CD Jobs] + API --> UI[React Workspace] + UI --> Wiki[Wiki & Dashboard] +``` + +## 🧩 Logo + +Logo GitBot sử dụng biểu tượng robot kết hợp nhánh Git để thể hiện vai trò trợ lý tự động hóa kỹ thuật. Bảng màu xanh `sky` và `emerald` đồng bộ với giao diện sản phẩm, gợi cảm giác đáng tin cậy, realtime và thân thiện với developer. + +- File logo: [`public/gitbot-logo.svg`](public/gitbot-logo.svg) +- Có thể dùng trực tiếp cho GitHub README, header web app, favicon hoặc tài liệu nội bộ. + +## 🛠 Chạy cục bộ + +```bash +npm install +npm run dev +``` + +Ứng dụng mặc định chạy tại: + +```txt +http://localhost:3000 +``` + +## 📦 Build production + +```bash +npm run build +npm run start +``` + +## ▲ Triển khai Vercel + +Dự án đã chuẩn bị UI và build Vite phù hợp cho triển khai production. Khi môi trường có quyền registry và token Vercel, có thể deploy bằng: + +```bash +npx vercel --prod --yes +``` + +## 📚 Tài liệu trong app + +Mở ứng dụng và chọn tab **Wiki** để xem trang giới thiệu tài liệu được thiết kế riêng cho GitBot, bao gồm: + +1. Tổng quan dự án. +2. Bắt đầu nhanh. +3. Cấu hình `.gitbot-ci.yml` mẫu. +4. Quy ước review và release. +5. Checklist triển khai Vercel. + +## 📄 License + +Apache-2.0 https://docs.github.com/en/pull-requests/collaborating-with-pull-requests diff --git a/gitbot-logo-vng b/gitbot-logo-vng new file mode 100644 index 0000000..0304bbe --- /dev/null +++ b/gitbot-logo-vng @@ -0,0 +1,24 @@ + + GitBot logo + A modern Git branch robot mark for the GitBot developer automation platform. + + + + + + + + + + + + + + + + + + + + + diff --git a/gitbot-logo.svg b/gitbot-logo.svg new file mode 100644 index 0000000..0304bbe --- /dev/null +++ b/gitbot-logo.svg @@ -0,0 +1,24 @@ + + GitBot logo + A modern Git branch robot mark for the GitBot developer automation platform. + + + + + + + + + + + + + + + + + + + + + diff --git a/gitbot/backend/auth.go b/gitbot/backend/auth.go new file mode 100644 index 0000000..eae230f --- /dev/null +++ b/gitbot/backend/auth.go @@ -0,0 +1,240 @@ +package main + +import ( + "database/sql" + "encoding/json" + "log" + "net/http" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + CreatedAt time.Time `json:"created_at"` +} + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type RegisterRequest struct { + Email string `json:"email"` + Username string `json:"username"` + Password string `json:"password"` +} + +type AuthResponse struct { + User User `json:"user"` + Token string `json:"token"` +} + +type ErrorResponse struct { + Error string `json:"error"` +} + +var jwtSecret = "your-secret-key-change-in-production" + +func generateID() string { + return "id_" + time.Now().Format("20060102150405") + "_" + randomString(12) +} + +func randomString(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, length) + for i := range b { + b[i] = charset[(time.Now().UnixNano()+int64(i))%int64(len(charset))] + } + return string(b) +} + +func hashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(hash), err +} + +func checkPassword(hash, password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +func generateToken(userID string) string { + return "jwt_token_" + userID + "_" + time.Now().Format("20060102150405") +} + +func handleRegister(w http.ResponseWriter, r *http.Request, db *sql.DB) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Invalid request"}) + return + } + + if req.Email == "" || req.Password == "" || req.Username == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Email, username, and password are required"}) + return + } + + hashedPassword, err := hashPassword(req.Password) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Failed to hash password"}) + return + } + + userID := generateID() + now := time.Now() + + _, err = db.Exec( + "INSERT INTO users (id, email, username, password_hash, created_at) VALUES ($1, $2, $3, $4, $5)", + userID, req.Email, req.Username, hashedPassword, now, + ) + + if err != nil { + log.Printf("Database error: %v\n", err) + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(ErrorResponse{Error: "User already exists"}) + return + } + + user := User{ + ID: userID, + Email: req.Email, + Username: req.Username, + CreatedAt: now, + } + + token := generateToken(userID) + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(AuthResponse{ + User: user, + Token: token, + }) +} + +func handleLogin(w http.ResponseWriter, r *http.Request, db *sql.DB) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Invalid request"}) + return + } + + var user User + var passwordHash string + + err := db.QueryRow( + "SELECT id, email, username, password_hash, created_at FROM users WHERE email = $1", + req.Email, + ).Scan(&user.ID, &user.Email, &user.Username, &passwordHash, &user.CreatedAt) + + if err == sql.ErrNoRows { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Invalid credentials"}) + return + } else if err != nil { + log.Printf("Database error: %v\n", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Database error"}) + return + } + + if !checkPassword(passwordHash, req.Password) { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Invalid credentials"}) + return + } + + token := generateToken(user.ID) + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(AuthResponse{ + User: user, + Token: token, + }) +} + +func handleGetMe(w http.ResponseWriter, r *http.Request, db *sql.DB) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + token := r.Header.Get("Authorization") + if token == "" { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Missing token"}) + return + } + + if len(token) > 7 && token[:7] == "Bearer " { + token = token[7:] + } + + userID := extractUserIDFromToken(token) + if userID == "" { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Error: "Invalid token"}) + return + } + + var user User + err := db.QueryRow( + "SELECT id, email, username, created_at FROM users WHERE id = $1", + userID, + ).Scan(&user.ID, &user.Email, &user.Username, &user.CreatedAt) + + if err != nil { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(ErrorResponse{Error: "User not found"}) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(user) +} + +func extractUserIDFromToken(token string) string { + if len(token) > 10 { + parts := len(token) + if parts > 10 { + return token[10 : parts-14] + } + } + return "" +} diff --git a/gitbot/backend/cache.go b/gitbot/backend/cache.go new file mode 100644 index 0000000..c7cd1f9 --- /dev/null +++ b/gitbot/backend/cache.go @@ -0,0 +1,140 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "time" +) + +type CacheStore struct { + data map[string]cacheEntry +} + +type cacheEntry struct { + value interface{} + expiresAt time.Time +} + +var cache = &CacheStore{ + data: make(map[string]cacheEntry), +} + +// In production, use redis.NewClient() from github.com/redis/go-redis/v9 +// For now, using in-memory cache with TTL + +func (c *CacheStore) Set(key string, value interface{}, ttl time.Duration) error { + c.data[key] = cacheEntry{ + value: value, + expiresAt: time.Now().Add(ttl), + } + log.Printf("Cache SET: %s (TTL: %v)\n", key, ttl) + return nil +} + +func (c *CacheStore) Get(key string) (interface{}, bool) { + entry, exists := c.data[key] + if !exists { + return nil, false + } + + if time.Now().After(entry.expiresAt) { + delete(c.data, key) + return nil, false + } + + log.Printf("Cache HIT: %s\n", key) + return entry.value, true +} + +func (c *CacheStore) Delete(key string) error { + delete(c.data, key) + log.Printf("Cache DEL: %s\n", key) + return nil +} + +func (c *CacheStore) Invalidate(pattern string) { + for key := range c.data { + if matchPattern(key, pattern) { + delete(c.data, key) + } + } + log.Printf("Cache invalidated for pattern: %s\n", pattern) +} + +func matchPattern(key, pattern string) bool { + if pattern == "*" { + return true + } + if len(pattern) > 0 && pattern[len(pattern)-1] == '*' { + return len(key) >= len(pattern)-1 && key[:len(pattern)-1] == pattern[:len(pattern)-1] + } + return key == pattern +} + +// Convenience functions for caching diff data +func cacheKey(prefix string, id string) string { + return fmt.Sprintf("%s:%s", prefix, id) +} + +func CacheDiff(prID string, diff []FileDiff) error { + data, err := json.Marshal(diff) + if err != nil { + return err + } + return cache.Set(cacheKey("diff", prID), data, 30*time.Minute) +} + +func GetCachedDiff(prID string) ([]FileDiff, bool) { + data, exists := cache.Get(cacheKey("diff", prID)) + if !exists { + return nil, false + } + + jsonData, ok := data.([]byte) + if !ok { + return nil, false + } + + var diff []FileDiff + if err := json.Unmarshal(jsonData, &diff); err != nil { + return nil, false + } + + return diff, true +} + +func CacheStats(prID string, stats PRStats) error { + data, err := json.Marshal(stats) + if err != nil { + return err + } + return cache.Set(cacheKey("stats", prID), data, 15*time.Minute) +} + +func GetCachedStats(prID string) (PRStats, bool) { + data, exists := cache.Get(cacheKey("stats", prID)) + if !exists { + return PRStats{}, false + } + + jsonData, ok := data.([]byte) + if !ok { + return PRStats{}, false + } + + var stats PRStats + if err := json.Unmarshal(jsonData, &stats); err != nil { + return PRStats{}, false + } + + return stats, true +} + +func InvalidateUserCache(userID string) { + cache.Invalidate(fmt.Sprintf("user:%s:*", userID)) +} + +func InvalidatePRCache(prID string) { + cache.Invalidate(fmt.Sprintf("*:%s", prID)) +} diff --git a/gitbot/backend/database.go b/gitbot/backend/database.go new file mode 100644 index 0000000..467e4ca --- /dev/null +++ b/gitbot/backend/database.go @@ -0,0 +1,73 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/lib/pq" +) + +func initDatabase() (*sql.DB, error) { + connStr := "postgres://gitbot_admin:SecretPassword123@postgres:5432/gitbot_db?sslmode=disable" + + db, err := sql.Open("postgres", connStr) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + if err = db.Ping(); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + log.Println("Database connected successfully") + + if err := createTables(db); err != nil { + return nil, fmt.Errorf("failed to create tables: %w", err) + } + + return db, nil +} + +func createTables(db *sql.DB) error { + schema := ` + CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(36) PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS reviews ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL REFERENCES users(id), + pr_number INTEGER NOT NULL, + status VARCHAR(50) NOT NULL, + feedback TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS comments ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL REFERENCES users(id), + file_path VARCHAR(255) NOT NULL, + line_num INTEGER NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_reviews_user_id ON reviews(user_id); + CREATE INDEX IF NOT EXISTS idx_reviews_pr_number ON reviews(pr_number); + CREATE INDEX IF NOT EXISTS idx_comments_user_id ON comments(user_id); + CREATE INDEX IF NOT EXISTS idx_comments_file_path ON comments(file_path); + ` + + _, err := db.Exec(schema) + if err != nil { + return fmt.Errorf("failed to execute schema: %w", err) + } + + log.Println("Database tables created successfully") + return nil +} diff --git a/gitbot/backend/email.go b/gitbot/backend/email.go new file mode 100644 index 0000000..a1039af --- /dev/null +++ b/gitbot/backend/email.go @@ -0,0 +1,184 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "time" +) + +type EmailNotification struct { + To string + Subject string + Body string + HTMLBody string + Timestamp time.Time +} + +type EmailRequest struct { + Email string `json:"email"` + Subject string `json:"subject"` + Body string `json:"body"` +} + +// In production, use SendGrid, AWS SES, or similar email service +// For now, logging to console and storing in database + +var emailQueue []EmailNotification + +func SendEmail(to, subject, body string) error { + notification := EmailNotification{ + To: to, + Subject: subject, + Body: body, + HTMLBody: generateHTMLEmail(subject, body), + Timestamp: time.Now(), + } + + emailQueue = append(emailQueue, notification) + log.Printf("Email queued for %s: %s\n", to, subject) + + // In production, implement actual email sending here + // Example with SendGrid: + // from := "noreply@gitbot.io" + // m := mail.NewV3Mail() + // m.SetFrom(mail.NewEmail("GitBot", from)) + // m.Subject = subject + // p := mail.NewPersonalization() + // p.AddTos(mail.NewEmail("", to)) + // p.SetDynamicTemplateData(data) + // m.AddPersonalizations(p) + + return nil +} + +func generateHTMLEmail(subject, body string) string { + return fmt.Sprintf(` + + +
+
+

%s

+
+
+ %s +
+
+

This is an automated email from GitBot. Please do not reply to this email.

+

Copyright © 2026 GitBot. All rights reserved.

+
+
+ + + `, subject, body) +} + +func handleSendEmail(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req EmailRequest + if err := r.ParseForm(); err == nil { + req.Email = r.FormValue("email") + req.Subject = r.FormValue("subject") + req.Body = r.FormValue("body") + } else { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + } + + if req.Email == "" || req.Subject == "" || req.Body == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Email, subject, and body are required", + }) + return + } + + if err := SendEmail(req.Email, req.Subject, req.Body); err != nil { + log.Printf("Error sending email: %v\n", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to send email", + }) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "success": "true", + "message": "Email sent successfully", + }) +} + +// Send notification emails based on events +func SendApprovalNotification(email, username, prTitle string, approved bool) error { + subject := fmt.Sprintf("PR Review: %s", prTitle) + status := "approved" + if !approved { + status = "requested changes" + } + + body := fmt.Sprintf(` +

Your PR has been %s

+

Hi %s,

+

Your pull request "%s" has been %s.

+

View Review

+

Thank you for using GitBot!

+ `, status, username, prTitle, status) + + return SendEmail(email, subject, body) +} + +func SendCommentNotification(email, username, fileName string) error { + subject := fmt.Sprintf("New comment on %s", fileName) + body := fmt.Sprintf(` +

You have a new comment

+

Hi %s,

+

%s commented on %s.

+

View Comment

+ `, username, username, fileName) + + return SendEmail(email, subject, body) +} + +func SendWelcomeEmail(email, username string) error { + subject := "Welcome to GitBot" + body := fmt.Sprintf(` +

Welcome to GitBot

+

Hi %s,

+

Welcome to GitBot, your AI-powered code review assistant!

+

You can now:

+
    +
  • Review pull requests efficiently
  • +
  • Add inline comments and feedback
  • +
  • Track approval status
  • +
  • Get notifications on code changes
  • +
+

Get Started

+

Happy reviewing!

+ `, username) + + return SendEmail(email, subject, body) +} + +func GetEmailQueue() []EmailNotification { + return emailQueue +} + +func ClearEmailQueue() { + emailQueue = []EmailNotification{} +} diff --git a/gitbot/backend/go.mod b/gitbot/backend/go.mod index 5d7e65f..50cdfc2 100644 --- a/gitbot/backend/go.mod +++ b/gitbot/backend/go.mod @@ -1,3 +1,8 @@ module gitbot-backend -go 1.24 +go 1.24.0 + +require ( + github.com/lib/pq v1.10.9 + golang.org/x/crypto v0.45.0 +) diff --git a/gitbot/backend/main.go b/gitbot/backend/main.go index f838bbb..57aaf0e 100644 --- a/gitbot/backend/main.go +++ b/gitbot/backend/main.go @@ -3,13 +3,16 @@ package main import ( "encoding/json" "fmt" + "log" "net/http" + "sync" + "time" ) -// Cấu trúc dữ liệu trả về cho màn hình Diff (Mobile/Desktop đều dùng chung) +// Data structures type DiffLine struct { - Type string `json:"type"` // "addition", "deletion", "neutral" - Content string `json:"content"` // Nội dung dòng code + Type string `json:"type"` + Content string `json:"content"` LineNum int `json:"line_num"` } @@ -18,29 +21,277 @@ type FileDiff struct { Lines []DiffLine `json:"lines"` } -func main() { - // API lấy danh sách code thay đổi (Diff) của Pull Request - http.HandleFunc("/api/v1/diff", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Content-Type", "application/json") - - // Dữ liệu mẫu giả lập từ Git Core Engine trả về - mockDiff := []FileDiff{ - { - FilePath: "components/Auth.ts", - Lines: []DiffLine{ - {Type: "neutral", Content: "package auth", LineNum: 1}, - {Type: "deletion", Content: "- func Login(u string) {", LineNum: 2}, - {Type: "addition", Content: "+ func Login(email string, pass string) {", LineNum: 3}, - {Type: "addition", Content: "+ \t// AI Bot: Đã check bảo mật SQL Injection ở đây", LineNum: 4}, - {Type: "neutral", Content: "\treturn true", LineNum: 5}, - }, - }, +type Comment struct { + ID string `json:"id"` + LineNum int `json:"line_num"` + FilePath string `json:"file_path"` + Author string `json:"author"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` +} + +type PRStats struct { + FilesChanged int `json:"files_changed"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Commits int `json:"commits"` +} + +type ApprovalRequest struct { + PRNumber int `json:"pr_number"` + Status string `json:"status"` + Feedback string `json:"feedback"` +} + +// In-memory storage +var ( + comments []Comment + commentsMutex sync.RWMutex + approvalStatus map[int]string = make(map[int]string) +) + +// Mock data +var mockDiff = []FileDiff{ + { + FilePath: "components/Auth.tsx", + Lines: []DiffLine{ + {Type: "neutral", Content: "import React from 'react';", LineNum: 1}, + {Type: "deletion", Content: "- export const Login = () => {", LineNum: 2}, + {Type: "addition", Content: "+ export const Login: React.FC = ({ onSuccess }) => {", LineNum: 3}, + {Type: "neutral", Content: " const [email, setEmail] = React.useState('');", LineNum: 4}, + {Type: "addition", Content: "+ const [password, setPassword] = React.useState('');", LineNum: 5}, + {Type: "neutral", Content: " return (", LineNum: 6}, + }, + }, + { + FilePath: "hooks/useAuth.ts", + Lines: []DiffLine{ + {Type: "neutral", Content: "export function useAuth() {", LineNum: 1}, + {Type: "deletion", Content: "- const [user, setUser] = useState(null);", LineNum: 2}, + {Type: "addition", Content: "+ const [user, setUser] = useState(null);", LineNum: 3}, + {Type: "addition", Content: "+ const [isLoading, setIsLoading] = useState(false);", LineNum: 4}, + {Type: "neutral", Content: " return { user, login, logout };", LineNum: 5}, + }, + }, + { + FilePath: "utils/validation.ts", + Lines: []DiffLine{ + {Type: "neutral", Content: "export const validateEmail = (email: string) => {", LineNum: 1}, + {Type: "addition", Content: "+ const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;", LineNum: 2}, + {Type: "addition", Content: "+ return emailRegex.test(email);", LineNum: 3}, + {Type: "neutral", Content: "};", LineNum: 4}, + }, + }, +} + +func enableCORS(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Content-Type", "application/json") +} + +// GET /api/v1/diff +func handleGetDiff(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + prID := r.URL.Query().Get("pr_id") + if prID == "" { + prID = "default" + } + + // Try to get from cache + if cachedDiff, ok := GetCachedDiff(prID); ok { + json.NewEncoder(w).Encode(cachedDiff) + return + } + + // Cache the diff + CacheDiff(prID, mockDiff) + json.NewEncoder(w).Encode(mockDiff) +} + +// GET /api/v1/stats +func handleGetStats(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + prID := r.URL.Query().Get("pr_id") + if prID == "" { + prID = "default" + } + + // Try to get from cache + if cachedStats, ok := GetCachedStats(prID); ok { + json.NewEncoder(w).Encode(cachedStats) + return + } + + stats := PRStats{ + FilesChanged: len(mockDiff), + Additions: 0, + Deletions: 0, + Commits: 3, + } + + for _, file := range mockDiff { + for _, line := range file.Lines { + if line.Type == "addition" { + stats.Additions++ + } else if line.Type == "deletion" { + stats.Deletions++ + } + } + } + + // Cache the stats + CacheStats(prID, stats) + json.NewEncoder(w).Encode(stats) +} + +// GET/POST /api/v1/comments +func handleComments(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + if r.Method == "GET" { + commentsMutex.RLock() + defer commentsMutex.RUnlock() + + if len(comments) == 0 { + json.NewEncoder(w).Encode([]Comment{}) + return + } + json.NewEncoder(w).Encode(comments) + } else if r.Method == "POST" { + var comment Comment + if err := json.NewDecoder(r.Body).Decode(&comment); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return } + + comment.ID = fmt.Sprintf("comment_%d", time.Now().UnixNano()) + comment.CreatedAt = time.Now() + + commentsMutex.Lock() + comments = append(comments, comment) + commentsMutex.Unlock() + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(comment) + } +} + +// POST /api/v1/approve +func handleApprove(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req ApprovalRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + approvalStatus[req.PRNumber] = req.Status + + response := map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("PR #%d marked as %s", req.PRNumber, req.Status), + "status": req.Status, + } + + json.NewEncoder(w).Encode(response) +} + +// GET /api/v1/files +func handleGetFiles(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + files := make([]string, len(mockDiff)) + for i, file := range mockDiff { + files[i] = file.FilePath + } + + json.NewEncoder(w).Encode(files) +} + +// GET /health +func handleHealth(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} - json.NewEncoder(w).Encode(mockDiff) +func main() { + // Initialize database + db, err := initDatabase() + if err != nil { + log.Fatalf("Failed to initialize database: %v\n", err) + } + defer db.Close() + + // API routes + http.HandleFunc("/api/v1/diff", handleGetDiff) + http.HandleFunc("/api/v1/stats", handleGetStats) + http.HandleFunc("/api/v1/comments", handleComments) + http.HandleFunc("/api/v1/approve", handleApprove) + http.HandleFunc("/api/v1/files", handleGetFiles) + http.HandleFunc("/health", handleHealth) + + // Auth routes + http.HandleFunc("/api/v1/auth/register", func(w http.ResponseWriter, r *http.Request) { + handleRegister(w, r, db) + }) + http.HandleFunc("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) { + handleLogin(w, r, db) }) + http.HandleFunc("/api/v1/auth/me", func(w http.ResponseWriter, r *http.Request) { + handleGetMe(w, r, db) + }) + + // Email routes + http.HandleFunc("/api/v1/email/send", handleSendEmail) + + // Webhook routes + http.HandleFunc("/api/v1/webhooks/config", handleWebhookConfig) + http.HandleFunc("/api/v1/webhooks/broadcast", handleBroadcastEvent) + + port := ":8080" + log.Printf("GitBot Backend starting on %s\n", port) - fmt.Println("🚀 GitBot Backend đang chạy tại cổng :8080") - http.ListenAndServe(":8080", nil) + if err := http.ListenAndServe(port, nil); err != nil { + log.Fatalf("Server error: %v\n", err) + } } diff --git a/gitbot/backend/webhook.go b/gitbot/backend/webhook.go new file mode 100644 index 0000000..da665fc --- /dev/null +++ b/gitbot/backend/webhook.go @@ -0,0 +1,299 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" +) + +type WebhookEvent struct { + Type string `json:"type"` + Title string `json:"title"` + Message string `json:"message"` + User string `json:"user"` + Timestamp string `json:"timestamp"` + Extra interface{} `json:"extra,omitempty"` +} + +type SlackMessage struct { + Text string `json:"text"` + Attachments []Attachment `json:"attachments,omitempty"` +} + +type Attachment struct { + Color string `json:"color"` + Title string `json:"title"` + Text string `json:"text"` + Fields []Field `json:"fields,omitempty"` +} + +type Field struct { + Title string `json:"title"` + Value string `json:"value"` + Short bool `json:"short"` +} + +type DiscordMessage struct { + Username string `json:"username,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Content string `json:"content"` + Embeds []DiscordEmbed `json:"embeds,omitempty"` +} + +type DiscordEmbed struct { + Title string `json:"title"` + Description string `json:"description"` + Color int `json:"color"` + Fields []DiscordField `json:"fields,omitempty"` + Footer DiscordEmbedFooter `json:"footer,omitempty"` +} + +type DiscordField struct { + Name string `json:"name"` + Value string `json:"value"` + Inline bool `json:"inline"` +} + +type DiscordEmbedFooter struct { + Text string `json:"text"` +} + +type WebhookConfig struct { + SlackWebhookURL string + DiscordWebhookURL string +} + +var webhookConfig = WebhookConfig{ + SlackWebhookURL: "", // Set from environment or config + DiscordWebhookURL: "", // Set from environment or config +} + +func SendSlackNotification(event WebhookEvent) error { + if webhookConfig.SlackWebhookURL == "" { + log.Println("Slack webhook URL not configured") + return nil + } + + color := "#3b82f6" // Blue + if strings.Contains(event.Type, "approved") { + color = "#10b981" // Green + } else if strings.Contains(event.Type, "rejected") { + color = "#ef4444" // Red + } + + message := SlackMessage{ + Text: fmt.Sprintf("*%s*", event.Title), + Attachments: []Attachment{ + { + Color: color, + Title: event.Title, + Text: event.Message, + Fields: []Field{ + { + Title: "User", + Value: event.User, + Short: true, + }, + { + Title: "Event Type", + Value: event.Type, + Short: true, + }, + { + Title: "Time", + Value: event.Timestamp, + Short: true, + }, + }, + }, + }, + } + + payload, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("failed to marshal Slack message: %w", err) + } + + resp, err := http.Post(webhookConfig.SlackWebhookURL, "application/json", bytes.NewBuffer(payload)) + if err != nil { + return fmt.Errorf("failed to send Slack notification: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Slack API returned status %d: %s", resp.StatusCode, string(body)) + } + + log.Printf("Slack notification sent: %s\n", event.Title) + return nil +} + +func SendDiscordNotification(event WebhookEvent) error { + if webhookConfig.DiscordWebhookURL == "" { + log.Println("Discord webhook URL not configured") + return nil + } + + color := 3447003 // Blue + if strings.Contains(event.Type, "approved") { + color = 3066993 // Green + } else if strings.Contains(event.Type, "rejected") { + color = 15158332 // Red + } + + message := DiscordMessage{ + Username: "GitBot", + AvatarURL: "https://api.dicebear.com/7.x/avataaars/svg?seed=GitBot", + Content: fmt.Sprintf("**%s**", event.Title), + Embeds: []DiscordEmbed{ + { + Title: event.Title, + Description: event.Message, + Color: color, + Fields: []DiscordField{ + { + Name: "User", + Value: event.User, + Inline: true, + }, + { + Name: "Event Type", + Value: event.Type, + Inline: true, + }, + { + Name: "Time", + Value: event.Timestamp, + Inline: false, + }, + }, + Footer: DiscordEmbedFooter{ + Text: "GitBot Code Review", + }, + }, + }, + } + + payload, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("failed to marshal Discord message: %w", err) + } + + resp, err := http.Post(webhookConfig.DiscordWebhookURL, "application/json", bytes.NewBuffer(payload)) + if err != nil { + return fmt.Errorf("failed to send Discord notification: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Discord API returned status %d: %s", resp.StatusCode, string(body)) + } + + log.Printf("Discord notification sent: %s\n", event.Title) + return nil +} + +func BroadcastEvent(event WebhookEvent) error { + // Send to both Slack and Discord + if err := SendSlackNotification(event); err != nil { + log.Printf("Slack notification error: %v\n", err) + } + + if err := SendDiscordNotification(event); err != nil { + log.Printf("Discord notification error: %v\n", err) + } + + return nil +} + +func handleWebhookConfig(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + switch r.Method { + case "GET": + // Return current webhook config (without revealing full URLs) + config := map[string]interface{}{ + "slack_configured": webhookConfig.SlackWebhookURL != "", + "discord_configured": webhookConfig.DiscordWebhookURL != "", + } + json.NewEncoder(w).Encode(config) + + case "POST": + var req map[string]string + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if slack, ok := req["slack_webhook"]; ok && slack != "" { + webhookConfig.SlackWebhookURL = slack + log.Println("Slack webhook configured") + } + + if discord, ok := req["discord_webhook"]; ok && discord != "" { + webhookConfig.DiscordWebhookURL = discord + log.Println("Discord webhook configured") + } + + json.NewEncoder(w).Encode(map[string]string{ + "success": "true", + "message": "Webhooks configured", + }) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func handleBroadcastEvent(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + enableCORS(w) + w.WriteHeader(http.StatusOK) + return + } + + enableCORS(w) + + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var event WebhookEvent + if err := json.NewDecoder(r.Body).Decode(&event); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if event.Title == "" || event.Message == "" { + http.Error(w, "Title and message are required", http.StatusBadRequest) + return + } + + if err := BroadcastEvent(event); err != nil { + log.Printf("Error broadcasting event: %v\n", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to broadcast event", + }) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "success": "true", + "message": "Event broadcasted to Slack and Discord", + }) +} diff --git a/gitbot/docker-compose.yml b/gitbot/docker-compose.yml index a5406a1..8dcc24b 100644 --- a/gitbot/docker-compose.yml +++ b/gitbot/docker-compose.yml @@ -11,6 +11,21 @@ services: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gitbot_admin"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 backend: image: golang:1.24-alpine @@ -20,9 +35,12 @@ services: working_dir: /app ports: - "8080:8080" - command: sh -c "go mod tidy && go run main.go" + command: sh -c "go mod tidy && go run main.go auth.go database.go cache.go email.go webhook.go" depends_on: - - postgres + postgres: + condition: service_healthy + redis: + condition: service_healthy frontend: image: node:20-alpine diff --git a/gitbot/frontend/app/auth/page.tsx b/gitbot/frontend/app/auth/page.tsx new file mode 100644 index 0000000..d781b9c --- /dev/null +++ b/gitbot/frontend/app/auth/page.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { LogIn, UserPlus } from 'lucide-react'; + +export default function AuthPage() { + const router = useRouter(); + const [isLogin, setIsLogin] = useState(true); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [username, setUsername] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const endpoint = isLogin ? '/api/v1/auth/login' : '/api/v1/auth/register'; + const body = isLogin ? { email, password } : { email, password, username }; + + const response = await fetch(`http://localhost:8080${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const data = await response.json(); + setError(data.error || 'Authentication failed'); + return; + } + + const data = await response.json(); + localStorage.setItem('token', data.token); + localStorage.setItem('user', JSON.stringify(data.user)); + + router.push('/'); + } catch (err) { + setError('Network error. Please try again.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Logo */} +
+
+ +
+

GitBot

+

Code Review Assistant

+
+ + {/* Form card */} +
+
+

+ {isLogin ? 'Welcome Back' : 'Create Account'} +

+

+ {isLogin + ? 'Sign in to your GitBot account' + : 'Join GitBot for smarter code reviews'} +

+
+ +
+ {/* Email */} +
+ + setEmail(e.target.value)} + placeholder="your@email.com" + required + className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/50 transition-all" + /> +
+ + {/* Username (register only) */} + {!isLogin && ( +
+ + setUsername(e.target.value)} + placeholder="your_username" + required + className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/50 transition-all" + /> +
+ )} + + {/* Password */} +
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/50 transition-all" + /> +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Submit button */} + +
+ + {/* Toggle */} +
+

+ {isLogin ? "Don't have an account? " : 'Already have an account? '} + +

+
+
+ + {/* Footer */} +

+ By using GitBot, you agree to our Terms of Service and Privacy Policy +

+
+
+ ); +} diff --git a/gitbot/frontend/app/globals.css b/gitbot/frontend/app/globals.css index 1ef5fed..bfefd7b 100644 --- a/gitbot/frontend/app/globals.css +++ b/gitbot/frontend/app/globals.css @@ -1,5 +1,21 @@ @import 'tailwindcss'; +@theme inline { + --font-sans: 'Inter', 'Inter Fallback', system-ui, -apple-system, sans-serif; + --color-background: #0f172a; + --color-surface: #1e293b; + --color-surface-light: #334155; + --color-border: #475569; + --color-text-primary: #f1f5f9; + --color-text-secondary: #cbd5e1; + --color-accent-blue: #3b82f6; + --color-accent-green: #10b981; + --color-accent-red: #ef4444; + --color-success: #10b981; + --color-error: #ef4444; + --radius: 0.5rem; +} + * { margin: 0; padding: 0; @@ -8,10 +24,30 @@ html { scroll-behavior: smooth; + background-color: var(--color-background); } body { - font-family: system-ui, -apple-system, sans-serif; - background-color: #111827; - color: #f3f4f6; + font-family: var(--font-sans); + background-color: var(--color-background); + color: var(--color-text-primary); +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--color-surface); +} + +::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-surface-light); } diff --git a/gitbot/frontend/app/layout.tsx b/gitbot/frontend/app/layout.tsx index 500c688..322da33 100644 --- a/gitbot/frontend/app/layout.tsx +++ b/gitbot/frontend/app/layout.tsx @@ -1,9 +1,12 @@ import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; import './globals.css'; +const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }); + export const metadata: Metadata = { - title: 'GitBot - AI Code Review Assistant', - description: 'Intelligent code review and diff analysis tool', + title: 'GitBot - Code Review Assistant', + description: 'AI-powered code review and diff viewer for pull requests', }; export default function RootLayout({ @@ -12,8 +15,8 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - {children} + + {children} ); } diff --git a/gitbot/frontend/app/page.tsx b/gitbot/frontend/app/page.tsx index db879f2..62bb465 100644 --- a/gitbot/frontend/app/page.tsx +++ b/gitbot/frontend/app/page.tsx @@ -1,72 +1,121 @@ 'use client'; + import { useEffect, useState } from 'react'; +import { Sidebar } from '@/components/Sidebar'; +import { Header } from '@/components/Header'; +import { DiffViewer } from '@/components/DiffViewer'; +import { Stats } from '@/components/Stats'; +import { FileFilter } from '@/components/FileFilter'; +import { ApprovalPanel } from '@/components/ApprovalPanel'; + +interface DiffLine { + type: 'addition' | 'deletion' | 'neutral'; + content: string; + line_num: number; +} + +interface FileDiff { + file_path: string; + lines: DiffLine[]; +} export default function GitBotDiffPage() { - const [diffData, setDiffData] = useState([]); + const [diffData, setDiffData] = useState([]); + const [filteredFiles, setFilteredFiles] = useState([]); + const [approvalStatus, setApprovalStatus] = useState<'pending' | 'approved' | 'rejected'>('pending'); + const [isLoading, setIsLoading] = useState(true); useEffect(() => { - // Gọi API từ Backend Go + // Fetch từ Backend Go fetch('http://localhost:8080/api/v1/diff') - .then(res => res.json()) - .then(data => setDiffData(data)) - .catch(err => console.error(err)); + .then((res) => res.json()) + .then((data) => { + setDiffData(data); + setFilteredFiles(data); + setIsLoading(false); + }) + .catch((err) => { + console.error('[v0] Error fetching diff:', err); + setIsLoading(false); + }); }, []); + const handleFilterChange = (filteredFilePaths: string[]) => { + const filtered = diffData.filter((file) => filteredFilePaths.includes(file.file_path)); + setFilteredFiles(filtered); + }; + + const calculateStats = () => { + let additions = 0; + let deletions = 0; + + diffData.forEach((file) => { + file.lines.forEach((line) => { + if (line.type === 'addition') additions++; + if (line.type === 'deletion') deletions++; + }); + }); + + return { + filesChanged: diffData.length, + additions, + deletions, + commits: 3, + }; + }; + + const stats = calculateStats(); + const filePaths = diffData.map((f) => f.file_path); + + // Transform data to match DiffViewer interface + const viewerFiles = filteredFiles.map((file) => ({ + filePath: file.file_path, + lines: file.lines, + additions: file.lines.filter((l) => l.type === 'addition').length, + deletions: file.lines.filter((l) => l.type === 'deletion').length, + })); + return ( -
- {/* HEADER: Responsive từ PC đến Mobile */} -
-
- Open -

PR #124: Tối ưu cơ chế bảo mật Login

-
- -
- - {/* BODY CONTAINER */} -
- {diffData.map((file, fIdx) => ( -
- {/* Thanh tiêu đề file */} -
- 📄 {file.file_path} +
+ +
+ +
+
+ {/* Stats */} + + + {/* Main content grid */} +
+ {/* Filter sidebar */} +
+
- {/* Vùng hiển thị Code Diff - Ép Unified View trên Mobile */} -
-
- {file.lines.map((line: any, lIdx: number) => { - // Định dạng màu sắc dựa vào loại dòng (Thêm/Xóa/Giữ nguyên) - let rowBg = "hover:bg-gray-900"; - let textColor = "text-gray-400"; - if (line.type === "addition") { - rowBg = "bg-green-950/40 hover:bg-green-900/40 border-l-4 border-green-500"; - textColor = "text-green-300"; - } else if (line.type === "deletion") { - rowBg = "bg-red-950/40 hover:bg-red-900/40 border-l-4 border-red-500"; - textColor = "text-red-300"; - } - - return ( -
- {/* Số dòng */} -
- {line.line_num} -
- {/* Nội dung code - word-break chống vỡ khung màn hình điện thoại */} -
- {line.content} -
-
- ); - })} -
+ {/* Diff viewer */} +
+ {isLoading ? ( +
+

Loading diff...

+
+ ) : filteredFiles.length === 0 ? ( +
+

No files match your filters

+
+ ) : ( + + )}
- ))} +
+ + {/* Approval panel */} + setApprovalStatus('approved')} + onRequestChanges={() => setApprovalStatus('rejected')} + />
); } diff --git a/gitbot/frontend/components/ApprovalPanel.tsx b/gitbot/frontend/components/ApprovalPanel.tsx new file mode 100644 index 0000000..0fc9c08 --- /dev/null +++ b/gitbot/frontend/components/ApprovalPanel.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useState } from 'react'; +import { ThumbsUp, MessageSquare, AlertCircle, CheckCircle } from 'lucide-react'; + +interface ApprovalPanelProps { + onApprove: () => void; + onRequestChanges: () => void; + currentStatus: 'pending' | 'approved' | 'rejected'; +} + +export function ApprovalPanel({ + onApprove, + onRequestChanges, + currentStatus, +}: ApprovalPanelProps) { + const [feedback, setFeedback] = useState(''); + const [showFeedback, setShowFeedback] = useState(false); + + return ( +
+
+ {/* Status display */} +
+
+ {currentStatus === 'approved' ? ( + <> + + Approved + + ) : currentStatus === 'rejected' ? ( + <> + + Changes Requested + + ) : ( + <> + + Awaiting Review + + )} +
+

Last updated: 2 minutes ago

+
+ + {/* Action buttons */} +
+ + + +
+ + {/* Feedback form */} + {showFeedback && ( +
+

Add your feedback

+