diff --git a/.air.toml b/.air.toml index 8854041a25..6e3c5bdc83 100644 --- a/.air.toml +++ b/.air.toml @@ -4,7 +4,7 @@ tmp_dir = ".air" [build] pre_cmd = ["killall -9 gitea 2>/dev/null || true"] # kill off potential zombie processes from previous runs cmd = "make --no-print-directory backend" -bin = "gitea" +entrypoint = ["./gitea"] delay = 2000 include_ext = ["go", "tmpl"] include_file = ["main.go"] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0b20da7c16..4f82a5d8c6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,7 +13,7 @@ "ghcr.io/devcontainers/features/git-lfs:1.2.5": {}, "ghcr.io/jsburckhardt/devcontainer-features/uv:1": {}, "ghcr.io/devcontainers/features/python:1": { - "version": "3.13" + "version": "3.14" }, "ghcr.io/warrenbuckley/codespace-features/sqlite:1": {} }, diff --git a/.dockerignore b/.dockerignore index 8e0d6b3666..c88fb144fe 100644 --- a/.dockerignore +++ b/.dockerignore @@ -74,6 +74,9 @@ cpu.out /VERSION /.air /.go-licenses +/Dockerfile +/Dockerfile.rootless +/.venv # Files and folders that were previously generated /public/assets/img/webpack diff --git a/.editorconfig b/.editorconfig index 13aa8d50f0..bf1cf757cc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -25,6 +25,10 @@ insert_final_newline = false [templates/user/auth/oidc_wellknown.tmpl] indent_style = space +[templates/shared/actions/runner_badge_*.tmpl] +# editconfig lint requires these XML-like files to have charset defined, but the files don't have. +charset = unset + [Makefile] indent_style = tab diff --git a/.gitattributes b/.gitattributes index e218bbe25d..afd02555f5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,3 +8,4 @@ /vendor/** -text -eol linguist-vendored /web_src/js/vendor/** -text -eol linguist-vendored Dockerfile.* linguist-language=Dockerfile +Makefile.* linguist-language=Makefile diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..be33b8975f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 + +updates: + - package-ecosystem: github-actions + labels: [modifies/dependencies] + directory: / + schedule: + interval: daily + cooldown: + default-days: 5 diff --git a/.github/labeler.yml b/.github/labeler.yml index 49679d28cf..68a0f30fd6 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -46,12 +46,11 @@ modifies/internal: - ".gitpod.yml" - ".markdownlint.yaml" - ".spectral.yaml" - - "stylelint.config.js" + - "stylelint.config.*" - ".yamllint.yaml" - ".github/**" - ".gitea/**" - ".devcontainer/**" - - "build.go" - "build/**" - "contrib/**" @@ -85,9 +84,9 @@ docs-update-needed: topic/code-linting: - changed-files: - any-glob-to-any-file: - - ".eslintrc.cjs" - ".golangci.yml" - ".markdownlint.yaml" - ".spectral.yaml" - ".yamllint.yaml" - - "stylelint.config.js" + - "eslint*.config.*" + - "stylelint.config.*" diff --git a/.github/workflows/cron-flake-updater.yml b/.github/workflows/cron-flake-updater.yml new file mode 100644 index 0000000000..105802e558 --- /dev/null +++ b/.github/workflows/cron-flake-updater.yml @@ -0,0 +1,22 @@ +name: cron-flake-updater + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * 0' # runs weekly on Sunday at 00:00 + +jobs: + nix-flake-update: + permissions: + contents: write + issues: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: DeterminateSystems/determinate-nix-action@v3 + - uses: DeterminateSystems/update-flake-lock@main + with: + pr-title: "Update Nix flake" + pr-labels: | + dependencies diff --git a/.github/workflows/cron-licenses.yml b/.github/workflows/cron-licenses.yml index c34066d318..ee1c3e0c75 100644 --- a/.github/workflows/cron-licenses.yml +++ b/.github/workflows/cron-licenses.yml @@ -9,16 +9,18 @@ jobs: cron-licenses: runs-on: ubuntu-latest if: github.repository == 'go-gitea/gitea' + permissions: + contents: write steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - run: make generate-gitignore timeout-minutes: 40 - name: push translations to repo - uses: appleboy/git-push-action@v0.0.3 + uses: appleboy/git-push-action@v1.2.0 with: author_email: "teabot@gitea.io" author_name: GiteaBot diff --git a/.github/workflows/cron-translations.yml b/.github/workflows/cron-translations.yml index f1b51debf1..56a30fb5ba 100644 --- a/.github/workflows/cron-translations.yml +++ b/.github/workflows/cron-translations.yml @@ -9,9 +9,11 @@ jobs: crowdin-pull: runs-on: ubuntu-latest if: github.repository == 'go-gitea/gitea' + permissions: + contents: write steps: - - uses: actions/checkout@v4 - - uses: crowdin/github-action@v1 + - uses: actions/checkout@v6 + - uses: crowdin/github-action@v2 with: upload_sources: true upload_translations: false @@ -27,7 +29,7 @@ jobs: - name: update locales run: ./build/update-locales.sh - name: push translations to repo - uses: appleboy/git-push-action@v0.0.3 + uses: appleboy/git-push-action@v1.2.0 with: author_email: "teabot@gitea.io" author_name: GiteaBot diff --git a/.github/workflows/files-changed.yml b/.github/workflows/files-changed.yml index edceef0092..332e9e0d6f 100644 --- a/.github/workflows/files-changed.yml +++ b/.github/workflows/files-changed.yml @@ -19,11 +19,15 @@ on: value: ${{ jobs.detect.outputs.swagger }} yaml: value: ${{ jobs.detect.outputs.yaml }} + json: + value: ${{ jobs.detect.outputs.json }} jobs: detect: runs-on: ubuntu-latest timeout-minutes: 3 + permissions: + contents: read outputs: backend: ${{ steps.changes.outputs.backend }} frontend: ${{ steps.changes.outputs.frontend }} @@ -33,8 +37,9 @@ jobs: docker: ${{ steps.changes.outputs.docker }} swagger: ${{ steps.changes.outputs.swagger }} yaml: ${{ steps.changes.outputs.yaml }} + json: ${{ steps.changes.outputs.json }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dorny/paths-filter@v3 id: changes with: @@ -48,7 +53,7 @@ jobs: - "Makefile" - ".golangci.yml" - ".editorconfig" - - "options/locale/locale_en-US.ini" + - "options/locale/locale_en-US.json" frontend: - "*.js" @@ -80,6 +85,7 @@ jobs: - "uv.lock" docker: + - ".github/workflows/pull-docker-dryrun.yml" - "Dockerfile" - "Dockerfile.rootless" - "docker/**" @@ -98,3 +104,6 @@ jobs: - "**/*.yaml" - ".yamllint.yaml" - "pyproject.toml" + + json: + - "**/*.json" diff --git a/.github/workflows/pull-compliance.yml b/.github/workflows/pull-compliance.yml index 6f8991ed4e..fb81622bd6 100644 --- a/.github/workflows/pull-compliance.yml +++ b/.github/workflows/pull-compliance.yml @@ -10,14 +10,18 @@ concurrency: jobs: files-changed: uses: ./.github/workflows/files-changed.yml + permissions: + contents: read lint-backend: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -30,14 +34,18 @@ jobs: if: needs.files-changed.outputs.templates == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v6 - - run: uv python install 3.12 + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + - run: uv python install 3.14 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - run: make deps-py - run: make deps-frontend - run: make lint-templates @@ -46,23 +54,44 @@ jobs: if: needs.files-changed.outputs.yaml == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v6 - - run: uv python install 3.12 + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + - run: uv python install 3.14 - run: make deps-py - run: make lint-yaml + lint-json: + if: needs.files-changed.outputs.json == 'true' + needs: files-changed + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v5 + with: + node-version: 24 + - run: make deps-frontend + - run: make lint-json + lint-swagger: if: needs.files-changed.outputs.swagger == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend - run: make lint-swagger @@ -70,9 +99,11 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.actions == 'true' || needs.files-changed.outputs.docs == 'true' || needs.files-changed.outputs.templates == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -82,9 +113,11 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -99,9 +132,11 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -114,9 +149,11 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -127,12 +164,16 @@ jobs: if: needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend - run: make lint-frontend - run: make checks-frontend @@ -143,9 +184,11 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -175,12 +218,16 @@ jobs: if: needs.files-changed.outputs.docs == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend - run: make lint-md @@ -188,9 +235,11 @@ jobs: if: needs.files-changed.outputs.actions == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true diff --git a/.github/workflows/pull-db-tests.yml b/.github/workflows/pull-db-tests.yml index a7ad7ed5c3..66f48d5af8 100644 --- a/.github/workflows/pull-db-tests.yml +++ b/.github/workflows/pull-db-tests.yml @@ -10,11 +10,15 @@ concurrency: jobs: files-changed: uses: ./.github/workflows/files-changed.yml + permissions: + contents: read test-pgsql: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read services: pgsql: image: postgres:14 @@ -38,8 +42,8 @@ jobs: ports: - "9000:9000" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -65,9 +69,11 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -90,6 +96,8 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read services: elasticsearch: image: elasticsearch:7.5.0 @@ -124,8 +132,8 @@ jobs: ports: - 10000:10000 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -152,6 +160,8 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read services: mysql: # the bitnami mysql image has more options than the official one, it's easier to customize @@ -177,8 +187,8 @@ jobs: - "587:587" - "993:993" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -203,6 +213,8 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read services: mssql: image: mcr.microsoft.com/mssql/server:2019-latest @@ -217,8 +229,8 @@ jobs: ports: - 10000:10000 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true diff --git a/.github/workflows/pull-docker-dryrun.yml b/.github/workflows/pull-docker-dryrun.yml index f74277de67..405521c354 100644 --- a/.github/workflows/pull-docker-dryrun.yml +++ b/.github/workflows/pull-docker-dryrun.yml @@ -10,26 +10,29 @@ concurrency: jobs: files-changed: uses: ./.github/workflows/files-changed.yml + permissions: + contents: read - regular: - if: needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.actions == 'true' + container: + if: needs.files-changed.outputs.docker == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: + - uses: actions/checkout@v6 + - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 - - uses: docker/build-push-action@v5 + - name: Build regular container image + uses: docker/build-push-action@v6 with: + context: . + platforms: linux/amd64,linux/arm64,linux/riscv64 push: false - tags: gitea/gitea:linux-amd64 - - rootless: - if: needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.actions == 'true' - needs: files-changed - runs-on: ubuntu-latest - steps: - - uses: docker/setup-buildx-action@v3 - - uses: docker/build-push-action@v5 + - name: Build rootless container image + uses: docker/build-push-action@v6 with: + context: . push: false + platforms: linux/amd64,linux/arm64,linux/riscv64 file: Dockerfile.rootless - tags: gitea/gitea:linux-amd64 diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml deleted file mode 100644 index 89b32260ca..0000000000 --- a/.github/workflows/pull-e2e-tests.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: e2e-tests - -on: - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - files-changed: - uses: ./.github/workflows/files-changed.yml - - test-e2e: - # the "test-e2e" won't pass, and it seems that there is no useful test, so skip - # if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.actions == 'true' - if: false - needs: files-changed - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - check-latest: true - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 - with: - node-version: 24 - - run: make deps-frontend frontend deps-backend - - run: pnpm exec playwright install --with-deps - - run: make test-e2e-sqlite - timeout-minutes: 40 - env: - USE_REPO_TEST_DIR: 1 diff --git a/.github/workflows/pull-labeler.yml b/.github/workflows/pull-labeler.yml index 812819b599..d05483e56c 100644 --- a/.github/workflows/pull-labeler.yml +++ b/.github/workflows/pull-labeler.yml @@ -15,6 +15,6 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/labeler@v5 + - uses: actions/labeler@v6 with: sync-labels: true diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index 3d652e4ad8..f073df05d3 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -11,19 +11,23 @@ concurrency: jobs: nightly-binary: runs-on: namespace-profile-gitea-release-binary + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend deps-backend # xgo build - run: make release @@ -48,7 +52,7 @@ jobs: echo "Cleaned name is ${REF_NAME}" echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT" - name: configure aws - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v5 with: aws-region: ${{ secrets.AWS_REGION }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -56,19 +60,17 @@ jobs: - name: upload binaries to s3 run: | aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress - nightly-docker-rootful: + + nightly-container: runs-on: namespace-profile-gitea-release-docker permissions: + contents: read packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - check-latest: true - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 - name: Get cleaned branch name @@ -76,6 +78,29 @@ jobs: run: | REF_NAME=$(echo "${{ github.ref }}" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//') echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT" + - uses: docker/metadata-action@v5 + id: meta + with: + images: |- + gitea/gitea + ghcr.io/go-gitea/gitea + tags: | + type=raw,value=${{ steps.clean_name.outputs.branch }} + annotations: | + org.opencontainers.image.authors="maintainers@gitea.io" + - uses: docker/metadata-action@v5 + id: meta_rootless + with: + images: |- + gitea/gitea + ghcr.io/go-gitea/gitea + # each tag below will have the suffix of -rootless + flavor: | + suffix=-rootless + tags: | + type=raw,value=${{ steps.clean_name.outputs.branch }} + annotations: | + org.opencontainers.image.authors="maintainers@gitea.io" - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -87,57 +112,20 @@ jobs: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: fetch go modules - run: make vendor - - name: build rootful docker image - uses: docker/build-push-action@v5 + - name: build regular docker image + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64,linux/riscv64 push: true - tags: |- - gitea/gitea:${{ steps.clean_name.outputs.branch }} - ghcr.io/go-gitea/gitea:${{ steps.clean_name.outputs.branch }} - nightly-docker-rootless: - runs-on: namespace-profile-gitea-release-docker - permissions: - packages: write # to publish to ghcr.io - steps: - - uses: actions/checkout@v4 - # fetch all commits instead of only the last as some branches are long lived and could have many between versions - # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - check-latest: true - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - name: Get cleaned branch name - id: clean_name - run: | - REF_NAME=$(echo "${{ github.ref }}" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//') - echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT" - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Login to GHCR using PAT - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: fetch go modules - run: make vendor + tags: ${{ steps.meta.outputs.tags }} + annotations: ${{ steps.meta.outputs.annotations }} - name: build rootless docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64,linux/arm64,linux/riscv64 push: true file: Dockerfile.rootless - tags: |- - gitea/gitea:${{ steps.clean_name.outputs.branch }}-rootless - ghcr.io/go-gitea/gitea:${{ steps.clean_name.outputs.branch }}-rootless + tags: ${{ steps.meta_rootless.outputs.tags }} + annotations: ${{ steps.meta_rootless.outputs.annotations }} diff --git a/.github/workflows/release-tag-rc.yml b/.github/workflows/release-tag-rc.yml index f4776a9ed8..fab468c9b4 100644 --- a/.github/workflows/release-tag-rc.yml +++ b/.github/workflows/release-tag-rc.yml @@ -12,19 +12,23 @@ concurrency: jobs: binary: runs-on: namespace-profile-gitea-release-binary + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend deps-backend # xgo build - run: make release @@ -49,7 +53,7 @@ jobs: echo "Cleaned name is ${REF_NAME}" echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT" - name: configure aws - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v5 with: aws-region: ${{ secrets.AWS_REGION }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -58,7 +62,7 @@ jobs: run: | aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress - name: Install GH CLI - uses: dev-hanz-ops/install-gh-cli-action@v0.1.0 + uses: dev-hanz-ops/install-gh-cli-action@v0.2.1 with: gh-cli-version: 2.39.1 - name: create github release @@ -66,12 +70,14 @@ jobs: gh release create ${{ github.ref_name }} --title ${{ github.ref_name }} --draft --notes-from-tag dist/release/* env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} - docker-rootful: + + container: runs-on: namespace-profile-gitea-release-docker permissions: + contents: read packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force @@ -88,38 +94,10 @@ jobs: # 1.2.3-rc0 tags: | type=semver,pattern={{version}} - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Login to GHCR using PAT - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: build rootful docker image - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64,linux/arm64,linux/riscv64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - docker-rootless: - runs-on: namespace-profile-gitea-release-docker - permissions: - packages: write # to publish to ghcr.io - steps: - - uses: actions/checkout@v4 - # fetch all commits instead of only the last as some branches are long lived and could have many between versions - # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - - run: git fetch --unshallow --quiet --tags --force - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 + annotations: | + org.opencontainers.image.authors="maintainers@gitea.io" - uses: docker/metadata-action@v5 - id: meta + id: meta_rootless with: images: |- gitea/gitea @@ -131,6 +109,8 @@ jobs: # 1.2.3-rc0 tags: | type=semver,pattern={{version}} + annotations: | + org.opencontainers.image.authors="maintainers@gitea.io" - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -142,12 +122,20 @@ jobs: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: build rootless docker image - uses: docker/build-push-action@v5 + - name: build regular container image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/riscv64 + push: true + tags: ${{ steps.meta.outputs.tags }} + annotations: ${{ steps.meta.outputs.annotations }} + - name: build rootless container image + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64,linux/riscv64 push: true file: Dockerfile.rootless - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.meta_rootless.outputs.tags }} + annotations: ${{ steps.meta_rootless.outputs.annotations }} diff --git a/.github/workflows/release-tag-version.yml b/.github/workflows/release-tag-version.yml index ad0820f31f..113a33c3c7 100644 --- a/.github/workflows/release-tag-version.yml +++ b/.github/workflows/release-tag-version.yml @@ -15,20 +15,23 @@ jobs: binary: runs-on: namespace-profile-gitea-release-binary permissions: + contents: read packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend deps-backend # xgo build - run: make release @@ -53,7 +56,7 @@ jobs: echo "Cleaned name is ${REF_NAME}" echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT" - name: configure aws - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v5 with: aws-region: ${{ secrets.AWS_REGION }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -62,7 +65,7 @@ jobs: run: | aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress - name: Install GH CLI - uses: dev-hanz-ops/install-gh-cli-action@v0.1.0 + uses: dev-hanz-ops/install-gh-cli-action@v0.2.1 with: gh-cli-version: 2.39.1 - name: create github release @@ -70,12 +73,14 @@ jobs: gh release create ${{ github.ref_name }} --title ${{ github.ref_name }} --notes-from-tag dist/release/* env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} - docker-rootful: + + container: runs-on: namespace-profile-gitea-release-docker permissions: + contents: read packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force @@ -96,36 +101,10 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}} type=semver,pattern={{major}}.{{minor}} - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Login to GHCR using PAT - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: build rootful docker image - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64,linux/arm64,linux/riscv64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - docker-rootless: - runs-on: namespace-profile-gitea-release-docker - steps: - - uses: actions/checkout@v4 - # fetch all commits instead of only the last as some branches are long lived and could have many between versions - # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - - run: git fetch --unshallow --quiet --tags --force - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 + annotations: | + org.opencontainers.image.authors="maintainers@gitea.io" - uses: docker/metadata-action@v5 - id: meta + id: meta_rootless with: images: |- gitea/gitea @@ -142,6 +121,8 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}} type=semver,pattern={{major}}.{{minor}} + annotations: | + org.opencontainers.image.authors="maintainers@gitea.io" - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -153,12 +134,20 @@ jobs: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: build rootless docker image - uses: docker/build-push-action@v5 + - name: build regular container image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/riscv64 + push: true + tags: ${{ steps.meta.outputs.tags }} + annotations: ${{ steps.meta.outputs.annotations }} + - name: build rootless container image + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64,linux/riscv64 push: true file: Dockerfile.rootless - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.meta_rootless.outputs.tags }} + annotations: ${{ steps.meta_rootless.outputs.annotations }} diff --git a/.gitignore b/.gitignore index 821b1b8c67..aa08e47aec 100644 --- a/.gitignore +++ b/.gitignore @@ -121,6 +121,10 @@ prime/ /.goosehints /.windsurfrules /.github/copilot-instructions.md -/AGENT.md -/CLAUDE.md /llms.txt + +# Ignore worktrees when working on multiple branches +.worktrees/ + +# A Makefile for custom make targets +Makefile.local diff --git a/.golangci.yml b/.golangci.yml index 2ad39fbae2..62c1d005fa 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -6,14 +6,17 @@ linters: default: none enable: - bidichk + - bodyclose - depguard - dupl - errcheck - forbidigo + - gocheckcompilerdirectives - gocritic - govet - ineffassign - mirror + - modernize - nakedret - nolintlint - perfsprint @@ -45,6 +48,10 @@ linters: desc: do not use the ini package, use gitea's config system instead - pkg: gitea.com/go-chi/cache desc: do not use the go-chi cache package, use gitea's cache system + - pkg: github.com/pkg/errors + desc: use builtin errors package instead + - pkg: github.com/go-ap/errors + desc: use builtin errors package instead nolintlint: allow-unused: false require-explanation: true @@ -55,6 +62,7 @@ linters: disabled-checks: - ifElseChain - singleCaseSwitch # Every time this occurred in the code, there was no other way. + - deprecatedComment # conflicts with go-swagger comments revive: severity: error rules: @@ -107,6 +115,12 @@ linters: - require-error usetesting: os-temp-dir: true + perfsprint: + concat-loop: false + govet: + enable: + - nilness + - unusedwrite exclusions: generated: lax presets: @@ -153,6 +167,7 @@ linters: text: '(?i)exitAfterDefer:' paths: - node_modules + - .venv - public - web_src - third_party$ @@ -172,6 +187,7 @@ formatters: generated: lax paths: - node_modules + - .venv - public - web_src - third_party$ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..402a9d6945 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# Instructions for agents + +- Use `make help` to find available development targets +- Before committing `.go` changes, run `make fmt` to format, and run `make lint-go` to lint +- Before committing `.ts` changes, run `make lint-js` to lint +- Before committing `go.mod` changes, run `make tidy` +- Before committing new `.go` files, add the current year into the copyright header +- Before committing any files, remove all trailing whitespace from source code lines +- Never force-push to pull request branches +- Always start issue and pull request comments with an authorship attribution diff --git a/CHANGELOG.md b/CHANGELOG.md index b72ac4849a..f0d93452ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,453 @@ This changelog goes through the changes that have been made in each release without substantial changes to our git log; to see the highlights of what has been added to each release, please refer to the [blog](https://blog.gitea.com). -## [1.24.0](https://github.com/go-gitea/gitea/releases/tag/1.24.0) - 2025-05-26 +## [1.25.4](https://github.com/go-gitea/gitea/releases/tag/v1.25.4) - 2026-01-15 + +* SECURITY + * Release attachments must belong to the intended repo (#36347) (#36375) + * Fix permission check on org project operations (#36318) (#36373) + * Clean watches when make a repository private and check permission when send release emails (#36319) (#36370) + * Add more check for stopwatch read or list (#36340) (#36368) + * Fix openid setting check (#36346) (#36361) + * Fix cancel auto merge bug (#36341) (#36356) + * Fix delete attachment check (#36320) (#36355) + * LFS locks must belong to the intended repo (#36344) (#36349) + * Fix bug on notification read (#36339) #36387 +* ENHANCEMENTS + * Add more routes to the "expensive" list (#36290) + * Make "commit statuses" API accept slashes in "ref" (#36264) (#36275) +* BUGFIXES + * Fix git http service handling (#36396) + * Fix markdown newline handling during IME composition (#36421) (#36424) + * Fix missing repository id when migrating release attachments (#36389) + * Fix bug when compare in the pull request (#36363) (#36372) + * Fix incorrect text content detection (#36364) (#36369) + * Fill missing `has_code` in repository api (#36338) (#36359) + * Fix notifications pagination query parameters (#36351) (#36358) + * Fix some trivial problems (#36336) (#36337) + * Prevent panic when GitLab release has more links than sources (#36295) (#36305) + * Fix stats bug when syncing release (#36285) (#36294) + * Always honor user's choice for "delete branch after merge" (#36281) (#36286) + * Use the requested host for LFS links (#36242) (#36258) + * Fix panic when get editor config file (#36241) (#36247) + * Fix regression in writing authorized principals (#36213) (#36218) + * Fix WebAuthn error checking (#36219) (#36235) + +## [1.25.3](https://github.com/go-gitea/gitea/releases/tag/v1.25.3) - 2025-12-17 + +* SECURITY + * Bump toolchain to go1.25.5, misc fixes (#36082) +* ENHANCEMENTS + * Add strikethrough button to markdown editor (#36087) (#36104) + * Add "site admin" back to profile menu (#36010) (#36013) + * Improve math rendering (#36124) (#36125) +* BUGFIXES + * Check user visibility when redirecting to a renamed user (#36148) (#36159) + * Fix various bugs (#36139) (#36151) + * Fix bug when viewing the commit diff page with non-ANSI files (#36149) (#36150) + * Hide RSS icon when viewing a file not under a branch (#36135) (#36141) + * Fix SVG size calulation, only use `style` attribute (#36133) (#36134) + * Make Golang correctly delete temp files during uploading (#36128) (#36129) + * Fix the bug when ssh clone with redirect user or repository (#36039) (#36090) + * Use Golang net/smtp instead of gomail's smtp to send email (#36055) (#36083) + * Fix edit user email bug in API (#36068) (#36081) + * Fix bug when updating user email (#36058) (#36066) + * Fix incorrect viewed files counter if file has changed (#36009) (#36047) + * Fix container registry error handling (#36021) (#36037) + * Fix webAuthn insecure error view (#36165) (#36179) + * Fix some file icon ui (#36078) (#36088) + * Fix Actions `pull_request.paths` being triggered incorrectly by rebase (#36045) (#36054) + * Fix error handling in mailer and wiki services (#36041) (#36053) + * Fix bugs when comparing and creating pull request (#36166) (#36144) + +## [1.25.2](https://github.com/go-gitea/gitea/releases/tag/v1.25.2) - 2025-11-23 + +* SECURITY + * Upgrade golang.org/x/crypto to 0.45.0 (#35985) (#35988) + * Fix various permission & login related bugs (#36002) (#36004) +* ENHANCEMENTS + * Display source code downloads last for release attachments (#35897) (#35903) + * Change project default column icon to 'star' (#35967) (#35979) +* BUGFIXES + * Allow empty commit when merging pull request with squash style (#35989) (#36003) + * Fix container push tag overwriting (#35936) (#35954) + * Fix corrupted external render content (#35946) and upgrade golang.org/x packages (#35950) + * Limit reading bytes instead of ReadAll (#35928) (#35934) + * Use correct form field for allowed force push users in branch protection API (#35894) (#35908) + * Fix team member access check (#35899) (#35905) + * Fix conda null depend issue (#35900) (#35902) + * Set the dates to now when not specified by the caller (#35861) (#35874) + * Fix gogit ListEntriesRecursiveWithSize (#35862) + * Misc CSS fixes (#35888) (#35981) + * Don't show unnecessary error message to end users for DeleteBranchAfterMerge (#35937) (#35941) + * Load jQuery as early as possible to support custom scripts (#35926) (#35929) + * Allow to display embed images/pdfs when SERVE_DIRECT was enabled on MinIO storage (#35882) (#35917) + * Make OAuth2 issuer configurable (#35915) (#35916) + * Fix #35763: Add proper page title for project pages (#35773) (#35909) + * Fix avatar upload error handling (#35887) (#35890) + * Contribution heatmap improvements (#35876) (#35880) + * Remove padding override on `.ui .sha.label` (#35864) (#35873) + * Fix pull description code label background (#35865) (#35870) + +## [1.25.1](https://github.com/go-gitea/gitea/releases/tag/v1.25.1) - 2025-11-03 + +* BUGFIXES + * Make ACME email optional (#35849) #35857 + * Add a doctor command to fix inconsistent run status (#35840) (#35845) + * Remove wrong code (#35846) + * Fix viewed files number is not right if not all files loaded (#35821) (#35844) + * Fix incorrect pull request counter (#35819) (#35841) + * Upgrade go mail to 0.7.2 and fix the bug (#35833) (#35837) + * Revert gomail to v0.7.0 to fix sending mail failed (#35816) (#35824) + * Fix clone mixed bug (#35810) (#35822) + * Fix cli "Before" handling (#35797) (#35808) + * Improve and fix markup code preview rendering (#35777) (#35787) + * Fix actions rerun bug (#35783) (#35784) + * Fix actions schedule update issue (#35767) (#35774) + * Fix circular spin animation direction (#35785) (#35823) + * Fix file extension on gogs.png (#35793) (#35799) + * Add pnpm to Snapcraft (#35778) + +## [1.25.0](https://github.com/go-gitea/gitea/releases/tag/v1.25.0) - 2025-10-30 + +* BREAKING + * Return 201 Created for CreateVariable API responses (#34517) + * Add label 'state' to metric 'gitea_users' (#34326) +* SECURITY + * Upgrade security public key (#34956) + * Also include all security fixes in 1.24.x after 1.25.0-rc0 +* FEATURES + * Stream repo zip/tar.gz/bundle achives by default (#35487) + * Use configurable remote name for git commands (#35172) + * Send email on Workflow Run Success/Failure (#34982) + * Refactor OpenIDConnect to support SSH/FullName sync (#34978) + * Refactor repo contents API and add "contents-ext" API (#34822) + * Add support for 3D/CAD file formats preview (#34794) + * Improve instance wide ssh commit signing (#34341) + * Edit file workflow for creating a fork and proposing changes (#34240) + * Follow file symlinks in the UI to their target (#28835) + * Allow renaming/moving binary/LFS files in the UI (#34350) +* PERFORMANCE + * Improve the performance when detecting the file editable (#34653) +* ENHANCEMENTS + * Enable more markdown paste features in textarea editor (#35494) + * Don't store repo archives on `gitea dump` (#35467) + * Always return the relevant status information, even if no status exists. (#35335) + * Add start time on perf trace because it seems some steps haven't been recorded. (#35282) + * Remove deprecated auth sources (#35272) + * When sorting issues by nearest due date, issues without due date should be sorted ascending (#35267) + * Disable field count validation of CSV viewer (#35228) + * Add `has_code` to repository REST API (#35214) + * Display pull request in merged commit view (#35202) + * Support Basic Authentication for archive downloads (#35087) + * Add hover background to table rows in user and repo admin page (#35072) + * Partially refresh notifications list (#35010) + * Also display "recently pushed branch" alert on PR view (#35001) + * Refactor time tracker UI (#34983) + * Improve CLI commands (#34973) + * Improve project & label color picker and image scroll (#34971) + * Improve NuGet API Parity (#21291) (#34940) + * Support getting last commit message using contents-ext API (#34904) + * Adds title on branch commit counts (#34869) + * Add "Cancel workflow run" button to Actions list page (#34817) + * Improve img lazy loading (#34804) + * Forks repository list page follow other repositories page (#34784) + * Add ff_only parameter to POST /repos/{owner}/{repo}/merge-upstream (#34770) + * Rework delete org and rename org UI (#34762) + * Improve nuget/rubygems package registries (#34741) + * Add repo file tree item link behavior (#34730) + * Add issue delete notifier (#34592) + * Improve Actions list (#34530) + * Add a default tab on repo header when migrating (#34503) + * Add post-installation redirect based on admin account status (#34493) + * Trigger 'unlabeled' event when label is Deleted from PR (#34316) + * Support annotated tags when using create release API (#31840) + * Use lfs label for lfs file rather than a long description (#34363) + * Add "View workflow file" to Actions list page (#34538) + * Move organization's visibility change to danger zone. (#34814) + * Don't block site admin's operation if SECRET_KEY is lost (#35721) + * Make restricted users can access public repositories (#35693) + * The status icon of the Action step is consistent with GitHub (#35618) #35621 +* BUGFIXES + * Update tab title when navigating file tree (#35757) #35772 + * Fix "ref-issue" handling in markup (#35739) #35771 + * Fix webhook to prevent tag events from bypassing branch filters targets (#35567) #35577 + * Fix markup init after issue comment editing (#35536) #35537 + * Fix creating pull request failure when the target branch name is the same as some tag (#35552) #35582 + * Fix auto-expand and auto-scroll for actions logs (#35570) (#35583) #35586 + * Use inputs context when parsing workflows (#35590) #35595 + * Fix diffpatch API endpoint (#35610) #35613 + * Creating push comments before invoke pull request checking (#35647) #35668 + * Fix missing Close when error occurs and abused connection pool (#35658) #35670 + * Fix build (#35674) + * Use LFS object size instead of blob size when viewing a LFS file (#35679) + * Fix workflow run event status while rerunning a failed job (#35689) + * Avoid emoji mismatch and allow to only enable chosen emojis (#35692) + * Refactor legacy code, fix LFS auth bypass, fix symlink bypass (#35708) + * Fix various trivial problems (#35714) + * Fix attachment file size limit in server backend (#35519) + * Honor delete branch on merge repo setting when using merge API (#35488) + * Fix external render, make iframe render work (#35727, #35730) + * Upgrade go mail to 0.7.2 (#35748) + * Revert #18491, fix oauth2 client link account (#35745) + * Fix different behavior in status check pattern matching with double stars (#35474) + * Fix overflow in notifications list (#35446) + * Fix package link setting can only list limited repositories (#35394) + * Extend comment treepath length (#35389) + * Fix font-size in inline code comment preview (#35209) + * Move git config/remote to gitrepo package and add global lock to resolve possible conflict when updating repository git config file (#35151) + * Change some columns from text to longtext and fix column wrong type caused by xorm (#35141) + * Redirect to a presigned URL of HEAD for HEAD requests (#35088) + * Fix git commit committer parsing and add some tests (#35007) + * Fix OCI manifest parser (#34797) + * Refactor FindOrgOptions to use enum instead of bool, fix membership visibility (#34629) + * Fix notification count positioning for variable-width elements (#34597) + * Keeping consistent between UI and API about combined commit status state and fix some bugs (#34562) + * Fix possible panic (#34508) + * Fix autofocus behavior (#34397) + * Fix Actions API (#35204) + * Fix ListWorkflowRuns OpenAPI response model. (#35026) + * Small fix in Pull Requests page (#34612) + * Fix http auth header parsing (#34936) + * Fix modal + form abuse (#34921) + * Fix PR toggle WIP (#34920) + * Fix log fmt (#34810) + * Replace stopwatch toggle with explicit start/stop actions (#34818) + * Fix some package registry problems (#34759) + * Fix RPM package download routing & missing package version count (#34909) + * Fix repo search input height (#34330) + * Fix "The sidebar of the repository file list does not have a fixed height #34298" (#34321) + * Fix minor typos in two files #HSFDPMUW (#34944) + * Fix actions skipped commit status indicator (#34507) + * Fix job status aggregation logic (#35000) + * Fix broken OneDev migration caused by various REST API changes in OneDev 7.8.0 and later (#35216) + * Fix typo in oauth2_full_name_claim_name string (#35199) + * Fix typo in locale_en-US.ini (#35196) +* API + * Exposing TimeEstimate field in the API (#35475) + * UpdateBranch API supports renaming a branch (#35374) + * Add `owner` and `parent` fields clarification to docs (#35023) + * Improve OAuth2 provider (correct Issuer, respect ENABLED) (#34966) + * Add a `login`/`login-name`/`username` disambiguation to affected endpoint parameters and response/request models (#34901) + * Do not mutate incoming options to SearchRepositoryByName (#34553) + * Do not mutate incoming options to RenderUserSearch and SearchUsers (#34544) + * Export repo's manual merge settings (#34502) + * Add date range filtering to commit retrieval endpoints (#34497) + * Add endpoint deleting workflow run (#34337) + * Add workflow_run api + webhook (#33964) +* REFACTOR + * Move updateref and removeref to gitrepo and remove unnecessary open repository (#35511) + * Remove unused param `doer` (#34545) + * Split GetLatestCommitStatus as two functions (#34535) + * Use gitrepo.SetDefaultBranch when set default branch of wiki repository (#33911) + * Refactor editor (#34780) + * Refactor packages (#34777) + * Refactor container package (#34877) + * Refactor "change file" API (#34855) + * Rename pull request GetGitRefName to GetGitHeadRefName to prepare introducing GetGitMergeRefName (#35093) + * Move git command to git/gitcmd (#35483) + * Use db.WithTx/WithTx2 instead of TxContext when possible (#35428) + * Support Node.js 22.6 with type stripping (#35427) + * Migrate tools and configs to typescript, require node.js >= 22.18.0 (#35421) + * Check user and repo for redirects when using git via SSH transport (#35416) + * Remove the duplicated function GetTags (#35375) + * Refactor to use reflect.TypeFor (#35370) + * Deleting branch could delete broken branch which has database record but git branch is missing (#35360) + * Exit with success when already up to date (#35312) + * Split admin config settings templates to make it maintain easier (#35294) + * A small refactor to use context in the service layer (#35179) + * Refactor and update mail templates (#35150) + * Use db.WithTx/WithTx2 instead of TxContext when possible (#35130) + * Align `issue-title-buttons` with `list-header` (#35018) + * Add Notifications section in User Settings (#35008) + * Tweak placement of diff file menu (#34999) + * Refactor mail template and support preview (#34990) + * Rerun job only when run is done (#34970) + * Merge index.js (#34963) + * Refactor "delete-button" to "link-action" (#34962) + * Refactor webhook and fix feishu/lark secret (#34961) + * Exclude devtest.ts from tailwindcss (#34935) + * Refactor head navbar icons (#34922) + * Improve html escape (#34911) + * Improve tags list page (#34898) + * Improve `labels-list` rendering (#34846) + * Remove unused variable HUGO_VERSION (#34840) + * Correct migration tab name (#34826) + * Refactor template helper (#34819) + * Use `shallowRef` instead of `ref` in `.vue` files where possible (#34813) + * Use standalone function to update repository cols (#34811) + * Refactor wiki (#34805) + * Remove unnecessary duplicate code (#34733) + * Refactor embedded assets and drop unnecessary dependencies (#34692) + * Update x/crypto package and make builtin SSH use default parameters (#34667) + * Add `--color-logo`, matching the logo's primary color (#34639) + * Add openssh-keygen to rootless image (#34625) + * Replace update repository function in some places (#34566) + * Change "rejected" to "changes requested" in 3rd party PR review notification (#34481) + * Remove legacy template helper functions (#34426) + * Use run-name and evaluate workflow variables (#34301) + * Move HasWiki to repository service package (#33912) + * Move some functions from package git to gitrepo (#33910) +* TESTING + * Add webhook test for push event (#34442) + * Add a webhook push test for dev branch (#34421) + * Add migrations tests (#34456) (#34498) +* STYLE + * Enforce explanation for necessary nolints and fix bugs (#34883) + * Fix remaining issues after `gopls modernize` formatting (#34771) + * Update gofumpt, add go.mod ignore directive (#35434) + * Enforce nolint scope (#34851) + * Enable gocritic `equalFold` and fix issues (#34952) + * Run `gopls modernize` on codebase (#34751) + * Upgrade `gopls` to v0.19.0, add `make fix` (#34772) +* BUILD + * bump archives&rar dep (#35637) #35638 + * Use github.com/mholt/archives replace github.com/mholt/archiver (#35390) + * Update JS and PY dependencies (#35444) + * Upgrade devcontainer go version to 1.24.6 (#35298) + * Upgrade golang to 1.25.1 and add descriptions for the swagger structs' fields (#35418) + * Update JS and PY deps (#35191) + * Update JS and PY dependencies (#34391) + * Update go tool dependencies (#34845) + * Update `uint8-to-base64`, remove type stub (#34844) + * Switch to `@resvg/resvg-wasm` for `generate-images` (#35415) + * Switch to pnpm (#35274) + * Update chroma to v2.20.0 (#35220) + * Migrate to urfave v3 (#34510) + * Update JS deps, regenerate SVGs (#34640) + * Upgrade dependencies (#35384) + * Bump `@github/relative-time-element` to v4.4.8 (#34413) + * Update JS dependencies (#34951) + * Upgrade orgmode to v1.8.0 (#34721) + * Raise minimum Node.js version to 20, test on 24 (#34713) + * Update JS deps (#34701) + * Upgrade htmx to 2.0.6 (#34887) + * Update eslint to v9 (#35485) + * Update js dependencies (#35429) + * Clean up npm dependencies (#35508) + * Clean up npm dependencies (#35484) + * Bump setup-node to v5 (#35448) +* MISC + * Add gitignore rules to exclude LLM instruction files (#35076) + * Gitignore: Visual Studio settings folder (#34375) + * Improve language in en-US locale strings (#35124) + * Fixed all grammatical errors in locale_en-US.ini (#35053) + * Docs/fix typo and grammar in CONTRIBUTING.md (#35024) + * Improve english grammar and readability in locale_en-US.ini (#35017) + +## [1.24.7](https://github.com/go-gitea/gitea/releases/tag/v1.24.7) - 2025-10-24 + +* SECURITY + * Refactor legacy code (#35708) (#35713) + * Fixing issue #35530: Password Leak in Log Messages (#35584) (#35665) + * Fix a bug missed return (#35655) (#35671) +* BUGFIXES + * Fix inputing review comment will remove reviewer (#35591) (#35664) +* TESTING + * Mock external service in hcaptcha TestCaptcha (#35604) (#35663) + * Fix build (#35669) + +## [1.24.6](https://github.com/go-gitea/gitea/releases/tag/v1.24.6) - 2025-09-10 + +* SECURITY + * Upgrade xz to v0.5.15 (#35385) +* BUGFIXES + * Fix a compare page 404 bug when the pull request disabled (#35441) (#35453) + * Fix bug when issue disabled, pull request number in the commit message cannot be redirected (#35420) (#35442) + * Add author.name field to Swift Package Registry API response (#35410) (#35431) + * Remove usernames when empty in discord webhook (#35412) (#35417) + * Allow foreachref parser to grow its buffer (#35365) (#35376) + * Allow deleting comment with content via API like web did (#35346) (#35354) + * Fix atom/rss mixed error (#35345) (#35347) + * Fix review request webhook bug (#35339) + * Remove duplicate html IDs (#35210) (#35325) + * Fix LFS range size header response (#35277) (#35293) + * Fix GitHub release assets URL validation (#35287) (#35290) + * Fix token lifetime, closes #35230 (#35271) (#35281) + * Fix push commits comments when changing the pull request target branch (#35386) (#35443) + +## [1.24.5](https://github.com/go-gitea/gitea/releases/tag/v1.24.5) - 2025-08-12 + +* BUGFIXES + * Fix a bug where lfs gc never worked. (#35198) (#35255) + * Reload issue when sending webhook to make num comments is right. (#35243) (#35248) + * Fix bug when review pull request commits (#35192) (#35246) +* MISC + * Vertically center "Show Resolved" (#35211) (#35218) + +## [1.24.4](https://github.com/go-gitea/gitea/releases/tag/v1.24.4) - 2025-08-03 + +* BUGFIXES + * Fix various bugs (1.24) (#35186) + * Fix migrate input box bug (#35166) (#35171) + * Only hide dropzone when no files have been uploaded (#35156) (#35167) + * Fix review comment/dimiss comment x reference can be refereced back (#35094) (#35099) + * Fix submodule nil check (#35096) (#35098) +* MISC + * Don't use full-file highlight when there is a git diff textconv (#35114) (#35119) + * Increase gap on latest commit (#35104) (#35113) + +## [1.24.3](https://github.com/go-gitea/gitea/releases/tag/v1.24.3) - 2025-07-15 + +* BUGFIXES + * Fix form property assignment edge case (#35073) (#35078) + * Improve submodule relative path handling (#35056) (#35075) + * Fix incorrect comment diff hunk parsing, fix github asset ID nil panic (#35046) (#35055) + * Fix updating user visibility (#35036) (#35044) + * Support base64-encoded agit push options (#35037) (#35041) + * Make submodule link work with relative path (#35034) (#35038) + * Fix bug when displaying git user avatar in commits list (#35006) + * Fix API response for swagger spec (#35029) + * Start automerge check again after the conflict check and the schedule (#34988) (#35002) + * Fix the response format for actions/workflows (#35009) (#35016) + * Fix repo settings and protocol log problems (#35012) (#35013) + * Fix project images scroll (#34971) (#34972) + * Mark old reviews as stale on agit pr updates (#34933) (#34965) + * Fix git graph page (#34948) (#34949) + * Don't send trigger for a pending review's comment create/update/delete (#34928) (#34939) + * Fix some log and UI problems (#34863) (#34868) + * Fix archive API (#34853) (#34857) + * Ignore force pushes for changed files in a PR review (#34837) (#34843) + * Fix SSH LFS timeout (#34838) (#34842) + * Fix team permissions (#34827) (#34836) + * Fix job status aggregation logic (#34823) (#34835) + * Fix issue filter (#34914) (#34915) + * Fix typo in pull request merge warning message text (#34899) (#34903) + * Support the open-icon of folder (#34168) (#34896) + * Optimize flex layout of release attachment area (#34885) (#34886) + * Fix the issue of abnormal interface when there is no issue-item on the project page (#34791) (#34880) + * Skip updating timestamp when sync branch (#34875) + * Fix required contexts and commit status matching bug (#34815) (#34829) + +## [1.24.2](https://github.com/go-gitea/gitea/releases/tag/v1.24.2) - 2025-06-20 + +* BUGFIXES + * Fix container range bug (#34795) (#34796) + * Upgrade chi to v5.2.2 (#34798) (#34799) +* BUILD + * Bump poetry feature to new url for dev container (#34787) (#34790) + +## [1.24.1](https://github.com/go-gitea/gitea/releases/tag/v1.24.1) - 2025-06-18 + +* ENHANCEMENTS + * Improve alignment of commit status icon on commit page (#34750) (#34757) + * Support title and body query parameters for new PRs (#34537) (#34752) + +* BUGFIXES + * When using rules to delete packages, remove unclean bugs (#34632) (#34761) + * Fix ghost user in feeds when pushing in an actions, it should be gitea-actions (#34703) (#34756) + * Prevent double markdown link brackets when pasting URL (#34745) (#34748) + * Prevent duplicate form submissions when creating forks (#34714) (#34735) + * Fix markdown wrap (#34697) (#34702) + * Fix pull requests API convert panic when head repository is deleted. (#34685) (#34687) + * Fix commit message rendering and some UI problems (#34680) (#34683) + * Fix container range bug (#34725) (#34732) + * Fix incorrect cli default values (#34765) (#34766) + * Fix dropdown filter (#34708) (#34711) + * Hide href attribute of a tag if there is no target_url (#34556) (#34684) + * Fix tag target (#34781) #34783 + +## [1.24.0](https://github.com/go-gitea/gitea/releases/tag/v1.24.0) - 2025-05-26 * BREAKING * Make Gitea always use its internal config, ignore `/etc/gitconfig` (#33076) @@ -374,7 +820,7 @@ been added to each release, please refer to the [blog](https://blog.gitea.com). * Bump x/net (#32896) (#32900) * Only activity tab needs heatmap data loading (#34652) -## [1.23.8](https://github.com/go-gitea/gitea/releases/tag/1.23.8) - 2025-05-11 +## [1.23.8](https://github.com/go-gitea/gitea/releases/tag/v1.23.8) - 2025-05-11 * SECURITY * Fix a bug when uploading file via lfs ssh command (#34408) (#34411) @@ -401,7 +847,7 @@ been added to each release, please refer to the [blog](https://blog.gitea.com). * Bump go version in go.mod (#34160) * remove hardcoded 'code' string in clone_panel.tmpl (#34153) (#34158) -## [1.23.7](https://github.com/go-gitea/gitea/releases/tag/1.23.7) - 2025-04-07 +## [1.23.7](https://github.com/go-gitea/gitea/releases/tag/v1.23.7) - 2025-04-07 * Enhancements * Add a config option to block "expensive" pages for anonymous users (#34024) (#34071) @@ -499,7 +945,7 @@ been added to each release, please refer to the [blog](https://blog.gitea.com). * BUGFIXES * Fix a bug caused by status webhook template #33512 -## [1.23.2](https://github.com/go-gitea/gitea/releases/tag/1.23.2) - 2025-02-04 +## [1.23.2](https://github.com/go-gitea/gitea/releases/tag/v1.23.2) - 2025-02-04 * BREAKING * Add tests for webhook and fix some webhook bugs (#33396) (#33442) @@ -3029,7 +3475,7 @@ Key highlights of this release encompass significant changes categorized under ` * Improve decryption failure message (#24573) (#24575) * Makefile: Use portable !, not GNUish -not, with find(1). (#24565) (#24572) -## [1.19.3](https://github.com/go-gitea/gitea/releases/tag/1.19.3) - 2023-05-03 +## [1.19.3](https://github.com/go-gitea/gitea/releases/tag/v1.19.3) - 2023-05-03 * SECURITY * Use golang 1.20.4 to fix CVE-2023-24539, CVE-2023-24540, and CVE-2023-29400 @@ -3042,7 +3488,7 @@ Key highlights of this release encompass significant changes categorized under ` * Fix incorrect CurrentUser check for docker rootless (#24435) * Getting the tag list does not require being signed in (#24413) (#24416) -## [1.19.2](https://github.com/go-gitea/gitea/releases/tag/1.19.2) - 2023-04-26 +## [1.19.2](https://github.com/go-gitea/gitea/releases/tag/v1.19.2) - 2023-04-26 * SECURITY * Require repo scope for PATs for private repos and basic authentication (#24362) (#24364) @@ -3541,7 +3987,7 @@ Key highlights of this release encompass significant changes categorized under ` * Display attachments of review comment when comment content is blank (#23035) (#23046) * Return empty url for submodule tree entries (#23043) (#23048) -## [1.18.4](https://github.com/go-gitea/gitea/releases/tag/1.18.4) - 2023-02-20 +## [1.18.4](https://github.com/go-gitea/gitea/releases/tag/v1.18.4) - 2023-02-20 * SECURITY * Provide the ability to set password hash algorithm parameters (#22942) (#22943) @@ -3968,7 +4414,7 @@ Key highlights of this release encompass significant changes categorized under ` * Fix the mode of custom dir to 0700 in docker-rootless (#20861) (#20867) * Fix UI mis-align for PR commit history (#20845) (#20859) -## [1.17.1](https://github.com/go-gitea/gitea/releases/tag/1.17.1) - 2022-08-17 +## [1.17.1](https://github.com/go-gitea/gitea/releases/tag/v1.17.1) - 2022-08-17 * SECURITY * Correctly escape within tribute.js (#20831) (#20832) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96e05c578f..52e4aefb6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,7 +80,7 @@ The more detailed and specific you are, the faster we can fix the issue. \ It is really helpful if you can reproduce your problem on a site running on the latest commits, i.e. , as perhaps your problem has already been fixed on a current version. \ Please follow the guidelines described in [How to Report Bugs Effectively](http://www.chiark.greenend.org.uk/~sgtatham/bugs.html) for your report. -Please be kind, remember that Gitea comes at no cost to you, and you're getting free help. +Please be kind—remember that Gitea comes at no cost to you, and you're getting free help. ### Types of issues @@ -166,19 +166,19 @@ Here's how to run the test suite: - code lint -| | | -| :-------------------- | :---------------------------------------------------------------- | +| | | +| :-------------------- | :--------------------------------------------------------------------------- | |``make lint`` | lint everything (not needed if you only change the front- **or** backend) | -|``make lint-frontend`` | lint frontend files | -|``make lint-backend`` | lint backend files | +|``make lint-frontend`` | lint frontend files | +|``make lint-backend`` | lint backend files | - run tests (we suggest running them on Linux) -| Command | Action | | -| :------------------------------------- | :----------------------------------------------- | ------------ | -|``make test[\#SpecificTestName]`` | run unit test(s) | | -|``make test-sqlite[\#SpecificTestName]``| run [integration](tests/integration) test(s) for SQLite |[More details](tests/integration/README.md) | -|``make test-e2e-sqlite[\#SpecificTestName]``| run [end-to-end](tests/e2e) test(s) for SQLite |[More details](tests/e2e/README.md) | +| Command | Action | | +| :------------------------------------------ | :------------------------------------------------------- | ------------------------------------------- | +|``make test[\#SpecificTestName]`` | run unit test(s) | | +|``make test-sqlite[\#SpecificTestName]`` | run [integration](tests/integration) test(s) for SQLite | [More details](tests/integration/README.md) | +|``make test-e2e-sqlite[\#SpecificTestName]`` | run [end-to-end](tests/e2e) test(s) for SQLite | [More details](tests/e2e/README.md) | ## Translation diff --git a/Dockerfile b/Dockerfile index 78a556497a..79f507dbc6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ +# syntax=docker/dockerfile:1 # Build stage -FROM docker.io/library/golang:1.25-alpine3.22 AS build-env +FROM docker.io/library/golang:1.26-alpine3.23 AS build-env -ARG GOPROXY -ENV GOPROXY=${GOPROXY:-direct} +ARG GOPROXY=direct ARG GITEA_VERSION ARG TAGS="sqlite sqlite_unlock_notify" @@ -14,35 +14,32 @@ RUN apk --no-cache add \ build-base \ git \ nodejs \ - npm \ - && npm install -g pnpm@10 \ - && rm -rf /var/cache/apk/* + pnpm -# Setup repo -COPY . ${GOPATH}/src/code.gitea.io/gitea WORKDIR ${GOPATH}/src/code.gitea.io/gitea +# Use COPY but not "mount" because some directories like "node_modules" contain platform-depended contents and these directories need to be ignored. +# ".git" directory will be mounted later separately for getting version data. +# TODO: in the future, maybe we can pre-build the frontend assets on one platform and share them for different platforms, the benefit is that it won't be affected by webpack plugin compatibility problems, then the working directory can be fully mounted and the COPY is not needed. +COPY --exclude=.git/ . . -# Checkout version if set -RUN if [ -n "${GITEA_VERSION}" ]; then git checkout "${GITEA_VERSION}"; fi \ - && make clean-all build +# Build gitea, .git mount is required for version data +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target="/root/.cache/go-build" \ + --mount=type=cache,target=/root/.local/share/pnpm/store \ + --mount=type=bind,source=".git/",target=".git/" \ + make -# Begin env-to-ini build -RUN go build contrib/environment-to-ini/environment-to-ini.go - -# Copy local files COPY docker/root /tmp/local -# Set permissions +# Set permissions for builds that made under windows which strips the executable bit from file RUN chmod 755 /tmp/local/usr/bin/entrypoint \ - /tmp/local/usr/local/bin/gitea \ + /tmp/local/usr/local/bin/* \ /tmp/local/etc/s6/gitea/* \ /tmp/local/etc/s6/openssh/* \ /tmp/local/etc/s6/.s6-svscan/* \ - /go/src/code.gitea.io/gitea/gitea \ - /go/src/code.gitea.io/gitea/environment-to-ini + /go/src/code.gitea.io/gitea/gitea -FROM docker.io/library/alpine:3.22 -LABEL maintainer="maintainers@gitea.io" +FROM docker.io/library/alpine:3.23 AS gitea EXPOSE 22 3000 @@ -57,8 +54,7 @@ RUN apk --no-cache add \ s6 \ sqlite \ su-exec \ - gnupg \ - && rm -rf /var/cache/apk/* + gnupg RUN addgroup \ -S -g 1000 \ @@ -72,14 +68,14 @@ RUN addgroup \ git && \ echo "git:*" | chpasswd -e +COPY --from=build-env /tmp/local / +COPY --from=build-env /go/src/code.gitea.io/gitea/gitea /app/gitea/gitea + ENV USER=git ENV GITEA_CUSTOM=/data/gitea VOLUME ["/data"] +# HINT: HEALTH-CHECK-ENDPOINT: don't use HEALTHCHECK, search this hint keyword for more information ENTRYPOINT ["/usr/bin/entrypoint"] CMD ["/usr/bin/s6-svscan", "/etc/s6"] - -COPY --from=build-env /tmp/local / -COPY --from=build-env /go/src/code.gitea.io/gitea/gitea /app/gitea/gitea -COPY --from=build-env /go/src/code.gitea.io/gitea/environment-to-ini /usr/local/bin/environment-to-ini diff --git a/Dockerfile.rootless b/Dockerfile.rootless index e83c1af33b..fe94774add 100644 --- a/Dockerfile.rootless +++ b/Dockerfile.rootless @@ -1,46 +1,39 @@ +# syntax=docker/dockerfile:1 # Build stage -FROM docker.io/library/golang:1.25-alpine3.22 AS build-env +FROM docker.io/library/golang:1.26-alpine3.23 AS build-env -ARG GOPROXY -ENV GOPROXY=${GOPROXY:-direct} +ARG GOPROXY=direct ARG GITEA_VERSION ARG TAGS="sqlite sqlite_unlock_notify" ENV TAGS="bindata timetzdata $TAGS" ARG CGO_EXTRA_CFLAGS -#Build deps +# Build deps RUN apk --no-cache add \ build-base \ git \ nodejs \ - npm \ - && npm install -g pnpm@10 \ - && rm -rf /var/cache/apk/* + pnpm -# Setup repo -COPY . ${GOPATH}/src/code.gitea.io/gitea WORKDIR ${GOPATH}/src/code.gitea.io/gitea +# See the comments in Dockerfile +COPY --exclude=.git/ . . -# Checkout version if set -RUN if [ -n "${GITEA_VERSION}" ]; then git checkout "${GITEA_VERSION}"; fi \ - && make clean-all build +# Build gitea, .git mount is required for version data +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target="/root/.cache/go-build" \ + --mount=type=cache,target=/root/.local/share/pnpm/store \ + --mount=type=bind,source=".git/",target=".git/" \ + make -# Begin env-to-ini build -RUN go build contrib/environment-to-ini/environment-to-ini.go - -# Copy local files COPY docker/rootless /tmp/local -# Set permissions -RUN chmod 755 /tmp/local/usr/local/bin/docker-entrypoint.sh \ - /tmp/local/usr/local/bin/docker-setup.sh \ - /tmp/local/usr/local/bin/gitea \ - /go/src/code.gitea.io/gitea/gitea \ - /go/src/code.gitea.io/gitea/environment-to-ini +# Set permissions for builds that made under windows which strips the executable bit from file +RUN chmod 755 /tmp/local/usr/local/bin/* \ + /go/src/code.gitea.io/gitea/gitea -FROM docker.io/library/alpine:3.22 -LABEL maintainer="maintainers@gitea.io" +FROM docker.io/library/alpine:3.23 AS gitea-rootless EXPOSE 2222 3000 @@ -52,8 +45,7 @@ RUN apk --no-cache add \ git \ curl \ gnupg \ - openssh-keygen \ - && rm -rf /var/cache/apk/* + openssh-keygen RUN addgroup \ -S -g 1000 \ @@ -71,7 +63,6 @@ RUN chown git:git /var/lib/gitea /etc/gitea COPY --from=build-env /tmp/local / COPY --from=build-env --chown=root:root /go/src/code.gitea.io/gitea/gitea /app/gitea/gitea -COPY --from=build-env --chown=root:root /go/src/code.gitea.io/gitea/environment-to-ini /usr/local/bin/environment-to-ini # git:git USER 1000:1000 @@ -86,5 +77,6 @@ ENV HOME="/var/lib/gitea/git" VOLUME ["/var/lib/gitea", "/etc/gitea"] WORKDIR /var/lib/gitea +# HINT: HEALTH-CHECK-ENDPOINT: don't use HEALTHCHECK, search this hint keyword for more information ENTRYPOINT ["/usr/bin/dumb-init", "--", "/usr/local/bin/docker-entrypoint.sh"] CMD [] diff --git a/Makefile b/Makefile index fc507367e7..93d87fc139 100644 --- a/Makefile +++ b/Makefile @@ -31,17 +31,15 @@ XGO_VERSION := go-1.25.x AIR_PACKAGE ?= github.com/air-verse/air@v1 EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v3/cmd/editorconfig-checker@v3 -GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.1 -GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.4.0 +GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2 +GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.9.0 GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.7.0 -SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@717e3cb29becaaf00e56953556c6d80f8a01b286 +SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.33.1 XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest GO_LICENSES_PACKAGE ?= github.com/google/go-licenses@v1 GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1 -ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1 -GOPLS_PACKAGE ?= golang.org/x/tools/gopls@v0.20.0 -GOPLS_MODERNIZE_PACKAGE ?= golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@v0.20.0 +ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.7.10 DOCKER_IMAGE ?= gitea/gitea DOCKER_TAG ?= latest @@ -102,7 +100,7 @@ GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) # Enable typescript support in Node.js before 22.18 # TODO: Remove this once we can raise the minimum Node.js version to 22.18 (alpine >= 3.23) -NODE_VERSION := $(shell printf "%03d%03d%03d" $(shell node -v 2>/dev/null | cut -c2- | tr '.' ' ')) +NODE_VERSION := $(shell printf "%03d%03d%03d" $(shell node -v 2>/dev/null | cut -c2- | sed 's/-.*//' | tr '.' ' ')) ifeq ($(shell test "$(NODE_VERSION)" -lt "022018000"; echo $$?),0) NODE_VARS := NODE_OPTIONS="--experimental-strip-types" else @@ -163,13 +161,13 @@ TEST_TAGS ?= $(TAGS_SPLIT) sqlite sqlite_unlock_notify TAR_EXCLUDES := .git data indexers queues log node_modules $(EXECUTABLE) $(DIST) $(MAKE_EVIDENCE_DIR) $(AIR_TMP_DIR) $(GO_LICENSE_TMP_DIR) -GO_DIRS := build cmd models modules routers services tests +GO_DIRS := build cmd models modules routers services tests tools WEB_DIRS := web_src/js web_src/css ESLINT_FILES := web_src/js tools *.ts tests/e2e STYLELINT_FILES := web_src/css web_src/js/components/*.vue -SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) templates options/locale/locale_en-US.ini .github $(filter-out CHANGELOG.md, $(wildcard *.go *.md *.yml *.yaml *.toml)) $(filter-out tools/misspellings.csv, $(wildcard tools/*)) -EDITORCONFIG_FILES := templates .github/workflows options/locale/locale_en-US.ini +SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) templates options/locale/locale_en-US.json .github $(filter-out CHANGELOG.md, $(wildcard *.go *.md *.yml *.yaml *.toml)) +EDITORCONFIG_FILES := templates .github/workflows options/locale/locale_en-US.json GO_SOURCES := $(wildcard *.go) GO_SOURCES += $(shell find $(GO_DIRS) -type f -name "*.go") @@ -199,6 +197,10 @@ TEST_MSSQL_DBNAME ?= gitea TEST_MSSQL_USERNAME ?= sa TEST_MSSQL_PASSWORD ?= MwantsaSecurePassword1 +# Include local Makefile +# Makefile.local is listed in .gitignore +sinclude Makefile.local + .PHONY: all all: build @@ -209,16 +211,6 @@ help: Makefile ## print Makefile help information. @printf " \033[36m%-46s\033[0m %s\n" "test[#TestSpecificName]" "run unit test" @printf " \033[36m%-46s\033[0m %s\n" "test-sqlite[#TestSpecificName]" "run integration test for sqlite" -.PHONY: go-check -go-check: - $(eval MIN_GO_VERSION_STR := $(shell grep -Eo '^go\s+[0-9]+\.[0-9]+' go.mod | cut -d' ' -f2)) - $(eval MIN_GO_VERSION := $(shell printf "%03d%03d" $(shell echo '$(MIN_GO_VERSION_STR)' | tr '.' ' '))) - $(eval GO_VERSION := $(shell printf "%03d%03d" $(shell $(GO) version | grep -Eo '[0-9]+\.[0-9]+' | tr '.' ' ');)) - @if [ "$(GO_VERSION)" -lt "$(MIN_GO_VERSION)" ]; then \ - echo "Gitea requires Go $(MIN_GO_VERSION_STR) or greater to build. You can get it at https://go.dev/dl/"; \ - exit 1; \ - fi - .PHONY: git-check git-check: @if git lfs >/dev/null 2>&1 ; then : ; else \ @@ -226,20 +218,6 @@ git-check: exit 1; \ fi -.PHONY: node-check -node-check: - $(eval MIN_NODE_VERSION_STR := $(shell grep -Eo '"node":.*[0-9.]+"' package.json | sed -n 's/.*[^0-9.]\([0-9.]*\)"/\1/p')) - $(eval MIN_NODE_VERSION := $(shell printf "%03d%03d%03d" $(shell echo '$(MIN_NODE_VERSION_STR)' | tr '.' ' '))) - $(eval PNPM_MISSING := $(shell hash pnpm > /dev/null 2>&1 || echo 1)) - @if [ "$(NODE_VERSION)" -lt "$(MIN_NODE_VERSION)" ]; then \ - echo "Gitea requires Node.js $(MIN_NODE_VERSION_STR) or greater to build. You can get it at https://nodejs.org/en/download/"; \ - exit 1; \ - fi - @if [ "$(PNPM_MISSING)" = "1" ]; then \ - echo "Gitea requires pnpm to build. You can install it at https://pnpm.io/installation"; \ - exit 1; \ - fi - .PHONY: clean-all clean-all: clean ## delete backend, frontend and integration files rm -rf $(WEBPACK_DEST_ENTRIES) node_modules @@ -258,7 +236,7 @@ clean: ## delete backend and integration files .PHONY: fmt fmt: ## format the Go and template code - @GOFUMPT_PACKAGE=$(GOFUMPT_PACKAGE) $(GO) run build/code-batch-process.go gitea-fmt -w '{file-list}' + @GOFUMPT_PACKAGE=$(GOFUMPT_PACKAGE) $(GO) run tools/code-batch-process.go gitea-fmt -w '{file-list}' $(eval TEMPLATES := $(shell find templates -type f -name '*.tmpl')) @# strip whitespace after '{{' or '(' and before '}}' or ')' unless there is only @# whitespace before it @@ -276,19 +254,6 @@ fmt-check: fmt exit 1; \ fi -.PHONY: fix -fix: ## apply automated fixes to Go code - $(GO) run $(GOPLS_MODERNIZE_PACKAGE) -fix ./... - -.PHONY: fix-check -fix-check: fix - @diff=$$(git diff --color=always $(GO_SOURCES)); \ - if [ -n "$$diff" ]; then \ - echo "Please run 'make fix' and commit the result:"; \ - printf "%s" "$${diff}"; \ - exit 1; \ - fi - .PHONY: $(TAGS_EVIDENCE) $(TAGS_EVIDENCE): @mkdir -p $(MAKE_EVIDENCE_DIR) @@ -328,7 +293,7 @@ checks: checks-frontend checks-backend ## run various consistency checks checks-frontend: lockfile-check svg-check ## check frontend files .PHONY: checks-backend -checks-backend: tidy-check swagger-check fmt-check fix-check swagger-validate security-check ## check backend files +checks-backend: tidy-check swagger-check fmt-check swagger-validate security-check ## check backend files .PHONY: lint lint: lint-frontend lint-backend lint-spell ## lint everything @@ -343,19 +308,19 @@ lint-frontend: lint-js lint-css ## lint frontend files lint-frontend-fix: lint-js-fix lint-css-fix ## lint frontend files and fix issues .PHONY: lint-backend -lint-backend: lint-go lint-go-gitea-vet lint-go-gopls lint-editorconfig ## lint backend files +lint-backend: lint-go lint-go-gitea-vet lint-editorconfig ## lint backend files .PHONY: lint-backend-fix lint-backend-fix: lint-go-fix lint-go-gitea-vet lint-editorconfig ## lint backend files and fix issues .PHONY: lint-js -lint-js: node_modules ## lint js files - $(NODE_VARS) pnpm exec eslint --color --max-warnings=0 --flag unstable_native_nodejs_ts_config $(ESLINT_FILES) +lint-js: node_modules ## lint js and ts files + $(NODE_VARS) pnpm exec eslint --color --max-warnings=0 $(ESLINT_FILES) $(NODE_VARS) pnpm exec vue-tsc .PHONY: lint-js-fix -lint-js-fix: node_modules ## lint js files and fix issues - $(NODE_VARS) pnpm exec eslint --color --max-warnings=0 --flag unstable_native_nodejs_ts_config $(ESLINT_FILES) --fix +lint-js-fix: node_modules ## lint js and ts files and fix issues + $(NODE_VARS) pnpm exec eslint --color --max-warnings=0 $(ESLINT_FILES) --fix $(NODE_VARS) pnpm exec vue-tsc .PHONY: lint-css @@ -374,13 +339,17 @@ lint-swagger: node_modules ## lint swagger files lint-md: node_modules ## lint markdown files $(NODE_VARS) pnpm exec markdownlint *.md +.PHONY: lint-md-fix +lint-md-fix: node_modules ## lint markdown files and fix issues + $(NODE_VARS) pnpm exec markdownlint --fix *.md + .PHONY: lint-spell lint-spell: ## lint spelling - @go run $(MISSPELL_PACKAGE) -dict tools/misspellings.csv -error $(SPELLCHECK_FILES) + @go run $(MISSPELL_PACKAGE) -dict assets/misspellings.csv -error $(SPELLCHECK_FILES) .PHONY: lint-spell-fix lint-spell-fix: ## lint spelling and fix issues - @go run $(MISSPELL_PACKAGE) -dict tools/misspellings.csv -w $(SPELLCHECK_FILES) + @go run $(MISSPELL_PACKAGE) -dict assets/misspellings.csv -w $(SPELLCHECK_FILES) .PHONY: lint-go lint-go: ## lint go files @@ -400,13 +369,7 @@ lint-go-windows: .PHONY: lint-go-gitea-vet lint-go-gitea-vet: ## lint go files with gitea-vet @echo "Running gitea-vet..." - @GOOS= GOARCH= $(GO) build code.gitea.io/gitea-vet - @$(GO) vet -vettool=gitea-vet ./... - -.PHONY: lint-go-gopls -lint-go-gopls: ## lint go files with gopls - @echo "Running gopls check..." - @GO=$(GO) GOPLS_PACKAGE=$(GOPLS_PACKAGE) tools/lint-go-gopls.sh $(GO_SOURCES) + @$(GO) vet -vettool="$(shell GOOS= GOARCH= go tool -n gitea-vet)" ./... .PHONY: lint-editorconfig lint-editorconfig: @@ -426,17 +389,25 @@ lint-templates: .venv node_modules ## lint template files lint-yaml: .venv ## lint yaml files @uv run --frozen yamllint -s . +.PHONY: lint-json +lint-json: node_modules ## lint json files + $(NODE_VARS) pnpm exec eslint -c eslint.json.config.ts --color --max-warnings=0 + +.PHONY: lint-json-fix +lint-json-fix: node_modules ## lint and fix json files + $(NODE_VARS) pnpm exec eslint -c eslint.json.config.ts --color --max-warnings=0 --fix + .PHONY: watch watch: ## watch everything and continuously rebuild @bash tools/watch.sh .PHONY: watch-frontend -watch-frontend: node-check node_modules ## watch frontend files and continuously rebuild +watch-frontend: node_modules ## watch frontend files and continuously rebuild @rm -rf $(WEBPACK_DEST_ENTRIES) NODE_ENV=development $(NODE_VARS) pnpm exec webpack --watch --progress --disable-interpret .PHONY: watch-backend -watch-backend: go-check ## watch backend files and continuously rebuild +watch-backend: ## watch backend files and continuously rebuild GITEA_RUN_MODE=dev $(GO) run $(AIR_PACKAGE) -c .air.toml .PHONY: test @@ -472,7 +443,7 @@ test\#%: coverage: grep '^\(mode: .*\)\|\(.*:[0-9]\+\.[0-9]\+,[0-9]\+\.[0-9]\+ [0-9]\+ [0-9]\+\)$$' coverage.out > coverage-bodged.out grep '^\(mode: .*\)\|\(.*:[0-9]\+\.[0-9]\+,[0-9]\+\.[0-9]\+ [0-9]\+ [0-9]\+\)$$' integration.coverage.out > integration.coverage-bodged.out - $(GO) run build/gocovmerge.go integration.coverage-bodged.out coverage-bodged.out > coverage.all + $(GO) run tools/gocovmerge.go integration.coverage-bodged.out coverage-bodged.out > coverage.all .PHONY: unit-test-coverage unit-test-coverage: @@ -516,11 +487,11 @@ generate-ini-sqlite: .PHONY: test-sqlite test-sqlite: integrations.sqlite.test generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini ./integrations.sqlite.test + GITEA_TEST_CONF=tests/sqlite.ini ./integrations.sqlite.test .PHONY: test-sqlite\#% test-sqlite\#%: integrations.sqlite.test generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini ./integrations.sqlite.test -test.run $(subst .,/,$*) + GITEA_TEST_CONF=tests/sqlite.ini ./integrations.sqlite.test -test.run $(subst .,/,$*) .PHONY: test-sqlite-migration test-sqlite-migration: migrations.sqlite.test migrations.individual.sqlite.test @@ -537,11 +508,11 @@ generate-ini-mysql: .PHONY: test-mysql test-mysql: integrations.mysql.test generate-ini-mysql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./integrations.mysql.test + GITEA_TEST_CONF=tests/mysql.ini ./integrations.mysql.test .PHONY: test-mysql\#% test-mysql\#%: integrations.mysql.test generate-ini-mysql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./integrations.mysql.test -test.run $(subst .,/,$*) + GITEA_TEST_CONF=tests/mysql.ini ./integrations.mysql.test -test.run $(subst .,/,$*) .PHONY: test-mysql-migration test-mysql-migration: migrations.mysql.test migrations.individual.mysql.test @@ -560,11 +531,11 @@ generate-ini-pgsql: .PHONY: test-pgsql test-pgsql: integrations.pgsql.test generate-ini-pgsql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini ./integrations.pgsql.test + GITEA_TEST_CONF=tests/pgsql.ini ./integrations.pgsql.test .PHONY: test-pgsql\#% test-pgsql\#%: integrations.pgsql.test generate-ini-pgsql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini ./integrations.pgsql.test -test.run $(subst .,/,$*) + GITEA_TEST_CONF=tests/pgsql.ini ./integrations.pgsql.test -test.run $(subst .,/,$*) .PHONY: test-pgsql-migration test-pgsql-migration: migrations.pgsql.test migrations.individual.pgsql.test @@ -581,11 +552,11 @@ generate-ini-mssql: .PHONY: test-mssql test-mssql: integrations.mssql.test generate-ini-mssql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini ./integrations.mssql.test + GITEA_TEST_CONF=tests/mssql.ini ./integrations.mssql.test .PHONY: test-mssql\#% test-mssql\#%: integrations.mssql.test generate-ini-mssql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini ./integrations.mssql.test -test.run $(subst .,/,$*) + GITEA_TEST_CONF=tests/mssql.ini ./integrations.mssql.test -test.run $(subst .,/,$*) .PHONY: test-mssql-migration test-mssql-migration: migrations.mssql.test migrations.individual.mssql.test @@ -604,59 +575,59 @@ test-e2e: test-e2e-sqlite .PHONY: test-e2e-sqlite test-e2e-sqlite: playwright e2e.sqlite.test generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini ./e2e.sqlite.test + GITEA_TEST_CONF=tests/sqlite.ini ./e2e.sqlite.test .PHONY: test-e2e-sqlite\#% test-e2e-sqlite\#%: playwright e2e.sqlite.test generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini ./e2e.sqlite.test -test.run TestE2e/$* + GITEA_TEST_CONF=tests/sqlite.ini ./e2e.sqlite.test -test.run TestE2e/$* .PHONY: test-e2e-mysql test-e2e-mysql: playwright e2e.mysql.test generate-ini-mysql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./e2e.mysql.test + GITEA_TEST_CONF=tests/mysql.ini ./e2e.mysql.test .PHONY: test-e2e-mysql\#% test-e2e-mysql\#%: playwright e2e.mysql.test generate-ini-mysql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./e2e.mysql.test -test.run TestE2e/$* + GITEA_TEST_CONF=tests/mysql.ini ./e2e.mysql.test -test.run TestE2e/$* .PHONY: test-e2e-pgsql test-e2e-pgsql: playwright e2e.pgsql.test generate-ini-pgsql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini ./e2e.pgsql.test + GITEA_TEST_CONF=tests/pgsql.ini ./e2e.pgsql.test .PHONY: test-e2e-pgsql\#% test-e2e-pgsql\#%: playwright e2e.pgsql.test generate-ini-pgsql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini ./e2e.pgsql.test -test.run TestE2e/$* + GITEA_TEST_CONF=tests/pgsql.ini ./e2e.pgsql.test -test.run TestE2e/$* .PHONY: test-e2e-mssql test-e2e-mssql: playwright e2e.mssql.test generate-ini-mssql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini ./e2e.mssql.test + GITEA_TEST_CONF=tests/mssql.ini ./e2e.mssql.test .PHONY: test-e2e-mssql\#% test-e2e-mssql\#%: playwright e2e.mssql.test generate-ini-mssql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini ./e2e.mssql.test -test.run TestE2e/$* + GITEA_TEST_CONF=tests/mssql.ini ./e2e.mssql.test -test.run TestE2e/$* .PHONY: bench-sqlite bench-sqlite: integrations.sqlite.test generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini ./integrations.sqlite.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . + GITEA_TEST_CONF=tests/sqlite.ini ./integrations.sqlite.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . .PHONY: bench-mysql bench-mysql: integrations.mysql.test generate-ini-mysql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./integrations.mysql.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . + GITEA_TEST_CONF=tests/mysql.ini ./integrations.mysql.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . .PHONY: bench-mssql bench-mssql: integrations.mssql.test generate-ini-mssql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini ./integrations.mssql.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . + GITEA_TEST_CONF=tests/mssql.ini ./integrations.mssql.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . .PHONY: bench-pgsql bench-pgsql: integrations.pgsql.test generate-ini-pgsql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini ./integrations.pgsql.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . + GITEA_TEST_CONF=tests/pgsql.ini ./integrations.pgsql.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . .PHONY: integration-test-coverage integration-test-coverage: integrations.cover.test generate-ini-mysql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./integrations.cover.test -test.coverprofile=integration.coverage.out + GITEA_TEST_CONF=tests/mysql.ini ./integrations.cover.test -test.coverprofile=integration.coverage.out .PHONY: integration-test-coverage-sqlite integration-test-coverage-sqlite: integrations.cover.sqlite.test generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini ./integrations.cover.sqlite.test -test.coverprofile=integration.coverage.out + GITEA_TEST_CONF=tests/sqlite.ini ./integrations.cover.sqlite.test -test.coverprofile=integration.coverage.out integrations.mysql.test: git-check $(GO_SOURCES) $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration -o integrations.mysql.test @@ -679,54 +650,54 @@ integrations.cover.sqlite.test: git-check $(GO_SOURCES) .PHONY: migrations.mysql.test migrations.mysql.test: $(GO_SOURCES) generate-ini-mysql $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration/migration-test -o migrations.mysql.test - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./migrations.mysql.test + GITEA_TEST_CONF=tests/mysql.ini ./migrations.mysql.test .PHONY: migrations.pgsql.test migrations.pgsql.test: $(GO_SOURCES) generate-ini-pgsql $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration/migration-test -o migrations.pgsql.test - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini ./migrations.pgsql.test + GITEA_TEST_CONF=tests/pgsql.ini ./migrations.pgsql.test .PHONY: migrations.mssql.test migrations.mssql.test: $(GO_SOURCES) generate-ini-mssql $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration/migration-test -o migrations.mssql.test - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini ./migrations.mssql.test + GITEA_TEST_CONF=tests/mssql.ini ./migrations.mssql.test .PHONY: migrations.sqlite.test migrations.sqlite.test: $(GO_SOURCES) generate-ini-sqlite $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration/migration-test -o migrations.sqlite.test -tags '$(TEST_TAGS)' - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini ./migrations.sqlite.test + GITEA_TEST_CONF=tests/sqlite.ini ./migrations.sqlite.test .PHONY: migrations.individual.mysql.test migrations.individual.mysql.test: $(GO_SOURCES) - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) + GITEA_TEST_CONF=tests/mysql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) .PHONY: migrations.individual.sqlite.test\#% migrations.individual.sqlite.test\#%: $(GO_SOURCES) generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* + GITEA_TEST_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* .PHONY: migrations.individual.pgsql.test migrations.individual.pgsql.test: $(GO_SOURCES) - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) + GITEA_TEST_CONF=tests/pgsql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) .PHONY: migrations.individual.pgsql.test\#% migrations.individual.pgsql.test\#%: $(GO_SOURCES) generate-ini-pgsql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* + GITEA_TEST_CONF=tests/pgsql.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* .PHONY: migrations.individual.mssql.test migrations.individual.mssql.test: $(GO_SOURCES) generate-ini-mssql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) + GITEA_TEST_CONF=tests/mssql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) .PHONY: migrations.individual.mssql.test\#% migrations.individual.mssql.test\#%: $(GO_SOURCES) generate-ini-mssql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* + GITEA_TEST_CONF=tests/mssql.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* .PHONY: migrations.individual.sqlite.test migrations.individual.sqlite.test: $(GO_SOURCES) generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) + GITEA_TEST_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) .PHONY: migrations.individual.sqlite.test\#% migrations.individual.sqlite.test\#%: $(GO_SOURCES) generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* + GITEA_TEST_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* e2e.mysql.test: $(GO_SOURCES) $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/e2e -o e2e.mysql.test @@ -754,7 +725,7 @@ build: frontend backend ## build everything frontend: $(WEBPACK_DEST) ## build frontend files .PHONY: backend -backend: go-check generate-backend $(EXECUTABLE) ## build backend files +backend: generate-backend $(EXECUTABLE) ## build backend files # We generate the backend before the frontend in case we in future we want to generate things in the frontend from generated files in backend .PHONY: generate @@ -774,7 +745,7 @@ security-check: $(EXECUTABLE): $(GO_SOURCES) $(TAGS_PREREQ) ifneq ($(and $(STATIC),$(findstring pam,$(TAGS))),) - $(error pam support set via TAGS doesn't support static builds) + $(error pam support set via TAGS does not support static builds) endif CGO_ENABLED="$(CGO_ENABLED)" CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@ @@ -851,8 +822,6 @@ deps-tools: ## install tool dependencies $(GO) install $(GO_LICENSES_PACKAGE) & \ $(GO) install $(GOVULNCHECK_PACKAGE) & \ $(GO) install $(ACTIONLINT_PACKAGE) & \ - $(GO) install $(GOPLS_PACKAGE) & \ - $(GO) install $(GOPLS_MODERNIZE_PACKAGE) & \ wait node_modules: pnpm-lock.yaml @@ -867,7 +836,7 @@ node_modules: pnpm-lock.yaml update: update-js update-py ## update js and py dependencies .PHONY: update-js -update-js: node-check | node_modules ## update js dependencies +update-js: node_modules ## update js dependencies $(NODE_VARS) pnpm exec updates -u -f package.json rm -rf node_modules pnpm-lock.yaml $(NODE_VARS) pnpm install @@ -876,7 +845,7 @@ update-js: node-check | node_modules ## update js dependencies @touch node_modules .PHONY: update-py -update-py: node-check | node_modules ## update py dependencies +update-py: node_modules ## update py dependencies $(NODE_VARS) pnpm exec updates -u -f pyproject.toml rm -rf .venv uv.lock uv sync @@ -886,14 +855,14 @@ update-py: node-check | node_modules ## update py dependencies webpack: $(WEBPACK_DEST) ## build webpack files $(WEBPACK_DEST): $(WEBPACK_SOURCES) $(WEBPACK_CONFIGS) pnpm-lock.yaml - @$(MAKE) -s node-check node_modules + @$(MAKE) -s node_modules @rm -rf $(WEBPACK_DEST_ENTRIES) @echo "Running webpack..." @BROWSERSLIST_IGNORE_OLD_DATA=true $(NODE_VARS) pnpm exec webpack --disable-interpret @touch $(WEBPACK_DEST) .PHONY: svg -svg: node-check | node_modules ## build svg files +svg: node_modules ## build svg files rm -rf $(SVG_DEST_DIR) node tools/generate-svg.ts @@ -918,16 +887,6 @@ lockfile-check: exit 1; \ fi -.PHONY: update-translations -update-translations: - mkdir -p ./translations - cd ./translations && curl -L https://crowdin.com/download/project/gitea.zip > gitea.zip && unzip gitea.zip - rm ./translations/gitea.zip - $(SED_INPLACE) -e 's/="/=/g' -e 's/"$$//g' ./translations/*.ini - $(SED_INPLACE) -e 's/\\"/"/g' ./translations/*.ini - mv ./translations/*.ini ./options/locale/ - rmdir ./translations - .PHONY: generate-gitignore generate-gitignore: ## update gitignore files $(GO) run build/generate-gitignores.go diff --git a/README.zh-cn.md b/README.zh-cn.md index f34b25b945..8d9531e8e4 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -46,7 +46,7 @@ `build` 目标分为两个子目标: - `make backend` 需要 [Go Stable](https://go.dev/dl/),所需版本在 [go.mod](/go.mod) 中定义。 -- `make frontend` 需要 [Node.js LTS](https://nodejs.org/en/download/) 或更高版本。 +- `make frontend` 需要 [Node.js LTS](https://nodejs.org/en/download/) 或更高版本以及 [pnpm](https://pnpm.io/installation)。 需要互联网连接来下载 go 和 npm 模块。从包含预构建前端文件的官方源代码压缩包构建时,不会触发 `frontend` 目标,因此可以在没有 Node.js 的情况下构建。 diff --git a/README.zh-tw.md b/README.zh-tw.md index 9de3f85dd5..875d31e28a 100644 --- a/README.zh-tw.md +++ b/README.zh-tw.md @@ -46,7 +46,7 @@ `build` 目標分為兩個子目標: - `make backend` 需要 [Go Stable](https://go.dev/dl/),所需版本在 [go.mod](/go.mod) 中定義。 -- `make frontend` 需要 [Node.js LTS](https://nodejs.org/en/download/) 或更高版本。 +- `make frontend` 需要 [Node.js LTS](https://nodejs.org/en/download/) 或更高版本以及 [pnpm](https://pnpm.io/installation)。 需要互聯網連接來下載 go 和 npm 模塊。從包含預構建前端文件的官方源代碼壓縮包構建時,不會觸發 `frontend` 目標,因此可以在沒有 Node.js 的情況下構建。 diff --git a/assets/emoji.json b/assets/emoji.json index 28244caa65..2d6329e44f 100644 --- a/assets/emoji.json +++ b/assets/emoji.json @@ -1 +1,11483 @@ -[{"emoji":"👍","aliases":["+1","thumbsup"]},{"emoji":"👎","aliases":["-1","thumbsdown"]},{"emoji":"💯","aliases":["100"]},{"emoji":"🔢","aliases":["1234"]},{"emoji":"🥇","aliases":["1st_place_medal"]},{"emoji":"🥈","aliases":["2nd_place_medal"]},{"emoji":"🥉","aliases":["3rd_place_medal"]},{"emoji":"🎱","aliases":["8ball"]},{"emoji":"🅰️","aliases":["a"]},{"emoji":"🆎","aliases":["ab"]},{"emoji":"🧮","aliases":["abacus"]},{"emoji":"🔤","aliases":["abc"]},{"emoji":"🔡","aliases":["abcd"]},{"emoji":"🉑","aliases":["accept"]},{"emoji":"🪗","aliases":["accordion"]},{"emoji":"🩹","aliases":["adhesive_bandage"]},{"emoji":"🧑","aliases":["adult"]},{"emoji":"🚡","aliases":["aerial_tramway"]},{"emoji":"🇦🇫","aliases":["afghanistan"]},{"emoji":"✈️","aliases":["airplane"]},{"emoji":"🇦🇽","aliases":["aland_islands"]},{"emoji":"⏰","aliases":["alarm_clock"]},{"emoji":"🇦🇱","aliases":["albania"]},{"emoji":"⚗️","aliases":["alembic"]},{"emoji":"🇩🇿","aliases":["algeria"]},{"emoji":"👽","aliases":["alien"]},{"emoji":"🚑","aliases":["ambulance"]},{"emoji":"🇦🇸","aliases":["american_samoa"]},{"emoji":"🏺","aliases":["amphora"]},{"emoji":"🫀","aliases":["anatomical_heart"]},{"emoji":"⚓","aliases":["anchor"]},{"emoji":"🇦🇩","aliases":["andorra"]},{"emoji":"👼","aliases":["angel"]},{"emoji":"💢","aliases":["anger"]},{"emoji":"🇦🇴","aliases":["angola"]},{"emoji":"😠","aliases":["angry"]},{"emoji":"🇦🇮","aliases":["anguilla"]},{"emoji":"😧","aliases":["anguished"]},{"emoji":"🐜","aliases":["ant"]},{"emoji":"🇦🇶","aliases":["antarctica"]},{"emoji":"🇦🇬","aliases":["antigua_barbuda"]},{"emoji":"🍎","aliases":["apple"]},{"emoji":"♒","aliases":["aquarius"]},{"emoji":"🇦🇷","aliases":["argentina"]},{"emoji":"♈","aliases":["aries"]},{"emoji":"🇦🇲","aliases":["armenia"]},{"emoji":"◀️","aliases":["arrow_backward"]},{"emoji":"⏬","aliases":["arrow_double_down"]},{"emoji":"⏫","aliases":["arrow_double_up"]},{"emoji":"⬇️","aliases":["arrow_down"]},{"emoji":"🔽","aliases":["arrow_down_small"]},{"emoji":"▶️","aliases":["arrow_forward"]},{"emoji":"⤵️","aliases":["arrow_heading_down"]},{"emoji":"⤴️","aliases":["arrow_heading_up"]},{"emoji":"⬅️","aliases":["arrow_left"]},{"emoji":"↙️","aliases":["arrow_lower_left"]},{"emoji":"↘️","aliases":["arrow_lower_right"]},{"emoji":"➡️","aliases":["arrow_right"]},{"emoji":"↪️","aliases":["arrow_right_hook"]},{"emoji":"⬆️","aliases":["arrow_up"]},{"emoji":"↕️","aliases":["arrow_up_down"]},{"emoji":"🔼","aliases":["arrow_up_small"]},{"emoji":"↖️","aliases":["arrow_upper_left"]},{"emoji":"↗️","aliases":["arrow_upper_right"]},{"emoji":"🔃","aliases":["arrows_clockwise"]},{"emoji":"🔄","aliases":["arrows_counterclockwise"]},{"emoji":"🎨","aliases":["art"]},{"emoji":"🚛","aliases":["articulated_lorry"]},{"emoji":"🛰️","aliases":["artificial_satellite"]},{"emoji":"🧑‍🎨","aliases":["artist"]},{"emoji":"🇦🇼","aliases":["aruba"]},{"emoji":"🇦🇨","aliases":["ascension_island"]},{"emoji":"*️⃣","aliases":["asterisk"]},{"emoji":"😲","aliases":["astonished"]},{"emoji":"🧑‍🚀","aliases":["astronaut"]},{"emoji":"👟","aliases":["athletic_shoe"]},{"emoji":"🏧","aliases":["atm"]},{"emoji":"⚛️","aliases":["atom_symbol"]},{"emoji":"🇦🇺","aliases":["australia"]},{"emoji":"🇦🇹","aliases":["austria"]},{"emoji":"🛺","aliases":["auto_rickshaw"]},{"emoji":"🥑","aliases":["avocado"]},{"emoji":"🪓","aliases":["axe"]},{"emoji":"🇦🇿","aliases":["azerbaijan"]},{"emoji":"🅱️","aliases":["b"]},{"emoji":"👶","aliases":["baby"]},{"emoji":"🍼","aliases":["baby_bottle"]},{"emoji":"🐤","aliases":["baby_chick"]},{"emoji":"🚼","aliases":["baby_symbol"]},{"emoji":"🔙","aliases":["back"]},{"emoji":"🥓","aliases":["bacon"]},{"emoji":"🦡","aliases":["badger"]},{"emoji":"🏸","aliases":["badminton"]},{"emoji":"🥯","aliases":["bagel"]},{"emoji":"🛄","aliases":["baggage_claim"]},{"emoji":"🥖","aliases":["baguette_bread"]},{"emoji":"🇧🇸","aliases":["bahamas"]},{"emoji":"🇧🇭","aliases":["bahrain"]},{"emoji":"⚖️","aliases":["balance_scale"]},{"emoji":"👨‍🦲","aliases":["bald_man"]},{"emoji":"👩‍🦲","aliases":["bald_woman"]},{"emoji":"🩰","aliases":["ballet_shoes"]},{"emoji":"🎈","aliases":["balloon"]},{"emoji":"🗳️","aliases":["ballot_box"]},{"emoji":"☑️","aliases":["ballot_box_with_check"]},{"emoji":"🎍","aliases":["bamboo"]},{"emoji":"🍌","aliases":["banana"]},{"emoji":"‼️","aliases":["bangbang"]},{"emoji":"🇧🇩","aliases":["bangladesh"]},{"emoji":"🪕","aliases":["banjo"]},{"emoji":"🏦","aliases":["bank"]},{"emoji":"📊","aliases":["bar_chart"]},{"emoji":"🇧🇧","aliases":["barbados"]},{"emoji":"💈","aliases":["barber"]},{"emoji":"⚾","aliases":["baseball"]},{"emoji":"🧺","aliases":["basket"]},{"emoji":"🏀","aliases":["basketball"]},{"emoji":"🦇","aliases":["bat"]},{"emoji":"🛀","aliases":["bath"]},{"emoji":"🛁","aliases":["bathtub"]},{"emoji":"🔋","aliases":["battery"]},{"emoji":"🏖️","aliases":["beach_umbrella"]},{"emoji":"🫘","aliases":["beans"]},{"emoji":"🐻","aliases":["bear"]},{"emoji":"🧔","aliases":["bearded_person"]},{"emoji":"🦫","aliases":["beaver"]},{"emoji":"🛏️","aliases":["bed"]},{"emoji":"🐝","aliases":["bee","honeybee"]},{"emoji":"🍺","aliases":["beer"]},{"emoji":"🍻","aliases":["beers"]},{"emoji":"🪲","aliases":["beetle"]},{"emoji":"🔰","aliases":["beginner"]},{"emoji":"🇧🇾","aliases":["belarus"]},{"emoji":"🇧🇪","aliases":["belgium"]},{"emoji":"🇧🇿","aliases":["belize"]},{"emoji":"🔔","aliases":["bell"]},{"emoji":"🫑","aliases":["bell_pepper"]},{"emoji":"🛎️","aliases":["bellhop_bell"]},{"emoji":"🇧🇯","aliases":["benin"]},{"emoji":"🍱","aliases":["bento"]},{"emoji":"🇧🇲","aliases":["bermuda"]},{"emoji":"🧃","aliases":["beverage_box"]},{"emoji":"🇧🇹","aliases":["bhutan"]},{"emoji":"🚴","aliases":["bicyclist"]},{"emoji":"🚲","aliases":["bike"]},{"emoji":"🚴‍♂️","aliases":["biking_man"]},{"emoji":"🚴‍♀️","aliases":["biking_woman"]},{"emoji":"👙","aliases":["bikini"]},{"emoji":"🧢","aliases":["billed_cap"]},{"emoji":"☣️","aliases":["biohazard"]},{"emoji":"🐦","aliases":["bird"]},{"emoji":"🎂","aliases":["birthday"]},{"emoji":"🦬","aliases":["bison"]},{"emoji":"🫦","aliases":["biting_lip"]},{"emoji":"🐦‍⬛","aliases":["black_bird"]},{"emoji":"🐈‍⬛","aliases":["black_cat"]},{"emoji":"⚫","aliases":["black_circle"]},{"emoji":"🏴","aliases":["black_flag"]},{"emoji":"🖤","aliases":["black_heart"]},{"emoji":"🃏","aliases":["black_joker"]},{"emoji":"⬛","aliases":["black_large_square"]},{"emoji":"◾","aliases":["black_medium_small_square"]},{"emoji":"◼️","aliases":["black_medium_square"]},{"emoji":"✒️","aliases":["black_nib"]},{"emoji":"▪️","aliases":["black_small_square"]},{"emoji":"🔲","aliases":["black_square_button"]},{"emoji":"👱‍♂️","aliases":["blond_haired_man"]},{"emoji":"👱","aliases":["blond_haired_person"]},{"emoji":"👱‍♀️","aliases":["blond_haired_woman","blonde_woman"]},{"emoji":"🌼","aliases":["blossom"]},{"emoji":"🐡","aliases":["blowfish"]},{"emoji":"📘","aliases":["blue_book"]},{"emoji":"🚙","aliases":["blue_car"]},{"emoji":"💙","aliases":["blue_heart"]},{"emoji":"🟦","aliases":["blue_square"]},{"emoji":"🫐","aliases":["blueberries"]},{"emoji":"😊","aliases":["blush"]},{"emoji":"🐗","aliases":["boar"]},{"emoji":"⛵","aliases":["boat","sailboat"]},{"emoji":"🇧🇴","aliases":["bolivia"]},{"emoji":"💣","aliases":["bomb"]},{"emoji":"🦴","aliases":["bone"]},{"emoji":"📖","aliases":["book","open_book"]},{"emoji":"🔖","aliases":["bookmark"]},{"emoji":"📑","aliases":["bookmark_tabs"]},{"emoji":"📚","aliases":["books"]},{"emoji":"💥","aliases":["boom","collision"]},{"emoji":"🪃","aliases":["boomerang"]},{"emoji":"👢","aliases":["boot"]},{"emoji":"🇧🇦","aliases":["bosnia_herzegovina"]},{"emoji":"🇧🇼","aliases":["botswana"]},{"emoji":"⛹️‍♂️","aliases":["bouncing_ball_man","basketball_man"]},{"emoji":"⛹️","aliases":["bouncing_ball_person"]},{"emoji":"⛹️‍♀️","aliases":["bouncing_ball_woman","basketball_woman"]},{"emoji":"💐","aliases":["bouquet"]},{"emoji":"🇧🇻","aliases":["bouvet_island"]},{"emoji":"🙇","aliases":["bow"]},{"emoji":"🏹","aliases":["bow_and_arrow"]},{"emoji":"🙇‍♂️","aliases":["bowing_man"]},{"emoji":"🙇‍♀️","aliases":["bowing_woman"]},{"emoji":"🥣","aliases":["bowl_with_spoon"]},{"emoji":"🎳","aliases":["bowling"]},{"emoji":"🥊","aliases":["boxing_glove"]},{"emoji":"👦","aliases":["boy"]},{"emoji":"🧠","aliases":["brain"]},{"emoji":"🇧🇷","aliases":["brazil"]},{"emoji":"🍞","aliases":["bread"]},{"emoji":"🤱","aliases":["breast_feeding"]},{"emoji":"🧱","aliases":["bricks"]},{"emoji":"🌉","aliases":["bridge_at_night"]},{"emoji":"💼","aliases":["briefcase"]},{"emoji":"🇮🇴","aliases":["british_indian_ocean_territory"]},{"emoji":"🇻🇬","aliases":["british_virgin_islands"]},{"emoji":"🥦","aliases":["broccoli"]},{"emoji":"💔","aliases":["broken_heart"]},{"emoji":"🧹","aliases":["broom"]},{"emoji":"🟤","aliases":["brown_circle"]},{"emoji":"🤎","aliases":["brown_heart"]},{"emoji":"🟫","aliases":["brown_square"]},{"emoji":"🇧🇳","aliases":["brunei"]},{"emoji":"🧋","aliases":["bubble_tea"]},{"emoji":"🫧","aliases":["bubbles"]},{"emoji":"🪣","aliases":["bucket"]},{"emoji":"🐛","aliases":["bug"]},{"emoji":"🏗️","aliases":["building_construction"]},{"emoji":"💡","aliases":["bulb"]},{"emoji":"🇧🇬","aliases":["bulgaria"]},{"emoji":"🚅","aliases":["bullettrain_front"]},{"emoji":"🚄","aliases":["bullettrain_side"]},{"emoji":"🇧🇫","aliases":["burkina_faso"]},{"emoji":"🌯","aliases":["burrito"]},{"emoji":"🇧🇮","aliases":["burundi"]},{"emoji":"🚌","aliases":["bus"]},{"emoji":"🕴️","aliases":["business_suit_levitating"]},{"emoji":"🚏","aliases":["busstop"]},{"emoji":"👤","aliases":["bust_in_silhouette"]},{"emoji":"👥","aliases":["busts_in_silhouette"]},{"emoji":"🧈","aliases":["butter"]},{"emoji":"🦋","aliases":["butterfly"]},{"emoji":"🌵","aliases":["cactus"]},{"emoji":"🍰","aliases":["cake"]},{"emoji":"📆","aliases":["calendar"]},{"emoji":"🤙","aliases":["call_me_hand"]},{"emoji":"📲","aliases":["calling"]},{"emoji":"🇰🇭","aliases":["cambodia"]},{"emoji":"🐫","aliases":["camel"]},{"emoji":"📷","aliases":["camera"]},{"emoji":"📸","aliases":["camera_flash"]},{"emoji":"🇨🇲","aliases":["cameroon"]},{"emoji":"🏕️","aliases":["camping"]},{"emoji":"🇨🇦","aliases":["canada"]},{"emoji":"🇮🇨","aliases":["canary_islands"]},{"emoji":"♋","aliases":["cancer"]},{"emoji":"🕯️","aliases":["candle"]},{"emoji":"🍬","aliases":["candy"]},{"emoji":"🥫","aliases":["canned_food"]},{"emoji":"🛶","aliases":["canoe"]},{"emoji":"🇨🇻","aliases":["cape_verde"]},{"emoji":"🔠","aliases":["capital_abcd"]},{"emoji":"♑","aliases":["capricorn"]},{"emoji":"🚗","aliases":["car","red_car"]},{"emoji":"🗃️","aliases":["card_file_box"]},{"emoji":"📇","aliases":["card_index"]},{"emoji":"🗂️","aliases":["card_index_dividers"]},{"emoji":"🇧🇶","aliases":["caribbean_netherlands"]},{"emoji":"🎠","aliases":["carousel_horse"]},{"emoji":"🪚","aliases":["carpentry_saw"]},{"emoji":"🥕","aliases":["carrot"]},{"emoji":"🤸","aliases":["cartwheeling"]},{"emoji":"🐱","aliases":["cat"]},{"emoji":"🐈","aliases":["cat2"]},{"emoji":"🇰🇾","aliases":["cayman_islands"]},{"emoji":"💿","aliases":["cd"]},{"emoji":"🇨🇫","aliases":["central_african_republic"]},{"emoji":"🇪🇦","aliases":["ceuta_melilla"]},{"emoji":"🇹🇩","aliases":["chad"]},{"emoji":"⛓️","aliases":["chains"]},{"emoji":"🪑","aliases":["chair"]},{"emoji":"🍾","aliases":["champagne"]},{"emoji":"💹","aliases":["chart"]},{"emoji":"📉","aliases":["chart_with_downwards_trend"]},{"emoji":"📈","aliases":["chart_with_upwards_trend"]},{"emoji":"🏁","aliases":["checkered_flag"]},{"emoji":"🧀","aliases":["cheese"]},{"emoji":"🍒","aliases":["cherries"]},{"emoji":"🌸","aliases":["cherry_blossom"]},{"emoji":"♟️","aliases":["chess_pawn"]},{"emoji":"🌰","aliases":["chestnut"]},{"emoji":"🐔","aliases":["chicken"]},{"emoji":"🧒","aliases":["child"]},{"emoji":"🚸","aliases":["children_crossing"]},{"emoji":"🇨🇱","aliases":["chile"]},{"emoji":"🐿️","aliases":["chipmunk"]},{"emoji":"🍫","aliases":["chocolate_bar"]},{"emoji":"🥢","aliases":["chopsticks"]},{"emoji":"🇨🇽","aliases":["christmas_island"]},{"emoji":"🎄","aliases":["christmas_tree"]},{"emoji":"⛪","aliases":["church"]},{"emoji":"🎦","aliases":["cinema"]},{"emoji":"🎪","aliases":["circus_tent"]},{"emoji":"🌇","aliases":["city_sunrise"]},{"emoji":"🌆","aliases":["city_sunset"]},{"emoji":"🏙️","aliases":["cityscape"]},{"emoji":"🆑","aliases":["cl"]},{"emoji":"🗜️","aliases":["clamp"]},{"emoji":"👏","aliases":["clap"]},{"emoji":"🎬","aliases":["clapper"]},{"emoji":"🏛️","aliases":["classical_building"]},{"emoji":"🧗","aliases":["climbing"]},{"emoji":"🧗‍♂️","aliases":["climbing_man"]},{"emoji":"🧗‍♀️","aliases":["climbing_woman"]},{"emoji":"🥂","aliases":["clinking_glasses"]},{"emoji":"📋","aliases":["clipboard"]},{"emoji":"🇨🇵","aliases":["clipperton_island"]},{"emoji":"🕐","aliases":["clock1"]},{"emoji":"🕙","aliases":["clock10"]},{"emoji":"🕥","aliases":["clock1030"]},{"emoji":"🕚","aliases":["clock11"]},{"emoji":"🕦","aliases":["clock1130"]},{"emoji":"🕛","aliases":["clock12"]},{"emoji":"🕧","aliases":["clock1230"]},{"emoji":"🕜","aliases":["clock130"]},{"emoji":"🕑","aliases":["clock2"]},{"emoji":"🕝","aliases":["clock230"]},{"emoji":"🕒","aliases":["clock3"]},{"emoji":"🕞","aliases":["clock330"]},{"emoji":"🕓","aliases":["clock4"]},{"emoji":"🕟","aliases":["clock430"]},{"emoji":"🕔","aliases":["clock5"]},{"emoji":"🕠","aliases":["clock530"]},{"emoji":"🕕","aliases":["clock6"]},{"emoji":"🕡","aliases":["clock630"]},{"emoji":"🕖","aliases":["clock7"]},{"emoji":"🕢","aliases":["clock730"]},{"emoji":"🕗","aliases":["clock8"]},{"emoji":"🕣","aliases":["clock830"]},{"emoji":"🕘","aliases":["clock9"]},{"emoji":"🕤","aliases":["clock930"]},{"emoji":"📕","aliases":["closed_book"]},{"emoji":"🔐","aliases":["closed_lock_with_key"]},{"emoji":"🌂","aliases":["closed_umbrella"]},{"emoji":"☁️","aliases":["cloud"]},{"emoji":"🌩️","aliases":["cloud_with_lightning"]},{"emoji":"⛈️","aliases":["cloud_with_lightning_and_rain"]},{"emoji":"🌧️","aliases":["cloud_with_rain"]},{"emoji":"🌨️","aliases":["cloud_with_snow"]},{"emoji":"🤡","aliases":["clown_face"]},{"emoji":"♣️","aliases":["clubs"]},{"emoji":"🇨🇳","aliases":["cn"]},{"emoji":"🧥","aliases":["coat"]},{"emoji":"🪳","aliases":["cockroach"]},{"emoji":"🍸","aliases":["cocktail"]},{"emoji":"🥥","aliases":["coconut"]},{"emoji":"🇨🇨","aliases":["cocos_islands"]},{"emoji":"☕","aliases":["coffee"]},{"emoji":"⚰️","aliases":["coffin"]},{"emoji":"🪙","aliases":["coin"]},{"emoji":"🥶","aliases":["cold_face"]},{"emoji":"😰","aliases":["cold_sweat"]},{"emoji":"🇨🇴","aliases":["colombia"]},{"emoji":"☄️","aliases":["comet"]},{"emoji":"🇰🇲","aliases":["comoros"]},{"emoji":"🧭","aliases":["compass"]},{"emoji":"💻","aliases":["computer"]},{"emoji":"🖱️","aliases":["computer_mouse"]},{"emoji":"🎊","aliases":["confetti_ball"]},{"emoji":"😖","aliases":["confounded"]},{"emoji":"😕","aliases":["confused"]},{"emoji":"🇨🇬","aliases":["congo_brazzaville"]},{"emoji":"🇨🇩","aliases":["congo_kinshasa"]},{"emoji":"㊗️","aliases":["congratulations"]},{"emoji":"🚧","aliases":["construction"]},{"emoji":"👷","aliases":["construction_worker"]},{"emoji":"👷‍♂️","aliases":["construction_worker_man"]},{"emoji":"👷‍♀️","aliases":["construction_worker_woman"]},{"emoji":"🎛️","aliases":["control_knobs"]},{"emoji":"🏪","aliases":["convenience_store"]},{"emoji":"🧑‍🍳","aliases":["cook"]},{"emoji":"🇨🇰","aliases":["cook_islands"]},{"emoji":"🍪","aliases":["cookie"]},{"emoji":"🆒","aliases":["cool"]},{"emoji":"©️","aliases":["copyright"]},{"emoji":"🪸","aliases":["coral"]},{"emoji":"🌽","aliases":["corn"]},{"emoji":"🇨🇷","aliases":["costa_rica"]},{"emoji":"🇨🇮","aliases":["cote_divoire"]},{"emoji":"🛋️","aliases":["couch_and_lamp"]},{"emoji":"👫","aliases":["couple"]},{"emoji":"💑","aliases":["couple_with_heart"]},{"emoji":"👨‍❤️‍👨","aliases":["couple_with_heart_man_man"]},{"emoji":"👩‍❤️‍👨","aliases":["couple_with_heart_woman_man"]},{"emoji":"👩‍❤️‍👩","aliases":["couple_with_heart_woman_woman"]},{"emoji":"💏","aliases":["couplekiss"]},{"emoji":"👨‍❤️‍💋‍👨","aliases":["couplekiss_man_man"]},{"emoji":"👩‍❤️‍💋‍👨","aliases":["couplekiss_man_woman"]},{"emoji":"👩‍❤️‍💋‍👩","aliases":["couplekiss_woman_woman"]},{"emoji":"🐮","aliases":["cow"]},{"emoji":"🐄","aliases":["cow2"]},{"emoji":"🤠","aliases":["cowboy_hat_face"]},{"emoji":"🦀","aliases":["crab"]},{"emoji":"🖍️","aliases":["crayon"]},{"emoji":"💳","aliases":["credit_card"]},{"emoji":"🌙","aliases":["crescent_moon"]},{"emoji":"🦗","aliases":["cricket"]},{"emoji":"🏏","aliases":["cricket_game"]},{"emoji":"🇭🇷","aliases":["croatia"]},{"emoji":"🐊","aliases":["crocodile"]},{"emoji":"🥐","aliases":["croissant"]},{"emoji":"🤞","aliases":["crossed_fingers"]},{"emoji":"🎌","aliases":["crossed_flags"]},{"emoji":"⚔️","aliases":["crossed_swords"]},{"emoji":"👑","aliases":["crown"]},{"emoji":"🩼","aliases":["crutch"]},{"emoji":"😢","aliases":["cry"]},{"emoji":"😿","aliases":["crying_cat_face"]},{"emoji":"🔮","aliases":["crystal_ball"]},{"emoji":"🇨🇺","aliases":["cuba"]},{"emoji":"🥒","aliases":["cucumber"]},{"emoji":"🥤","aliases":["cup_with_straw"]},{"emoji":"🧁","aliases":["cupcake"]},{"emoji":"💘","aliases":["cupid"]},{"emoji":"🇨🇼","aliases":["curacao"]},{"emoji":"🥌","aliases":["curling_stone"]},{"emoji":"👨‍🦱","aliases":["curly_haired_man"]},{"emoji":"👩‍🦱","aliases":["curly_haired_woman"]},{"emoji":"➰","aliases":["curly_loop"]},{"emoji":"💱","aliases":["currency_exchange"]},{"emoji":"🍛","aliases":["curry"]},{"emoji":"🤬","aliases":["cursing_face"]},{"emoji":"🍮","aliases":["custard"]},{"emoji":"🛃","aliases":["customs"]},{"emoji":"🥩","aliases":["cut_of_meat"]},{"emoji":"🌀","aliases":["cyclone"]},{"emoji":"🇨🇾","aliases":["cyprus"]},{"emoji":"🇨🇿","aliases":["czech_republic"]},{"emoji":"🗡️","aliases":["dagger"]},{"emoji":"👯","aliases":["dancers"]},{"emoji":"👯‍♂️","aliases":["dancing_men"]},{"emoji":"👯‍♀️","aliases":["dancing_women"]},{"emoji":"🍡","aliases":["dango"]},{"emoji":"🕶️","aliases":["dark_sunglasses"]},{"emoji":"🎯","aliases":["dart"]},{"emoji":"💨","aliases":["dash"]},{"emoji":"📅","aliases":["date"]},{"emoji":"🇩🇪","aliases":["de"]},{"emoji":"🧏‍♂️","aliases":["deaf_man"]},{"emoji":"🧏","aliases":["deaf_person"]},{"emoji":"🧏‍♀️","aliases":["deaf_woman"]},{"emoji":"🌳","aliases":["deciduous_tree"]},{"emoji":"🦌","aliases":["deer"]},{"emoji":"🇩🇰","aliases":["denmark"]},{"emoji":"🏬","aliases":["department_store"]},{"emoji":"🏚️","aliases":["derelict_house"]},{"emoji":"🏜️","aliases":["desert"]},{"emoji":"🏝️","aliases":["desert_island"]},{"emoji":"🖥️","aliases":["desktop_computer"]},{"emoji":"🕵️","aliases":["detective"]},{"emoji":"💠","aliases":["diamond_shape_with_a_dot_inside"]},{"emoji":"♦️","aliases":["diamonds"]},{"emoji":"🇩🇬","aliases":["diego_garcia"]},{"emoji":"😞","aliases":["disappointed"]},{"emoji":"😥","aliases":["disappointed_relieved"]},{"emoji":"🥸","aliases":["disguised_face"]},{"emoji":"🤿","aliases":["diving_mask"]},{"emoji":"🪔","aliases":["diya_lamp"]},{"emoji":"💫","aliases":["dizzy"]},{"emoji":"😵","aliases":["dizzy_face"]},{"emoji":"🇩🇯","aliases":["djibouti"]},{"emoji":"🧬","aliases":["dna"]},{"emoji":"🚯","aliases":["do_not_litter"]},{"emoji":"🦤","aliases":["dodo"]},{"emoji":"🐶","aliases":["dog"]},{"emoji":"🐕","aliases":["dog2"]},{"emoji":"💵","aliases":["dollar"]},{"emoji":"🎎","aliases":["dolls"]},{"emoji":"🐬","aliases":["dolphin","flipper"]},{"emoji":"🇩🇲","aliases":["dominica"]},{"emoji":"🇩🇴","aliases":["dominican_republic"]},{"emoji":"🫏","aliases":["donkey"]},{"emoji":"🚪","aliases":["door"]},{"emoji":"🫥","aliases":["dotted_line_face"]},{"emoji":"🍩","aliases":["doughnut"]},{"emoji":"🕊️","aliases":["dove"]},{"emoji":"🐉","aliases":["dragon"]},{"emoji":"🐲","aliases":["dragon_face"]},{"emoji":"👗","aliases":["dress"]},{"emoji":"🐪","aliases":["dromedary_camel"]},{"emoji":"🤤","aliases":["drooling_face"]},{"emoji":"🩸","aliases":["drop_of_blood"]},{"emoji":"💧","aliases":["droplet"]},{"emoji":"🥁","aliases":["drum"]},{"emoji":"🦆","aliases":["duck"]},{"emoji":"🥟","aliases":["dumpling"]},{"emoji":"📀","aliases":["dvd"]},{"emoji":"🦅","aliases":["eagle"]},{"emoji":"👂","aliases":["ear"]},{"emoji":"🌾","aliases":["ear_of_rice"]},{"emoji":"🦻","aliases":["ear_with_hearing_aid"]},{"emoji":"🌍","aliases":["earth_africa"]},{"emoji":"🌎","aliases":["earth_americas"]},{"emoji":"🌏","aliases":["earth_asia"]},{"emoji":"🇪🇨","aliases":["ecuador"]},{"emoji":"🥚","aliases":["egg"]},{"emoji":"🍆","aliases":["eggplant"]},{"emoji":"🇪🇬","aliases":["egypt"]},{"emoji":"8️⃣","aliases":["eight"]},{"emoji":"✴️","aliases":["eight_pointed_black_star"]},{"emoji":"✳️","aliases":["eight_spoked_asterisk"]},{"emoji":"⏏️","aliases":["eject_button"]},{"emoji":"🇸🇻","aliases":["el_salvador"]},{"emoji":"🔌","aliases":["electric_plug"]},{"emoji":"🐘","aliases":["elephant"]},{"emoji":"🛗","aliases":["elevator"]},{"emoji":"🧝","aliases":["elf"]},{"emoji":"🧝‍♂️","aliases":["elf_man"]},{"emoji":"🧝‍♀️","aliases":["elf_woman"]},{"emoji":"📧","aliases":["email","e-mail"]},{"emoji":"🪹","aliases":["empty_nest"]},{"emoji":"🔚","aliases":["end"]},{"emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿","aliases":["england"]},{"emoji":"✉️","aliases":["envelope"]},{"emoji":"📩","aliases":["envelope_with_arrow"]},{"emoji":"🇬🇶","aliases":["equatorial_guinea"]},{"emoji":"🇪🇷","aliases":["eritrea"]},{"emoji":"🇪🇸","aliases":["es"]},{"emoji":"🇪🇪","aliases":["estonia"]},{"emoji":"🇪🇹","aliases":["ethiopia"]},{"emoji":"🇪🇺","aliases":["eu","european_union"]},{"emoji":"💶","aliases":["euro"]},{"emoji":"🏰","aliases":["european_castle"]},{"emoji":"🏤","aliases":["european_post_office"]},{"emoji":"🌲","aliases":["evergreen_tree"]},{"emoji":"❗","aliases":["exclamation","heavy_exclamation_mark"]},{"emoji":"🤯","aliases":["exploding_head"]},{"emoji":"😑","aliases":["expressionless"]},{"emoji":"👁️","aliases":["eye"]},{"emoji":"👁️‍🗨️","aliases":["eye_speech_bubble"]},{"emoji":"👓","aliases":["eyeglasses"]},{"emoji":"👀","aliases":["eyes"]},{"emoji":"😮‍💨","aliases":["face_exhaling"]},{"emoji":"🥹","aliases":["face_holding_back_tears"]},{"emoji":"😶‍🌫️","aliases":["face_in_clouds"]},{"emoji":"🫤","aliases":["face_with_diagonal_mouth"]},{"emoji":"🤕","aliases":["face_with_head_bandage"]},{"emoji":"🫢","aliases":["face_with_open_eyes_and_hand_over_mouth"]},{"emoji":"🫣","aliases":["face_with_peeking_eye"]},{"emoji":"😵‍💫","aliases":["face_with_spiral_eyes"]},{"emoji":"🤒","aliases":["face_with_thermometer"]},{"emoji":"🤦","aliases":["facepalm"]},{"emoji":"🏭","aliases":["factory"]},{"emoji":"🧑‍🏭","aliases":["factory_worker"]},{"emoji":"🧚","aliases":["fairy"]},{"emoji":"🧚‍♂️","aliases":["fairy_man"]},{"emoji":"🧚‍♀️","aliases":["fairy_woman"]},{"emoji":"🧆","aliases":["falafel"]},{"emoji":"🇫🇰","aliases":["falkland_islands"]},{"emoji":"🍂","aliases":["fallen_leaf"]},{"emoji":"👪","aliases":["family"]},{"emoji":"👨‍👦","aliases":["family_man_boy"]},{"emoji":"👨‍👦‍👦","aliases":["family_man_boy_boy"]},{"emoji":"👨‍👧","aliases":["family_man_girl"]},{"emoji":"👨‍👧‍👦","aliases":["family_man_girl_boy"]},{"emoji":"👨‍👧‍👧","aliases":["family_man_girl_girl"]},{"emoji":"👨‍👨‍👦","aliases":["family_man_man_boy"]},{"emoji":"👨‍👨‍👦‍👦","aliases":["family_man_man_boy_boy"]},{"emoji":"👨‍👨‍👧","aliases":["family_man_man_girl"]},{"emoji":"👨‍👨‍👧‍👦","aliases":["family_man_man_girl_boy"]},{"emoji":"👨‍👨‍👧‍👧","aliases":["family_man_man_girl_girl"]},{"emoji":"👨‍👩‍👦","aliases":["family_man_woman_boy"]},{"emoji":"👨‍👩‍👦‍👦","aliases":["family_man_woman_boy_boy"]},{"emoji":"👨‍👩‍👧","aliases":["family_man_woman_girl"]},{"emoji":"👨‍👩‍👧‍👦","aliases":["family_man_woman_girl_boy"]},{"emoji":"👨‍👩‍👧‍👧","aliases":["family_man_woman_girl_girl"]},{"emoji":"👩‍👦","aliases":["family_woman_boy"]},{"emoji":"👩‍👦‍👦","aliases":["family_woman_boy_boy"]},{"emoji":"👩‍👧","aliases":["family_woman_girl"]},{"emoji":"👩‍👧‍👦","aliases":["family_woman_girl_boy"]},{"emoji":"👩‍👧‍👧","aliases":["family_woman_girl_girl"]},{"emoji":"👩‍👩‍👦","aliases":["family_woman_woman_boy"]},{"emoji":"👩‍👩‍👦‍👦","aliases":["family_woman_woman_boy_boy"]},{"emoji":"👩‍👩‍👧","aliases":["family_woman_woman_girl"]},{"emoji":"👩‍👩‍👧‍👦","aliases":["family_woman_woman_girl_boy"]},{"emoji":"👩‍👩‍👧‍👧","aliases":["family_woman_woman_girl_girl"]},{"emoji":"🧑‍🌾","aliases":["farmer"]},{"emoji":"🇫🇴","aliases":["faroe_islands"]},{"emoji":"⏩","aliases":["fast_forward"]},{"emoji":"📠","aliases":["fax"]},{"emoji":"😨","aliases":["fearful"]},{"emoji":"🪶","aliases":["feather"]},{"emoji":"🐾","aliases":["feet","paw_prints"]},{"emoji":"🕵️‍♀️","aliases":["female_detective"]},{"emoji":"♀️","aliases":["female_sign"]},{"emoji":"🎡","aliases":["ferris_wheel"]},{"emoji":"⛴️","aliases":["ferry"]},{"emoji":"🏑","aliases":["field_hockey"]},{"emoji":"🇫🇯","aliases":["fiji"]},{"emoji":"🗄️","aliases":["file_cabinet"]},{"emoji":"📁","aliases":["file_folder"]},{"emoji":"📽️","aliases":["film_projector"]},{"emoji":"🎞️","aliases":["film_strip"]},{"emoji":"🇫🇮","aliases":["finland"]},{"emoji":"🔥","aliases":["fire"]},{"emoji":"🚒","aliases":["fire_engine"]},{"emoji":"🧯","aliases":["fire_extinguisher"]},{"emoji":"🧨","aliases":["firecracker"]},{"emoji":"🧑‍🚒","aliases":["firefighter"]},{"emoji":"🎆","aliases":["fireworks"]},{"emoji":"🌓","aliases":["first_quarter_moon"]},{"emoji":"🌛","aliases":["first_quarter_moon_with_face"]},{"emoji":"🐟","aliases":["fish"]},{"emoji":"🍥","aliases":["fish_cake"]},{"emoji":"🎣","aliases":["fishing_pole_and_fish"]},{"emoji":"🤛","aliases":["fist_left"]},{"emoji":"👊","aliases":["fist_oncoming","facepunch","punch"]},{"emoji":"✊","aliases":["fist_raised","fist"]},{"emoji":"🤜","aliases":["fist_right"]},{"emoji":"5️⃣","aliases":["five"]},{"emoji":"🎏","aliases":["flags"]},{"emoji":"🦩","aliases":["flamingo"]},{"emoji":"🔦","aliases":["flashlight"]},{"emoji":"🥿","aliases":["flat_shoe"]},{"emoji":"🫓","aliases":["flatbread"]},{"emoji":"⚜️","aliases":["fleur_de_lis"]},{"emoji":"🛬","aliases":["flight_arrival"]},{"emoji":"🛫","aliases":["flight_departure"]},{"emoji":"💾","aliases":["floppy_disk"]},{"emoji":"🎴","aliases":["flower_playing_cards"]},{"emoji":"😳","aliases":["flushed"]},{"emoji":"🪈","aliases":["flute"]},{"emoji":"🪰","aliases":["fly"]},{"emoji":"🥏","aliases":["flying_disc"]},{"emoji":"🛸","aliases":["flying_saucer"]},{"emoji":"🌫️","aliases":["fog"]},{"emoji":"🌁","aliases":["foggy"]},{"emoji":"🪭","aliases":["folding_hand_fan"]},{"emoji":"🫕","aliases":["fondue"]},{"emoji":"🦶","aliases":["foot"]},{"emoji":"🏈","aliases":["football"]},{"emoji":"👣","aliases":["footprints"]},{"emoji":"🍴","aliases":["fork_and_knife"]},{"emoji":"🥠","aliases":["fortune_cookie"]},{"emoji":"⛲","aliases":["fountain"]},{"emoji":"🖋️","aliases":["fountain_pen"]},{"emoji":"4️⃣","aliases":["four"]},{"emoji":"🍀","aliases":["four_leaf_clover"]},{"emoji":"🦊","aliases":["fox_face"]},{"emoji":"🇫🇷","aliases":["fr"]},{"emoji":"🖼️","aliases":["framed_picture"]},{"emoji":"🆓","aliases":["free"]},{"emoji":"🇬🇫","aliases":["french_guiana"]},{"emoji":"🇵🇫","aliases":["french_polynesia"]},{"emoji":"🇹🇫","aliases":["french_southern_territories"]},{"emoji":"🍳","aliases":["fried_egg"]},{"emoji":"🍤","aliases":["fried_shrimp"]},{"emoji":"🍟","aliases":["fries"]},{"emoji":"🐸","aliases":["frog"]},{"emoji":"😦","aliases":["frowning"]},{"emoji":"☹️","aliases":["frowning_face"]},{"emoji":"🙍‍♂️","aliases":["frowning_man"]},{"emoji":"🙍","aliases":["frowning_person"]},{"emoji":"🙍‍♀️","aliases":["frowning_woman"]},{"emoji":"⛽","aliases":["fuelpump"]},{"emoji":"🌕","aliases":["full_moon"]},{"emoji":"🌝","aliases":["full_moon_with_face"]},{"emoji":"⚱️","aliases":["funeral_urn"]},{"emoji":"🇬🇦","aliases":["gabon"]},{"emoji":"🇬🇲","aliases":["gambia"]},{"emoji":"🎲","aliases":["game_die"]},{"emoji":"🧄","aliases":["garlic"]},{"emoji":"🇬🇧","aliases":["gb","uk"]},{"emoji":"⚙️","aliases":["gear"]},{"emoji":"💎","aliases":["gem"]},{"emoji":"♊","aliases":["gemini"]},{"emoji":"🧞","aliases":["genie"]},{"emoji":"🧞‍♂️","aliases":["genie_man"]},{"emoji":"🧞‍♀️","aliases":["genie_woman"]},{"emoji":"🇬🇪","aliases":["georgia"]},{"emoji":"🇬🇭","aliases":["ghana"]},{"emoji":"👻","aliases":["ghost"]},{"emoji":"🇬🇮","aliases":["gibraltar"]},{"emoji":"🎁","aliases":["gift"]},{"emoji":"💝","aliases":["gift_heart"]},{"emoji":"🫚","aliases":["ginger_root"]},{"emoji":"🦒","aliases":["giraffe"]},{"emoji":"👧","aliases":["girl"]},{"emoji":"🌐","aliases":["globe_with_meridians"]},{"emoji":"🧤","aliases":["gloves"]},{"emoji":"🥅","aliases":["goal_net"]},{"emoji":"🐐","aliases":["goat"]},{"emoji":"🥽","aliases":["goggles"]},{"emoji":"⛳","aliases":["golf"]},{"emoji":"🏌️","aliases":["golfing"]},{"emoji":"🏌️‍♂️","aliases":["golfing_man"]},{"emoji":"🏌️‍♀️","aliases":["golfing_woman"]},{"emoji":"🪿","aliases":["goose"]},{"emoji":"🦍","aliases":["gorilla"]},{"emoji":"🍇","aliases":["grapes"]},{"emoji":"🇬🇷","aliases":["greece"]},{"emoji":"🍏","aliases":["green_apple"]},{"emoji":"📗","aliases":["green_book"]},{"emoji":"🟢","aliases":["green_circle"]},{"emoji":"💚","aliases":["green_heart"]},{"emoji":"🥗","aliases":["green_salad"]},{"emoji":"🟩","aliases":["green_square"]},{"emoji":"🇬🇱","aliases":["greenland"]},{"emoji":"🇬🇩","aliases":["grenada"]},{"emoji":"❕","aliases":["grey_exclamation"]},{"emoji":"🩶","aliases":["grey_heart"]},{"emoji":"❔","aliases":["grey_question"]},{"emoji":"😬","aliases":["grimacing"]},{"emoji":"😁","aliases":["grin"]},{"emoji":"😀","aliases":["grinning"]},{"emoji":"🇬🇵","aliases":["guadeloupe"]},{"emoji":"🇬🇺","aliases":["guam"]},{"emoji":"💂","aliases":["guard"]},{"emoji":"💂‍♂️","aliases":["guardsman"]},{"emoji":"💂‍♀️","aliases":["guardswoman"]},{"emoji":"🇬🇹","aliases":["guatemala"]},{"emoji":"🇬🇬","aliases":["guernsey"]},{"emoji":"🦮","aliases":["guide_dog"]},{"emoji":"🇬🇳","aliases":["guinea"]},{"emoji":"🇬🇼","aliases":["guinea_bissau"]},{"emoji":"🎸","aliases":["guitar"]},{"emoji":"🔫","aliases":["gun"]},{"emoji":"🇬🇾","aliases":["guyana"]},{"emoji":"🪮","aliases":["hair_pick"]},{"emoji":"💇","aliases":["haircut"]},{"emoji":"💇‍♂️","aliases":["haircut_man"]},{"emoji":"💇‍♀️","aliases":["haircut_woman"]},{"emoji":"🇭🇹","aliases":["haiti"]},{"emoji":"🍔","aliases":["hamburger"]},{"emoji":"🔨","aliases":["hammer"]},{"emoji":"⚒️","aliases":["hammer_and_pick"]},{"emoji":"🛠️","aliases":["hammer_and_wrench"]},{"emoji":"🪬","aliases":["hamsa"]},{"emoji":"🐹","aliases":["hamster"]},{"emoji":"✋","aliases":["hand","raised_hand"]},{"emoji":"🤭","aliases":["hand_over_mouth"]},{"emoji":"🫰","aliases":["hand_with_index_finger_and_thumb_crossed"]},{"emoji":"👜","aliases":["handbag"]},{"emoji":"🤾","aliases":["handball_person"]},{"emoji":"🤝","aliases":["handshake"]},{"emoji":"💩","aliases":["hankey","poop","shit"]},{"emoji":"#️⃣","aliases":["hash"]},{"emoji":"🐥","aliases":["hatched_chick"]},{"emoji":"🐣","aliases":["hatching_chick"]},{"emoji":"🎧","aliases":["headphones"]},{"emoji":"🪦","aliases":["headstone"]},{"emoji":"🧑‍⚕️","aliases":["health_worker"]},{"emoji":"🙉","aliases":["hear_no_evil"]},{"emoji":"🇭🇲","aliases":["heard_mcdonald_islands"]},{"emoji":"❤️","aliases":["heart"]},{"emoji":"💟","aliases":["heart_decoration"]},{"emoji":"😍","aliases":["heart_eyes"]},{"emoji":"😻","aliases":["heart_eyes_cat"]},{"emoji":"🫶","aliases":["heart_hands"]},{"emoji":"❤️‍🔥","aliases":["heart_on_fire"]},{"emoji":"💓","aliases":["heartbeat"]},{"emoji":"💗","aliases":["heartpulse"]},{"emoji":"♥️","aliases":["hearts"]},{"emoji":"✔️","aliases":["heavy_check_mark"]},{"emoji":"➗","aliases":["heavy_division_sign"]},{"emoji":"💲","aliases":["heavy_dollar_sign"]},{"emoji":"🟰","aliases":["heavy_equals_sign"]},{"emoji":"❣️","aliases":["heavy_heart_exclamation"]},{"emoji":"➖","aliases":["heavy_minus_sign"]},{"emoji":"✖️","aliases":["heavy_multiplication_x"]},{"emoji":"➕","aliases":["heavy_plus_sign"]},{"emoji":"🦔","aliases":["hedgehog"]},{"emoji":"🚁","aliases":["helicopter"]},{"emoji":"🌿","aliases":["herb"]},{"emoji":"🌺","aliases":["hibiscus"]},{"emoji":"🔆","aliases":["high_brightness"]},{"emoji":"👠","aliases":["high_heel"]},{"emoji":"🥾","aliases":["hiking_boot"]},{"emoji":"🛕","aliases":["hindu_temple"]},{"emoji":"🦛","aliases":["hippopotamus"]},{"emoji":"🔪","aliases":["hocho","knife"]},{"emoji":"🕳️","aliases":["hole"]},{"emoji":"🇭🇳","aliases":["honduras"]},{"emoji":"🍯","aliases":["honey_pot"]},{"emoji":"🇭🇰","aliases":["hong_kong"]},{"emoji":"🪝","aliases":["hook"]},{"emoji":"🐴","aliases":["horse"]},{"emoji":"🏇","aliases":["horse_racing"]},{"emoji":"🏥","aliases":["hospital"]},{"emoji":"🥵","aliases":["hot_face"]},{"emoji":"🌶️","aliases":["hot_pepper"]},{"emoji":"🌭","aliases":["hotdog"]},{"emoji":"🏨","aliases":["hotel"]},{"emoji":"♨️","aliases":["hotsprings"]},{"emoji":"⌛","aliases":["hourglass"]},{"emoji":"⏳","aliases":["hourglass_flowing_sand"]},{"emoji":"🏠","aliases":["house"]},{"emoji":"🏡","aliases":["house_with_garden"]},{"emoji":"🏘️","aliases":["houses"]},{"emoji":"🤗","aliases":["hugs"]},{"emoji":"🇭🇺","aliases":["hungary"]},{"emoji":"😯","aliases":["hushed"]},{"emoji":"🛖","aliases":["hut"]},{"emoji":"🪻","aliases":["hyacinth"]},{"emoji":"🍨","aliases":["ice_cream"]},{"emoji":"🧊","aliases":["ice_cube"]},{"emoji":"🏒","aliases":["ice_hockey"]},{"emoji":"⛸️","aliases":["ice_skate"]},{"emoji":"🍦","aliases":["icecream"]},{"emoji":"🇮🇸","aliases":["iceland"]},{"emoji":"🆔","aliases":["id"]},{"emoji":"🪪","aliases":["identification_card"]},{"emoji":"🉐","aliases":["ideograph_advantage"]},{"emoji":"👿","aliases":["imp"]},{"emoji":"📥","aliases":["inbox_tray"]},{"emoji":"📨","aliases":["incoming_envelope"]},{"emoji":"🫵","aliases":["index_pointing_at_the_viewer"]},{"emoji":"🇮🇳","aliases":["india"]},{"emoji":"🇮🇩","aliases":["indonesia"]},{"emoji":"♾️","aliases":["infinity"]},{"emoji":"ℹ️","aliases":["information_source"]},{"emoji":"😇","aliases":["innocent"]},{"emoji":"⁉️","aliases":["interrobang"]},{"emoji":"📱","aliases":["iphone"]},{"emoji":"🇮🇷","aliases":["iran"]},{"emoji":"🇮🇶","aliases":["iraq"]},{"emoji":"🇮🇪","aliases":["ireland"]},{"emoji":"🇮🇲","aliases":["isle_of_man"]},{"emoji":"🇮🇱","aliases":["israel"]},{"emoji":"🇮🇹","aliases":["it"]},{"emoji":"🏮","aliases":["izakaya_lantern","lantern"]},{"emoji":"🎃","aliases":["jack_o_lantern"]},{"emoji":"🇯🇲","aliases":["jamaica"]},{"emoji":"🗾","aliases":["japan"]},{"emoji":"🏯","aliases":["japanese_castle"]},{"emoji":"👺","aliases":["japanese_goblin"]},{"emoji":"👹","aliases":["japanese_ogre"]},{"emoji":"🫙","aliases":["jar"]},{"emoji":"👖","aliases":["jeans"]},{"emoji":"🪼","aliases":["jellyfish"]},{"emoji":"🇯🇪","aliases":["jersey"]},{"emoji":"🧩","aliases":["jigsaw"]},{"emoji":"🇯🇴","aliases":["jordan"]},{"emoji":"😂","aliases":["joy"]},{"emoji":"😹","aliases":["joy_cat"]},{"emoji":"🕹️","aliases":["joystick"]},{"emoji":"🇯🇵","aliases":["jp"]},{"emoji":"🧑‍⚖️","aliases":["judge"]},{"emoji":"🤹","aliases":["juggling_person"]},{"emoji":"🕋","aliases":["kaaba"]},{"emoji":"🦘","aliases":["kangaroo"]},{"emoji":"🇰🇿","aliases":["kazakhstan"]},{"emoji":"🇰🇪","aliases":["kenya"]},{"emoji":"🔑","aliases":["key"]},{"emoji":"⌨️","aliases":["keyboard"]},{"emoji":"🔟","aliases":["keycap_ten"]},{"emoji":"🪯","aliases":["khanda"]},{"emoji":"🛴","aliases":["kick_scooter"]},{"emoji":"👘","aliases":["kimono"]},{"emoji":"🇰🇮","aliases":["kiribati"]},{"emoji":"💋","aliases":["kiss"]},{"emoji":"😗","aliases":["kissing"]},{"emoji":"😽","aliases":["kissing_cat"]},{"emoji":"😚","aliases":["kissing_closed_eyes"]},{"emoji":"😘","aliases":["kissing_heart"]},{"emoji":"😙","aliases":["kissing_smiling_eyes"]},{"emoji":"🪁","aliases":["kite"]},{"emoji":"🥝","aliases":["kiwi_fruit"]},{"emoji":"🧎‍♂️","aliases":["kneeling_man"]},{"emoji":"🧎","aliases":["kneeling_person"]},{"emoji":"🧎‍♀️","aliases":["kneeling_woman"]},{"emoji":"🪢","aliases":["knot"]},{"emoji":"🐨","aliases":["koala"]},{"emoji":"🈁","aliases":["koko"]},{"emoji":"🇽🇰","aliases":["kosovo"]},{"emoji":"🇰🇷","aliases":["kr"]},{"emoji":"🇰🇼","aliases":["kuwait"]},{"emoji":"🇰🇬","aliases":["kyrgyzstan"]},{"emoji":"🥼","aliases":["lab_coat"]},{"emoji":"🏷️","aliases":["label"]},{"emoji":"🥍","aliases":["lacrosse"]},{"emoji":"🪜","aliases":["ladder"]},{"emoji":"🐞","aliases":["lady_beetle"]},{"emoji":"🇱🇦","aliases":["laos"]},{"emoji":"🔵","aliases":["large_blue_circle"]},{"emoji":"🔷","aliases":["large_blue_diamond"]},{"emoji":"🔶","aliases":["large_orange_diamond"]},{"emoji":"🌗","aliases":["last_quarter_moon"]},{"emoji":"🌜","aliases":["last_quarter_moon_with_face"]},{"emoji":"✝️","aliases":["latin_cross"]},{"emoji":"🇱🇻","aliases":["latvia"]},{"emoji":"😆","aliases":["laughing","satisfied","laugh"]},{"emoji":"🥬","aliases":["leafy_green"]},{"emoji":"🍃","aliases":["leaves"]},{"emoji":"🇱🇧","aliases":["lebanon"]},{"emoji":"📒","aliases":["ledger"]},{"emoji":"🛅","aliases":["left_luggage"]},{"emoji":"↔️","aliases":["left_right_arrow"]},{"emoji":"🗨️","aliases":["left_speech_bubble"]},{"emoji":"↩️","aliases":["leftwards_arrow_with_hook"]},{"emoji":"🫲","aliases":["leftwards_hand"]},{"emoji":"🫷","aliases":["leftwards_pushing_hand"]},{"emoji":"🦵","aliases":["leg"]},{"emoji":"🍋","aliases":["lemon"]},{"emoji":"♌","aliases":["leo"]},{"emoji":"🐆","aliases":["leopard"]},{"emoji":"🇱🇸","aliases":["lesotho"]},{"emoji":"🎚️","aliases":["level_slider"]},{"emoji":"🇱🇷","aliases":["liberia"]},{"emoji":"♎","aliases":["libra"]},{"emoji":"🇱🇾","aliases":["libya"]},{"emoji":"🇱🇮","aliases":["liechtenstein"]},{"emoji":"🩵","aliases":["light_blue_heart"]},{"emoji":"🚈","aliases":["light_rail"]},{"emoji":"🔗","aliases":["link"]},{"emoji":"🦁","aliases":["lion"]},{"emoji":"👄","aliases":["lips"]},{"emoji":"💄","aliases":["lipstick"]},{"emoji":"🇱🇹","aliases":["lithuania"]},{"emoji":"🦎","aliases":["lizard"]},{"emoji":"🦙","aliases":["llama"]},{"emoji":"🦞","aliases":["lobster"]},{"emoji":"🔒","aliases":["lock"]},{"emoji":"🔏","aliases":["lock_with_ink_pen"]},{"emoji":"🍭","aliases":["lollipop"]},{"emoji":"🪘","aliases":["long_drum"]},{"emoji":"➿","aliases":["loop"]},{"emoji":"🧴","aliases":["lotion_bottle"]},{"emoji":"🪷","aliases":["lotus"]},{"emoji":"🧘","aliases":["lotus_position"]},{"emoji":"🧘‍♂️","aliases":["lotus_position_man"]},{"emoji":"🧘‍♀️","aliases":["lotus_position_woman"]},{"emoji":"🔊","aliases":["loud_sound"]},{"emoji":"📢","aliases":["loudspeaker"]},{"emoji":"🏩","aliases":["love_hotel"]},{"emoji":"💌","aliases":["love_letter"]},{"emoji":"🤟","aliases":["love_you_gesture"]},{"emoji":"🪫","aliases":["low_battery"]},{"emoji":"🔅","aliases":["low_brightness"]},{"emoji":"🧳","aliases":["luggage"]},{"emoji":"🫁","aliases":["lungs"]},{"emoji":"🇱🇺","aliases":["luxembourg"]},{"emoji":"🤥","aliases":["lying_face"]},{"emoji":"Ⓜ️","aliases":["m"]},{"emoji":"🇲🇴","aliases":["macau"]},{"emoji":"🇲🇰","aliases":["macedonia"]},{"emoji":"🇲🇬","aliases":["madagascar"]},{"emoji":"🔍","aliases":["mag"]},{"emoji":"🔎","aliases":["mag_right"]},{"emoji":"🧙","aliases":["mage"]},{"emoji":"🧙‍♂️","aliases":["mage_man"]},{"emoji":"🧙‍♀️","aliases":["mage_woman"]},{"emoji":"🪄","aliases":["magic_wand"]},{"emoji":"🧲","aliases":["magnet"]},{"emoji":"🀄","aliases":["mahjong"]},{"emoji":"📫","aliases":["mailbox"]},{"emoji":"📪","aliases":["mailbox_closed"]},{"emoji":"📬","aliases":["mailbox_with_mail"]},{"emoji":"📭","aliases":["mailbox_with_no_mail"]},{"emoji":"🇲🇼","aliases":["malawi"]},{"emoji":"🇲🇾","aliases":["malaysia"]},{"emoji":"🇲🇻","aliases":["maldives"]},{"emoji":"🕵️‍♂️","aliases":["male_detective"]},{"emoji":"♂️","aliases":["male_sign"]},{"emoji":"🇲🇱","aliases":["mali"]},{"emoji":"🇲🇹","aliases":["malta"]},{"emoji":"🦣","aliases":["mammoth"]},{"emoji":"👨","aliases":["man"]},{"emoji":"👨‍🎨","aliases":["man_artist"]},{"emoji":"👨‍🚀","aliases":["man_astronaut"]},{"emoji":"🧔‍♂️","aliases":["man_beard"]},{"emoji":"🤸‍♂️","aliases":["man_cartwheeling"]},{"emoji":"👨‍🍳","aliases":["man_cook"]},{"emoji":"🕺","aliases":["man_dancing"]},{"emoji":"🤦‍♂️","aliases":["man_facepalming"]},{"emoji":"👨‍🏭","aliases":["man_factory_worker"]},{"emoji":"👨‍🌾","aliases":["man_farmer"]},{"emoji":"👨‍🍼","aliases":["man_feeding_baby"]},{"emoji":"👨‍🚒","aliases":["man_firefighter"]},{"emoji":"👨‍⚕️","aliases":["man_health_worker"]},{"emoji":"👨‍🦽","aliases":["man_in_manual_wheelchair"]},{"emoji":"👨‍🦼","aliases":["man_in_motorized_wheelchair"]},{"emoji":"🤵‍♂️","aliases":["man_in_tuxedo"]},{"emoji":"👨‍⚖️","aliases":["man_judge"]},{"emoji":"🤹‍♂️","aliases":["man_juggling"]},{"emoji":"👨‍🔧","aliases":["man_mechanic"]},{"emoji":"👨‍💼","aliases":["man_office_worker"]},{"emoji":"👨‍✈️","aliases":["man_pilot"]},{"emoji":"🤾‍♂️","aliases":["man_playing_handball"]},{"emoji":"🤽‍♂️","aliases":["man_playing_water_polo"]},{"emoji":"👨‍🔬","aliases":["man_scientist"]},{"emoji":"🤷‍♂️","aliases":["man_shrugging"]},{"emoji":"👨‍🎤","aliases":["man_singer"]},{"emoji":"👨‍🎓","aliases":["man_student"]},{"emoji":"👨‍🏫","aliases":["man_teacher"]},{"emoji":"👨‍💻","aliases":["man_technologist"]},{"emoji":"👲","aliases":["man_with_gua_pi_mao"]},{"emoji":"👨‍🦯","aliases":["man_with_probing_cane"]},{"emoji":"👳‍♂️","aliases":["man_with_turban"]},{"emoji":"👰‍♂️","aliases":["man_with_veil"]},{"emoji":"🥭","aliases":["mango"]},{"emoji":"👞","aliases":["mans_shoe","shoe"]},{"emoji":"🕰️","aliases":["mantelpiece_clock"]},{"emoji":"🦽","aliases":["manual_wheelchair"]},{"emoji":"🍁","aliases":["maple_leaf"]},{"emoji":"🪇","aliases":["maracas"]},{"emoji":"🇲🇭","aliases":["marshall_islands"]},{"emoji":"🥋","aliases":["martial_arts_uniform"]},{"emoji":"🇲🇶","aliases":["martinique"]},{"emoji":"😷","aliases":["mask"]},{"emoji":"💆","aliases":["massage"]},{"emoji":"💆‍♂️","aliases":["massage_man"]},{"emoji":"💆‍♀️","aliases":["massage_woman"]},{"emoji":"🧉","aliases":["mate"]},{"emoji":"🇲🇷","aliases":["mauritania"]},{"emoji":"🇲🇺","aliases":["mauritius"]},{"emoji":"🇾🇹","aliases":["mayotte"]},{"emoji":"🍖","aliases":["meat_on_bone"]},{"emoji":"🧑‍🔧","aliases":["mechanic"]},{"emoji":"🦾","aliases":["mechanical_arm"]},{"emoji":"🦿","aliases":["mechanical_leg"]},{"emoji":"🎖️","aliases":["medal_military"]},{"emoji":"🏅","aliases":["medal_sports"]},{"emoji":"⚕️","aliases":["medical_symbol"]},{"emoji":"📣","aliases":["mega"]},{"emoji":"🍈","aliases":["melon"]},{"emoji":"🫠","aliases":["melting_face"]},{"emoji":"📝","aliases":["memo","pencil"]},{"emoji":"🤼‍♂️","aliases":["men_wrestling"]},{"emoji":"❤️‍🩹","aliases":["mending_heart"]},{"emoji":"🕎","aliases":["menorah"]},{"emoji":"🚹","aliases":["mens"]},{"emoji":"🧜‍♀️","aliases":["mermaid"]},{"emoji":"🧜‍♂️","aliases":["merman"]},{"emoji":"🧜","aliases":["merperson"]},{"emoji":"🤘","aliases":["metal"]},{"emoji":"🚇","aliases":["metro"]},{"emoji":"🇲🇽","aliases":["mexico"]},{"emoji":"🦠","aliases":["microbe"]},{"emoji":"🇫🇲","aliases":["micronesia"]},{"emoji":"🎤","aliases":["microphone"]},{"emoji":"🔬","aliases":["microscope"]},{"emoji":"🖕","aliases":["middle_finger","fu"]},{"emoji":"🪖","aliases":["military_helmet"]},{"emoji":"🥛","aliases":["milk_glass"]},{"emoji":"🌌","aliases":["milky_way"]},{"emoji":"🚐","aliases":["minibus"]},{"emoji":"💽","aliases":["minidisc"]},{"emoji":"🪞","aliases":["mirror"]},{"emoji":"🪩","aliases":["mirror_ball"]},{"emoji":"📴","aliases":["mobile_phone_off"]},{"emoji":"🇲🇩","aliases":["moldova"]},{"emoji":"🇲🇨","aliases":["monaco"]},{"emoji":"🤑","aliases":["money_mouth_face"]},{"emoji":"💸","aliases":["money_with_wings"]},{"emoji":"💰","aliases":["moneybag"]},{"emoji":"🇲🇳","aliases":["mongolia"]},{"emoji":"🐒","aliases":["monkey"]},{"emoji":"🐵","aliases":["monkey_face"]},{"emoji":"🧐","aliases":["monocle_face"]},{"emoji":"🚝","aliases":["monorail"]},{"emoji":"🇲🇪","aliases":["montenegro"]},{"emoji":"🇲🇸","aliases":["montserrat"]},{"emoji":"🌔","aliases":["moon","waxing_gibbous_moon"]},{"emoji":"🥮","aliases":["moon_cake"]},{"emoji":"🫎","aliases":["moose"]},{"emoji":"🇲🇦","aliases":["morocco"]},{"emoji":"🎓","aliases":["mortar_board"]},{"emoji":"🕌","aliases":["mosque"]},{"emoji":"🦟","aliases":["mosquito"]},{"emoji":"🛥️","aliases":["motor_boat"]},{"emoji":"🛵","aliases":["motor_scooter"]},{"emoji":"🏍️","aliases":["motorcycle"]},{"emoji":"🦼","aliases":["motorized_wheelchair"]},{"emoji":"🛣️","aliases":["motorway"]},{"emoji":"🗻","aliases":["mount_fuji"]},{"emoji":"⛰️","aliases":["mountain"]},{"emoji":"🚵","aliases":["mountain_bicyclist"]},{"emoji":"🚵‍♂️","aliases":["mountain_biking_man"]},{"emoji":"🚵‍♀️","aliases":["mountain_biking_woman"]},{"emoji":"🚠","aliases":["mountain_cableway"]},{"emoji":"🚞","aliases":["mountain_railway"]},{"emoji":"🏔️","aliases":["mountain_snow"]},{"emoji":"🐭","aliases":["mouse"]},{"emoji":"🐁","aliases":["mouse2"]},{"emoji":"🪤","aliases":["mouse_trap"]},{"emoji":"🎥","aliases":["movie_camera"]},{"emoji":"🗿","aliases":["moyai"]},{"emoji":"🇲🇿","aliases":["mozambique"]},{"emoji":"🤶","aliases":["mrs_claus"]},{"emoji":"💪","aliases":["muscle"]},{"emoji":"🍄","aliases":["mushroom"]},{"emoji":"🎹","aliases":["musical_keyboard"]},{"emoji":"🎵","aliases":["musical_note"]},{"emoji":"🎼","aliases":["musical_score"]},{"emoji":"🔇","aliases":["mute"]},{"emoji":"🧑‍🎄","aliases":["mx_claus"]},{"emoji":"🇲🇲","aliases":["myanmar"]},{"emoji":"💅","aliases":["nail_care"]},{"emoji":"📛","aliases":["name_badge"]},{"emoji":"🇳🇦","aliases":["namibia"]},{"emoji":"🏞️","aliases":["national_park"]},{"emoji":"🇳🇷","aliases":["nauru"]},{"emoji":"🤢","aliases":["nauseated_face"]},{"emoji":"🧿","aliases":["nazar_amulet"]},{"emoji":"👔","aliases":["necktie"]},{"emoji":"❎","aliases":["negative_squared_cross_mark"]},{"emoji":"🇳🇵","aliases":["nepal"]},{"emoji":"🤓","aliases":["nerd_face"]},{"emoji":"🪺","aliases":["nest_with_eggs"]},{"emoji":"🪆","aliases":["nesting_dolls"]},{"emoji":"🇳🇱","aliases":["netherlands"]},{"emoji":"😐","aliases":["neutral_face"]},{"emoji":"🆕","aliases":["new"]},{"emoji":"🇳🇨","aliases":["new_caledonia"]},{"emoji":"🌑","aliases":["new_moon"]},{"emoji":"🌚","aliases":["new_moon_with_face"]},{"emoji":"🇳🇿","aliases":["new_zealand"]},{"emoji":"📰","aliases":["newspaper"]},{"emoji":"🗞️","aliases":["newspaper_roll"]},{"emoji":"⏭️","aliases":["next_track_button"]},{"emoji":"🆖","aliases":["ng"]},{"emoji":"🇳🇮","aliases":["nicaragua"]},{"emoji":"🇳🇪","aliases":["niger"]},{"emoji":"🇳🇬","aliases":["nigeria"]},{"emoji":"🌃","aliases":["night_with_stars"]},{"emoji":"9️⃣","aliases":["nine"]},{"emoji":"🥷","aliases":["ninja"]},{"emoji":"🇳🇺","aliases":["niue"]},{"emoji":"🔕","aliases":["no_bell"]},{"emoji":"🚳","aliases":["no_bicycles"]},{"emoji":"⛔","aliases":["no_entry"]},{"emoji":"🚫","aliases":["no_entry_sign"]},{"emoji":"🙅","aliases":["no_good"]},{"emoji":"🙅‍♂️","aliases":["no_good_man","ng_man"]},{"emoji":"🙅‍♀️","aliases":["no_good_woman","ng_woman"]},{"emoji":"📵","aliases":["no_mobile_phones"]},{"emoji":"😶","aliases":["no_mouth"]},{"emoji":"🚷","aliases":["no_pedestrians"]},{"emoji":"🚭","aliases":["no_smoking"]},{"emoji":"🚱","aliases":["non-potable_water"]},{"emoji":"🇳🇫","aliases":["norfolk_island"]},{"emoji":"🇰🇵","aliases":["north_korea"]},{"emoji":"🇲🇵","aliases":["northern_mariana_islands"]},{"emoji":"🇳🇴","aliases":["norway"]},{"emoji":"👃","aliases":["nose"]},{"emoji":"📓","aliases":["notebook"]},{"emoji":"📔","aliases":["notebook_with_decorative_cover"]},{"emoji":"🎶","aliases":["notes"]},{"emoji":"🔩","aliases":["nut_and_bolt"]},{"emoji":"⭕","aliases":["o"]},{"emoji":"🅾️","aliases":["o2"]},{"emoji":"🌊","aliases":["ocean"]},{"emoji":"🐙","aliases":["octopus"]},{"emoji":"🍢","aliases":["oden"]},{"emoji":"🏢","aliases":["office"]},{"emoji":"🧑‍💼","aliases":["office_worker"]},{"emoji":"🛢️","aliases":["oil_drum"]},{"emoji":"🆗","aliases":["ok"]},{"emoji":"👌","aliases":["ok_hand"]},{"emoji":"🙆‍♂️","aliases":["ok_man"]},{"emoji":"🙆","aliases":["ok_person"]},{"emoji":"🙆‍♀️","aliases":["ok_woman"]},{"emoji":"🗝️","aliases":["old_key"]},{"emoji":"🧓","aliases":["older_adult"]},{"emoji":"👴","aliases":["older_man"]},{"emoji":"👵","aliases":["older_woman"]},{"emoji":"🫒","aliases":["olive"]},{"emoji":"🕉️","aliases":["om"]},{"emoji":"🇴🇲","aliases":["oman"]},{"emoji":"🔛","aliases":["on"]},{"emoji":"🚘","aliases":["oncoming_automobile"]},{"emoji":"🚍","aliases":["oncoming_bus"]},{"emoji":"🚔","aliases":["oncoming_police_car"]},{"emoji":"🚖","aliases":["oncoming_taxi"]},{"emoji":"1️⃣","aliases":["one"]},{"emoji":"🩱","aliases":["one_piece_swimsuit"]},{"emoji":"🧅","aliases":["onion"]},{"emoji":"📂","aliases":["open_file_folder"]},{"emoji":"👐","aliases":["open_hands"]},{"emoji":"😮","aliases":["open_mouth"]},{"emoji":"☂️","aliases":["open_umbrella"]},{"emoji":"⛎","aliases":["ophiuchus"]},{"emoji":"📙","aliases":["orange_book"]},{"emoji":"🟠","aliases":["orange_circle"]},{"emoji":"🧡","aliases":["orange_heart"]},{"emoji":"🟧","aliases":["orange_square"]},{"emoji":"🦧","aliases":["orangutan"]},{"emoji":"☦️","aliases":["orthodox_cross"]},{"emoji":"🦦","aliases":["otter"]},{"emoji":"📤","aliases":["outbox_tray"]},{"emoji":"🦉","aliases":["owl"]},{"emoji":"🐂","aliases":["ox"]},{"emoji":"🦪","aliases":["oyster"]},{"emoji":"📦","aliases":["package"]},{"emoji":"📄","aliases":["page_facing_up"]},{"emoji":"📃","aliases":["page_with_curl"]},{"emoji":"📟","aliases":["pager"]},{"emoji":"🖌️","aliases":["paintbrush"]},{"emoji":"🇵🇰","aliases":["pakistan"]},{"emoji":"🇵🇼","aliases":["palau"]},{"emoji":"🇵🇸","aliases":["palestinian_territories"]},{"emoji":"🫳","aliases":["palm_down_hand"]},{"emoji":"🌴","aliases":["palm_tree"]},{"emoji":"🫴","aliases":["palm_up_hand"]},{"emoji":"🤲","aliases":["palms_up_together"]},{"emoji":"🇵🇦","aliases":["panama"]},{"emoji":"🥞","aliases":["pancakes"]},{"emoji":"🐼","aliases":["panda_face"]},{"emoji":"📎","aliases":["paperclip"]},{"emoji":"🖇️","aliases":["paperclips"]},{"emoji":"🇵🇬","aliases":["papua_new_guinea"]},{"emoji":"🪂","aliases":["parachute"]},{"emoji":"🇵🇾","aliases":["paraguay"]},{"emoji":"⛱️","aliases":["parasol_on_ground"]},{"emoji":"🅿️","aliases":["parking"]},{"emoji":"🦜","aliases":["parrot"]},{"emoji":"〽️","aliases":["part_alternation_mark"]},{"emoji":"⛅","aliases":["partly_sunny"]},{"emoji":"🥳","aliases":["partying_face"]},{"emoji":"🛳️","aliases":["passenger_ship"]},{"emoji":"🛂","aliases":["passport_control"]},{"emoji":"⏸️","aliases":["pause_button"]},{"emoji":"🫛","aliases":["pea_pod"]},{"emoji":"☮️","aliases":["peace_symbol"]},{"emoji":"🍑","aliases":["peach"]},{"emoji":"🦚","aliases":["peacock"]},{"emoji":"🥜","aliases":["peanuts"]},{"emoji":"🍐","aliases":["pear"]},{"emoji":"🖊️","aliases":["pen"]},{"emoji":"✏️","aliases":["pencil2"]},{"emoji":"🐧","aliases":["penguin"]},{"emoji":"😔","aliases":["pensive"]},{"emoji":"🧑‍🤝‍🧑","aliases":["people_holding_hands"]},{"emoji":"🫂","aliases":["people_hugging"]},{"emoji":"🎭","aliases":["performing_arts"]},{"emoji":"😣","aliases":["persevere"]},{"emoji":"🧑‍🦲","aliases":["person_bald"]},{"emoji":"🧑‍🦱","aliases":["person_curly_hair"]},{"emoji":"🧑‍🍼","aliases":["person_feeding_baby"]},{"emoji":"🤺","aliases":["person_fencing"]},{"emoji":"🧑‍🦽","aliases":["person_in_manual_wheelchair"]},{"emoji":"🧑‍🦼","aliases":["person_in_motorized_wheelchair"]},{"emoji":"🤵","aliases":["person_in_tuxedo"]},{"emoji":"🧑‍🦰","aliases":["person_red_hair"]},{"emoji":"🧑‍🦳","aliases":["person_white_hair"]},{"emoji":"🫅","aliases":["person_with_crown"]},{"emoji":"🧑‍🦯","aliases":["person_with_probing_cane"]},{"emoji":"👳","aliases":["person_with_turban"]},{"emoji":"👰","aliases":["person_with_veil"]},{"emoji":"🇵🇪","aliases":["peru"]},{"emoji":"🧫","aliases":["petri_dish"]},{"emoji":"🇵🇭","aliases":["philippines"]},{"emoji":"☎️","aliases":["phone","telephone"]},{"emoji":"⛏️","aliases":["pick"]},{"emoji":"🛻","aliases":["pickup_truck"]},{"emoji":"🥧","aliases":["pie"]},{"emoji":"🐷","aliases":["pig"]},{"emoji":"🐖","aliases":["pig2"]},{"emoji":"🐽","aliases":["pig_nose"]},{"emoji":"💊","aliases":["pill"]},{"emoji":"🧑‍✈️","aliases":["pilot"]},{"emoji":"🪅","aliases":["pinata"]},{"emoji":"🤌","aliases":["pinched_fingers"]},{"emoji":"🤏","aliases":["pinching_hand"]},{"emoji":"🍍","aliases":["pineapple"]},{"emoji":"🏓","aliases":["ping_pong"]},{"emoji":"🩷","aliases":["pink_heart"]},{"emoji":"🏴‍☠️","aliases":["pirate_flag"]},{"emoji":"♓","aliases":["pisces"]},{"emoji":"🇵🇳","aliases":["pitcairn_islands"]},{"emoji":"🍕","aliases":["pizza"]},{"emoji":"🪧","aliases":["placard"]},{"emoji":"🛐","aliases":["place_of_worship"]},{"emoji":"🍽️","aliases":["plate_with_cutlery"]},{"emoji":"⏯️","aliases":["play_or_pause_button"]},{"emoji":"🛝","aliases":["playground_slide"]},{"emoji":"🥺","aliases":["pleading_face"]},{"emoji":"🪠","aliases":["plunger"]},{"emoji":"👇","aliases":["point_down"]},{"emoji":"👈","aliases":["point_left"]},{"emoji":"👉","aliases":["point_right"]},{"emoji":"☝️","aliases":["point_up"]},{"emoji":"👆","aliases":["point_up_2"]},{"emoji":"🇵🇱","aliases":["poland"]},{"emoji":"🐻‍❄️","aliases":["polar_bear"]},{"emoji":"🚓","aliases":["police_car"]},{"emoji":"👮","aliases":["police_officer","cop"]},{"emoji":"👮‍♂️","aliases":["policeman"]},{"emoji":"👮‍♀️","aliases":["policewoman"]},{"emoji":"🐩","aliases":["poodle"]},{"emoji":"🍿","aliases":["popcorn"]},{"emoji":"🇵🇹","aliases":["portugal"]},{"emoji":"🏣","aliases":["post_office"]},{"emoji":"📯","aliases":["postal_horn"]},{"emoji":"📮","aliases":["postbox"]},{"emoji":"🚰","aliases":["potable_water"]},{"emoji":"🥔","aliases":["potato"]},{"emoji":"🪴","aliases":["potted_plant"]},{"emoji":"👝","aliases":["pouch"]},{"emoji":"🍗","aliases":["poultry_leg"]},{"emoji":"💷","aliases":["pound"]},{"emoji":"🫗","aliases":["pouring_liquid"]},{"emoji":"😾","aliases":["pouting_cat"]},{"emoji":"🙎","aliases":["pouting_face"]},{"emoji":"🙎‍♂️","aliases":["pouting_man"]},{"emoji":"🙎‍♀️","aliases":["pouting_woman"]},{"emoji":"🙏","aliases":["pray"]},{"emoji":"📿","aliases":["prayer_beads"]},{"emoji":"🫃","aliases":["pregnant_man"]},{"emoji":"🫄","aliases":["pregnant_person"]},{"emoji":"🤰","aliases":["pregnant_woman"]},{"emoji":"🥨","aliases":["pretzel"]},{"emoji":"⏮️","aliases":["previous_track_button"]},{"emoji":"🤴","aliases":["prince"]},{"emoji":"👸","aliases":["princess"]},{"emoji":"🖨️","aliases":["printer"]},{"emoji":"🦯","aliases":["probing_cane"]},{"emoji":"🇵🇷","aliases":["puerto_rico"]},{"emoji":"🟣","aliases":["purple_circle"]},{"emoji":"💜","aliases":["purple_heart"]},{"emoji":"🟪","aliases":["purple_square"]},{"emoji":"👛","aliases":["purse"]},{"emoji":"📌","aliases":["pushpin"]},{"emoji":"🚮","aliases":["put_litter_in_its_place"]},{"emoji":"🇶🇦","aliases":["qatar"]},{"emoji":"❓","aliases":["question"]},{"emoji":"🐰","aliases":["rabbit"]},{"emoji":"🐇","aliases":["rabbit2"]},{"emoji":"🦝","aliases":["raccoon"]},{"emoji":"🐎","aliases":["racehorse"]},{"emoji":"🏎️","aliases":["racing_car"]},{"emoji":"📻","aliases":["radio"]},{"emoji":"🔘","aliases":["radio_button"]},{"emoji":"☢️","aliases":["radioactive"]},{"emoji":"😡","aliases":["rage","pout"]},{"emoji":"🚃","aliases":["railway_car"]},{"emoji":"🛤️","aliases":["railway_track"]},{"emoji":"🌈","aliases":["rainbow"]},{"emoji":"🏳️‍🌈","aliases":["rainbow_flag"]},{"emoji":"🤚","aliases":["raised_back_of_hand"]},{"emoji":"🤨","aliases":["raised_eyebrow"]},{"emoji":"🖐️","aliases":["raised_hand_with_fingers_splayed"]},{"emoji":"🙌","aliases":["raised_hands"]},{"emoji":"🙋","aliases":["raising_hand"]},{"emoji":"🙋‍♂️","aliases":["raising_hand_man"]},{"emoji":"🙋‍♀️","aliases":["raising_hand_woman"]},{"emoji":"🐏","aliases":["ram"]},{"emoji":"🍜","aliases":["ramen"]},{"emoji":"🐀","aliases":["rat"]},{"emoji":"🪒","aliases":["razor"]},{"emoji":"🧾","aliases":["receipt"]},{"emoji":"⏺️","aliases":["record_button"]},{"emoji":"♻️","aliases":["recycle"]},{"emoji":"🔴","aliases":["red_circle"]},{"emoji":"🧧","aliases":["red_envelope"]},{"emoji":"👨‍🦰","aliases":["red_haired_man"]},{"emoji":"👩‍🦰","aliases":["red_haired_woman"]},{"emoji":"🟥","aliases":["red_square"]},{"emoji":"®️","aliases":["registered"]},{"emoji":"☺️","aliases":["relaxed"]},{"emoji":"😌","aliases":["relieved"]},{"emoji":"🎗️","aliases":["reminder_ribbon"]},{"emoji":"🔁","aliases":["repeat"]},{"emoji":"🔂","aliases":["repeat_one"]},{"emoji":"⛑️","aliases":["rescue_worker_helmet"]},{"emoji":"🚻","aliases":["restroom"]},{"emoji":"🇷🇪","aliases":["reunion"]},{"emoji":"💞","aliases":["revolving_hearts"]},{"emoji":"⏪","aliases":["rewind"]},{"emoji":"🦏","aliases":["rhinoceros"]},{"emoji":"🎀","aliases":["ribbon"]},{"emoji":"🍚","aliases":["rice"]},{"emoji":"🍙","aliases":["rice_ball"]},{"emoji":"🍘","aliases":["rice_cracker"]},{"emoji":"🎑","aliases":["rice_scene"]},{"emoji":"🗯️","aliases":["right_anger_bubble"]},{"emoji":"🫱","aliases":["rightwards_hand"]},{"emoji":"🫸","aliases":["rightwards_pushing_hand"]},{"emoji":"💍","aliases":["ring"]},{"emoji":"🛟","aliases":["ring_buoy"]},{"emoji":"🪐","aliases":["ringed_planet"]},{"emoji":"🤖","aliases":["robot"]},{"emoji":"🪨","aliases":["rock"]},{"emoji":"🚀","aliases":["rocket"]},{"emoji":"🤣","aliases":["rofl"]},{"emoji":"🙄","aliases":["roll_eyes"]},{"emoji":"🧻","aliases":["roll_of_paper"]},{"emoji":"🎢","aliases":["roller_coaster"]},{"emoji":"🛼","aliases":["roller_skate"]},{"emoji":"🇷🇴","aliases":["romania"]},{"emoji":"🐓","aliases":["rooster"]},{"emoji":"🌹","aliases":["rose"]},{"emoji":"🏵️","aliases":["rosette"]},{"emoji":"🚨","aliases":["rotating_light"]},{"emoji":"📍","aliases":["round_pushpin"]},{"emoji":"🚣","aliases":["rowboat"]},{"emoji":"🚣‍♂️","aliases":["rowing_man"]},{"emoji":"🚣‍♀️","aliases":["rowing_woman"]},{"emoji":"🇷🇺","aliases":["ru"]},{"emoji":"🏉","aliases":["rugby_football"]},{"emoji":"🏃","aliases":["runner","running"]},{"emoji":"🏃‍♂️","aliases":["running_man"]},{"emoji":"🎽","aliases":["running_shirt_with_sash"]},{"emoji":"🏃‍♀️","aliases":["running_woman"]},{"emoji":"🇷🇼","aliases":["rwanda"]},{"emoji":"🈂️","aliases":["sa"]},{"emoji":"🧷","aliases":["safety_pin"]},{"emoji":"🦺","aliases":["safety_vest"]},{"emoji":"♐","aliases":["sagittarius"]},{"emoji":"🍶","aliases":["sake"]},{"emoji":"🧂","aliases":["salt"]},{"emoji":"🫡","aliases":["saluting_face"]},{"emoji":"🇼🇸","aliases":["samoa"]},{"emoji":"🇸🇲","aliases":["san_marino"]},{"emoji":"👡","aliases":["sandal"]},{"emoji":"🥪","aliases":["sandwich"]},{"emoji":"🎅","aliases":["santa"]},{"emoji":"🇸🇹","aliases":["sao_tome_principe"]},{"emoji":"🥻","aliases":["sari"]},{"emoji":"📡","aliases":["satellite"]},{"emoji":"🇸🇦","aliases":["saudi_arabia"]},{"emoji":"🧖‍♂️","aliases":["sauna_man"]},{"emoji":"🧖","aliases":["sauna_person"]},{"emoji":"🧖‍♀️","aliases":["sauna_woman"]},{"emoji":"🦕","aliases":["sauropod"]},{"emoji":"🎷","aliases":["saxophone"]},{"emoji":"🧣","aliases":["scarf"]},{"emoji":"🏫","aliases":["school"]},{"emoji":"🎒","aliases":["school_satchel"]},{"emoji":"🧑‍🔬","aliases":["scientist"]},{"emoji":"✂️","aliases":["scissors"]},{"emoji":"🦂","aliases":["scorpion"]},{"emoji":"♏","aliases":["scorpius"]},{"emoji":"🏴󠁧󠁢󠁳󠁣󠁴󠁿","aliases":["scotland"]},{"emoji":"😱","aliases":["scream"]},{"emoji":"🙀","aliases":["scream_cat"]},{"emoji":"🪛","aliases":["screwdriver"]},{"emoji":"📜","aliases":["scroll"]},{"emoji":"🦭","aliases":["seal"]},{"emoji":"💺","aliases":["seat"]},{"emoji":"㊙️","aliases":["secret"]},{"emoji":"🙈","aliases":["see_no_evil"]},{"emoji":"🌱","aliases":["seedling"]},{"emoji":"🤳","aliases":["selfie"]},{"emoji":"🇸🇳","aliases":["senegal"]},{"emoji":"🇷🇸","aliases":["serbia"]},{"emoji":"🐕‍🦺","aliases":["service_dog"]},{"emoji":"7️⃣","aliases":["seven"]},{"emoji":"🪡","aliases":["sewing_needle"]},{"emoji":"🇸🇨","aliases":["seychelles"]},{"emoji":"🫨","aliases":["shaking_face"]},{"emoji":"🥘","aliases":["shallow_pan_of_food"]},{"emoji":"☘️","aliases":["shamrock"]},{"emoji":"🦈","aliases":["shark"]},{"emoji":"🍧","aliases":["shaved_ice"]},{"emoji":"🐑","aliases":["sheep"]},{"emoji":"🐚","aliases":["shell"]},{"emoji":"🛡️","aliases":["shield"]},{"emoji":"⛩️","aliases":["shinto_shrine"]},{"emoji":"🚢","aliases":["ship"]},{"emoji":"👕","aliases":["shirt","tshirt"]},{"emoji":"🛍️","aliases":["shopping"]},{"emoji":"🛒","aliases":["shopping_cart"]},{"emoji":"🩳","aliases":["shorts"]},{"emoji":"🚿","aliases":["shower"]},{"emoji":"🦐","aliases":["shrimp"]},{"emoji":"🤷","aliases":["shrug"]},{"emoji":"🤫","aliases":["shushing_face"]},{"emoji":"🇸🇱","aliases":["sierra_leone"]},{"emoji":"📶","aliases":["signal_strength"]},{"emoji":"🇸🇬","aliases":["singapore"]},{"emoji":"🧑‍🎤","aliases":["singer"]},{"emoji":"🇸🇽","aliases":["sint_maarten"]},{"emoji":"6️⃣","aliases":["six"]},{"emoji":"🔯","aliases":["six_pointed_star"]},{"emoji":"🛹","aliases":["skateboard"]},{"emoji":"🎿","aliases":["ski"]},{"emoji":"⛷️","aliases":["skier"]},{"emoji":"💀","aliases":["skull"]},{"emoji":"☠️","aliases":["skull_and_crossbones"]},{"emoji":"🦨","aliases":["skunk"]},{"emoji":"🛷","aliases":["sled"]},{"emoji":"😴","aliases":["sleeping"]},{"emoji":"🛌","aliases":["sleeping_bed"]},{"emoji":"😪","aliases":["sleepy"]},{"emoji":"🙁","aliases":["slightly_frowning_face"]},{"emoji":"🙂","aliases":["slightly_smiling_face"]},{"emoji":"🎰","aliases":["slot_machine"]},{"emoji":"🦥","aliases":["sloth"]},{"emoji":"🇸🇰","aliases":["slovakia"]},{"emoji":"🇸🇮","aliases":["slovenia"]},{"emoji":"🛩️","aliases":["small_airplane"]},{"emoji":"🔹","aliases":["small_blue_diamond"]},{"emoji":"🔸","aliases":["small_orange_diamond"]},{"emoji":"🔺","aliases":["small_red_triangle"]},{"emoji":"🔻","aliases":["small_red_triangle_down"]},{"emoji":"😄","aliases":["smile"]},{"emoji":"😸","aliases":["smile_cat"]},{"emoji":"😃","aliases":["smiley"]},{"emoji":"😺","aliases":["smiley_cat"]},{"emoji":"🥲","aliases":["smiling_face_with_tear"]},{"emoji":"🥰","aliases":["smiling_face_with_three_hearts"]},{"emoji":"😈","aliases":["smiling_imp"]},{"emoji":"😏","aliases":["smirk"]},{"emoji":"😼","aliases":["smirk_cat"]},{"emoji":"🚬","aliases":["smoking"]},{"emoji":"🐌","aliases":["snail"]},{"emoji":"🐍","aliases":["snake"]},{"emoji":"🤧","aliases":["sneezing_face"]},{"emoji":"🏂","aliases":["snowboarder"]},{"emoji":"❄️","aliases":["snowflake"]},{"emoji":"⛄","aliases":["snowman"]},{"emoji":"☃️","aliases":["snowman_with_snow"]},{"emoji":"🧼","aliases":["soap"]},{"emoji":"😭","aliases":["sob"]},{"emoji":"⚽","aliases":["soccer"]},{"emoji":"🧦","aliases":["socks"]},{"emoji":"🥎","aliases":["softball"]},{"emoji":"🇸🇧","aliases":["solomon_islands"]},{"emoji":"🇸🇴","aliases":["somalia"]},{"emoji":"🔜","aliases":["soon"]},{"emoji":"🆘","aliases":["sos"]},{"emoji":"🔉","aliases":["sound"]},{"emoji":"🇿🇦","aliases":["south_africa"]},{"emoji":"🇬🇸","aliases":["south_georgia_south_sandwich_islands"]},{"emoji":"🇸🇸","aliases":["south_sudan"]},{"emoji":"👾","aliases":["space_invader"]},{"emoji":"♠️","aliases":["spades"]},{"emoji":"🍝","aliases":["spaghetti"]},{"emoji":"❇️","aliases":["sparkle"]},{"emoji":"🎇","aliases":["sparkler"]},{"emoji":"✨","aliases":["sparkles"]},{"emoji":"💖","aliases":["sparkling_heart"]},{"emoji":"🙊","aliases":["speak_no_evil"]},{"emoji":"🔈","aliases":["speaker"]},{"emoji":"🗣️","aliases":["speaking_head"]},{"emoji":"💬","aliases":["speech_balloon"]},{"emoji":"🚤","aliases":["speedboat"]},{"emoji":"🕷️","aliases":["spider"]},{"emoji":"🕸️","aliases":["spider_web"]},{"emoji":"🗓️","aliases":["spiral_calendar"]},{"emoji":"🗒️","aliases":["spiral_notepad"]},{"emoji":"🧽","aliases":["sponge"]},{"emoji":"🥄","aliases":["spoon"]},{"emoji":"🦑","aliases":["squid"]},{"emoji":"🇱🇰","aliases":["sri_lanka"]},{"emoji":"🇧🇱","aliases":["st_barthelemy"]},{"emoji":"🇸🇭","aliases":["st_helena"]},{"emoji":"🇰🇳","aliases":["st_kitts_nevis"]},{"emoji":"🇱🇨","aliases":["st_lucia"]},{"emoji":"🇲🇫","aliases":["st_martin"]},{"emoji":"🇵🇲","aliases":["st_pierre_miquelon"]},{"emoji":"🇻🇨","aliases":["st_vincent_grenadines"]},{"emoji":"🏟️","aliases":["stadium"]},{"emoji":"🧍‍♂️","aliases":["standing_man"]},{"emoji":"🧍","aliases":["standing_person"]},{"emoji":"🧍‍♀️","aliases":["standing_woman"]},{"emoji":"⭐","aliases":["star"]},{"emoji":"🌟","aliases":["star2"]},{"emoji":"☪️","aliases":["star_and_crescent"]},{"emoji":"✡️","aliases":["star_of_david"]},{"emoji":"🤩","aliases":["star_struck"]},{"emoji":"🌠","aliases":["stars"]},{"emoji":"🚉","aliases":["station"]},{"emoji":"🗽","aliases":["statue_of_liberty"]},{"emoji":"🚂","aliases":["steam_locomotive"]},{"emoji":"🩺","aliases":["stethoscope"]},{"emoji":"🍲","aliases":["stew"]},{"emoji":"⏹️","aliases":["stop_button"]},{"emoji":"🛑","aliases":["stop_sign"]},{"emoji":"⏱️","aliases":["stopwatch"]},{"emoji":"📏","aliases":["straight_ruler"]},{"emoji":"🍓","aliases":["strawberry"]},{"emoji":"😛","aliases":["stuck_out_tongue"]},{"emoji":"😝","aliases":["stuck_out_tongue_closed_eyes"]},{"emoji":"😜","aliases":["stuck_out_tongue_winking_eye"]},{"emoji":"🧑‍🎓","aliases":["student"]},{"emoji":"🎙️","aliases":["studio_microphone"]},{"emoji":"🥙","aliases":["stuffed_flatbread"]},{"emoji":"🇸🇩","aliases":["sudan"]},{"emoji":"🌥️","aliases":["sun_behind_large_cloud"]},{"emoji":"🌦️","aliases":["sun_behind_rain_cloud"]},{"emoji":"🌤️","aliases":["sun_behind_small_cloud"]},{"emoji":"🌞","aliases":["sun_with_face"]},{"emoji":"🌻","aliases":["sunflower"]},{"emoji":"😎","aliases":["sunglasses"]},{"emoji":"☀️","aliases":["sunny"]},{"emoji":"🌅","aliases":["sunrise"]},{"emoji":"🌄","aliases":["sunrise_over_mountains"]},{"emoji":"🦸","aliases":["superhero"]},{"emoji":"🦸‍♂️","aliases":["superhero_man"]},{"emoji":"🦸‍♀️","aliases":["superhero_woman"]},{"emoji":"🦹","aliases":["supervillain"]},{"emoji":"🦹‍♂️","aliases":["supervillain_man"]},{"emoji":"🦹‍♀️","aliases":["supervillain_woman"]},{"emoji":"🏄","aliases":["surfer"]},{"emoji":"🏄‍♂️","aliases":["surfing_man"]},{"emoji":"🏄‍♀️","aliases":["surfing_woman"]},{"emoji":"🇸🇷","aliases":["suriname"]},{"emoji":"🍣","aliases":["sushi"]},{"emoji":"🚟","aliases":["suspension_railway"]},{"emoji":"🇸🇯","aliases":["svalbard_jan_mayen"]},{"emoji":"🦢","aliases":["swan"]},{"emoji":"🇸🇿","aliases":["swaziland"]},{"emoji":"😓","aliases":["sweat"]},{"emoji":"💦","aliases":["sweat_drops"]},{"emoji":"😅","aliases":["sweat_smile"]},{"emoji":"🇸🇪","aliases":["sweden"]},{"emoji":"🍠","aliases":["sweet_potato"]},{"emoji":"🩲","aliases":["swim_brief"]},{"emoji":"🏊","aliases":["swimmer"]},{"emoji":"🏊‍♂️","aliases":["swimming_man"]},{"emoji":"🏊‍♀️","aliases":["swimming_woman"]},{"emoji":"🇨🇭","aliases":["switzerland"]},{"emoji":"🔣","aliases":["symbols"]},{"emoji":"🕍","aliases":["synagogue"]},{"emoji":"🇸🇾","aliases":["syria"]},{"emoji":"💉","aliases":["syringe"]},{"emoji":"🦖","aliases":["t-rex"]},{"emoji":"🌮","aliases":["taco"]},{"emoji":"🎉","aliases":["tada","hooray"]},{"emoji":"🇹🇼","aliases":["taiwan"]},{"emoji":"🇹🇯","aliases":["tajikistan"]},{"emoji":"🥡","aliases":["takeout_box"]},{"emoji":"🫔","aliases":["tamale"]},{"emoji":"🎋","aliases":["tanabata_tree"]},{"emoji":"🍊","aliases":["tangerine","orange","mandarin"]},{"emoji":"🇹🇿","aliases":["tanzania"]},{"emoji":"♉","aliases":["taurus"]},{"emoji":"🚕","aliases":["taxi"]},{"emoji":"🍵","aliases":["tea"]},{"emoji":"🧑‍🏫","aliases":["teacher"]},{"emoji":"🫖","aliases":["teapot"]},{"emoji":"🧑‍💻","aliases":["technologist"]},{"emoji":"🧸","aliases":["teddy_bear"]},{"emoji":"📞","aliases":["telephone_receiver"]},{"emoji":"🔭","aliases":["telescope"]},{"emoji":"🎾","aliases":["tennis"]},{"emoji":"⛺","aliases":["tent"]},{"emoji":"🧪","aliases":["test_tube"]},{"emoji":"🇹🇭","aliases":["thailand"]},{"emoji":"🌡️","aliases":["thermometer"]},{"emoji":"🤔","aliases":["thinking"]},{"emoji":"🩴","aliases":["thong_sandal"]},{"emoji":"💭","aliases":["thought_balloon"]},{"emoji":"🧵","aliases":["thread"]},{"emoji":"3️⃣","aliases":["three"]},{"emoji":"🎫","aliases":["ticket"]},{"emoji":"🎟️","aliases":["tickets"]},{"emoji":"🐯","aliases":["tiger"]},{"emoji":"🐅","aliases":["tiger2"]},{"emoji":"⏲️","aliases":["timer_clock"]},{"emoji":"🇹🇱","aliases":["timor_leste"]},{"emoji":"💁‍♂️","aliases":["tipping_hand_man","sassy_man"]},{"emoji":"💁","aliases":["tipping_hand_person","information_desk_person"]},{"emoji":"💁‍♀️","aliases":["tipping_hand_woman","sassy_woman"]},{"emoji":"😫","aliases":["tired_face"]},{"emoji":"™️","aliases":["tm"]},{"emoji":"🇹🇬","aliases":["togo"]},{"emoji":"🚽","aliases":["toilet"]},{"emoji":"🇹🇰","aliases":["tokelau"]},{"emoji":"🗼","aliases":["tokyo_tower"]},{"emoji":"🍅","aliases":["tomato"]},{"emoji":"🇹🇴","aliases":["tonga"]},{"emoji":"👅","aliases":["tongue"]},{"emoji":"🧰","aliases":["toolbox"]},{"emoji":"🦷","aliases":["tooth"]},{"emoji":"🪥","aliases":["toothbrush"]},{"emoji":"🔝","aliases":["top"]},{"emoji":"🎩","aliases":["tophat"]},{"emoji":"🌪️","aliases":["tornado"]},{"emoji":"🇹🇷","aliases":["tr"]},{"emoji":"🖲️","aliases":["trackball"]},{"emoji":"🚜","aliases":["tractor"]},{"emoji":"🚥","aliases":["traffic_light"]},{"emoji":"🚋","aliases":["train"]},{"emoji":"🚆","aliases":["train2"]},{"emoji":"🚊","aliases":["tram"]},{"emoji":"🏳️‍⚧️","aliases":["transgender_flag"]},{"emoji":"⚧️","aliases":["transgender_symbol"]},{"emoji":"🚩","aliases":["triangular_flag_on_post"]},{"emoji":"📐","aliases":["triangular_ruler"]},{"emoji":"🔱","aliases":["trident"]},{"emoji":"🇹🇹","aliases":["trinidad_tobago"]},{"emoji":"🇹🇦","aliases":["tristan_da_cunha"]},{"emoji":"😤","aliases":["triumph"]},{"emoji":"🧌","aliases":["troll"]},{"emoji":"🚎","aliases":["trolleybus"]},{"emoji":"🏆","aliases":["trophy"]},{"emoji":"🍹","aliases":["tropical_drink"]},{"emoji":"🐠","aliases":["tropical_fish"]},{"emoji":"🚚","aliases":["truck"]},{"emoji":"🎺","aliases":["trumpet"]},{"emoji":"🌷","aliases":["tulip"]},{"emoji":"🥃","aliases":["tumbler_glass"]},{"emoji":"🇹🇳","aliases":["tunisia"]},{"emoji":"🦃","aliases":["turkey"]},{"emoji":"🇹🇲","aliases":["turkmenistan"]},{"emoji":"🇹🇨","aliases":["turks_caicos_islands"]},{"emoji":"🐢","aliases":["turtle"]},{"emoji":"🇹🇻","aliases":["tuvalu"]},{"emoji":"📺","aliases":["tv"]},{"emoji":"🔀","aliases":["twisted_rightwards_arrows"]},{"emoji":"2️⃣","aliases":["two"]},{"emoji":"💕","aliases":["two_hearts"]},{"emoji":"👬","aliases":["two_men_holding_hands"]},{"emoji":"👭","aliases":["two_women_holding_hands"]},{"emoji":"🈹","aliases":["u5272"]},{"emoji":"🈴","aliases":["u5408"]},{"emoji":"🈺","aliases":["u55b6"]},{"emoji":"🈯","aliases":["u6307"]},{"emoji":"🈷️","aliases":["u6708"]},{"emoji":"🈶","aliases":["u6709"]},{"emoji":"🈵","aliases":["u6e80"]},{"emoji":"🈚","aliases":["u7121"]},{"emoji":"🈸","aliases":["u7533"]},{"emoji":"🈲","aliases":["u7981"]},{"emoji":"🈳","aliases":["u7a7a"]},{"emoji":"🇺🇬","aliases":["uganda"]},{"emoji":"🇺🇦","aliases":["ukraine"]},{"emoji":"☔","aliases":["umbrella"]},{"emoji":"😒","aliases":["unamused"]},{"emoji":"🔞","aliases":["underage"]},{"emoji":"🦄","aliases":["unicorn"]},{"emoji":"🇦🇪","aliases":["united_arab_emirates"]},{"emoji":"🇺🇳","aliases":["united_nations"]},{"emoji":"🔓","aliases":["unlock"]},{"emoji":"🆙","aliases":["up"]},{"emoji":"🙃","aliases":["upside_down_face"]},{"emoji":"🇺🇾","aliases":["uruguay"]},{"emoji":"🇺🇸","aliases":["us"]},{"emoji":"🇺🇲","aliases":["us_outlying_islands"]},{"emoji":"🇻🇮","aliases":["us_virgin_islands"]},{"emoji":"🇺🇿","aliases":["uzbekistan"]},{"emoji":"✌️","aliases":["v"]},{"emoji":"🧛","aliases":["vampire"]},{"emoji":"🧛‍♂️","aliases":["vampire_man"]},{"emoji":"🧛‍♀️","aliases":["vampire_woman"]},{"emoji":"🇻🇺","aliases":["vanuatu"]},{"emoji":"🇻🇦","aliases":["vatican_city"]},{"emoji":"🇻🇪","aliases":["venezuela"]},{"emoji":"🚦","aliases":["vertical_traffic_light"]},{"emoji":"📼","aliases":["vhs"]},{"emoji":"📳","aliases":["vibration_mode"]},{"emoji":"📹","aliases":["video_camera"]},{"emoji":"🎮","aliases":["video_game"]},{"emoji":"🇻🇳","aliases":["vietnam"]},{"emoji":"🎻","aliases":["violin"]},{"emoji":"♍","aliases":["virgo"]},{"emoji":"🌋","aliases":["volcano"]},{"emoji":"🏐","aliases":["volleyball"]},{"emoji":"🤮","aliases":["vomiting_face"]},{"emoji":"🆚","aliases":["vs"]},{"emoji":"🖖","aliases":["vulcan_salute"]},{"emoji":"🧇","aliases":["waffle"]},{"emoji":"🏴󠁧󠁢󠁷󠁬󠁳󠁿","aliases":["wales"]},{"emoji":"🚶","aliases":["walking"]},{"emoji":"🚶‍♂️","aliases":["walking_man"]},{"emoji":"🚶‍♀️","aliases":["walking_woman"]},{"emoji":"🇼🇫","aliases":["wallis_futuna"]},{"emoji":"🌘","aliases":["waning_crescent_moon"]},{"emoji":"🌖","aliases":["waning_gibbous_moon"]},{"emoji":"⚠️","aliases":["warning"]},{"emoji":"🗑️","aliases":["wastebasket"]},{"emoji":"⌚","aliases":["watch"]},{"emoji":"🐃","aliases":["water_buffalo"]},{"emoji":"🤽","aliases":["water_polo"]},{"emoji":"🍉","aliases":["watermelon"]},{"emoji":"👋","aliases":["wave"]},{"emoji":"〰️","aliases":["wavy_dash"]},{"emoji":"🌒","aliases":["waxing_crescent_moon"]},{"emoji":"🚾","aliases":["wc"]},{"emoji":"😩","aliases":["weary"]},{"emoji":"💒","aliases":["wedding"]},{"emoji":"🏋️","aliases":["weight_lifting"]},{"emoji":"🏋️‍♂️","aliases":["weight_lifting_man"]},{"emoji":"🏋️‍♀️","aliases":["weight_lifting_woman"]},{"emoji":"🇪🇭","aliases":["western_sahara"]},{"emoji":"🐳","aliases":["whale"]},{"emoji":"🐋","aliases":["whale2"]},{"emoji":"🛞","aliases":["wheel"]},{"emoji":"☸️","aliases":["wheel_of_dharma"]},{"emoji":"♿","aliases":["wheelchair"]},{"emoji":"✅","aliases":["white_check_mark"]},{"emoji":"⚪","aliases":["white_circle"]},{"emoji":"🏳️","aliases":["white_flag"]},{"emoji":"💮","aliases":["white_flower"]},{"emoji":"👨‍🦳","aliases":["white_haired_man"]},{"emoji":"👩‍🦳","aliases":["white_haired_woman"]},{"emoji":"🤍","aliases":["white_heart"]},{"emoji":"⬜","aliases":["white_large_square"]},{"emoji":"◽","aliases":["white_medium_small_square"]},{"emoji":"◻️","aliases":["white_medium_square"]},{"emoji":"▫️","aliases":["white_small_square"]},{"emoji":"🔳","aliases":["white_square_button"]},{"emoji":"🥀","aliases":["wilted_flower"]},{"emoji":"🎐","aliases":["wind_chime"]},{"emoji":"🌬️","aliases":["wind_face"]},{"emoji":"🪟","aliases":["window"]},{"emoji":"🍷","aliases":["wine_glass"]},{"emoji":"🪽","aliases":["wing"]},{"emoji":"😉","aliases":["wink"]},{"emoji":"🛜","aliases":["wireless"]},{"emoji":"🐺","aliases":["wolf"]},{"emoji":"👩","aliases":["woman"]},{"emoji":"👩‍🎨","aliases":["woman_artist"]},{"emoji":"👩‍🚀","aliases":["woman_astronaut"]},{"emoji":"🧔‍♀️","aliases":["woman_beard"]},{"emoji":"🤸‍♀️","aliases":["woman_cartwheeling"]},{"emoji":"👩‍🍳","aliases":["woman_cook"]},{"emoji":"💃","aliases":["woman_dancing","dancer"]},{"emoji":"🤦‍♀️","aliases":["woman_facepalming"]},{"emoji":"👩‍🏭","aliases":["woman_factory_worker"]},{"emoji":"👩‍🌾","aliases":["woman_farmer"]},{"emoji":"👩‍🍼","aliases":["woman_feeding_baby"]},{"emoji":"👩‍🚒","aliases":["woman_firefighter"]},{"emoji":"👩‍⚕️","aliases":["woman_health_worker"]},{"emoji":"👩‍🦽","aliases":["woman_in_manual_wheelchair"]},{"emoji":"👩‍🦼","aliases":["woman_in_motorized_wheelchair"]},{"emoji":"🤵‍♀️","aliases":["woman_in_tuxedo"]},{"emoji":"👩‍⚖️","aliases":["woman_judge"]},{"emoji":"🤹‍♀️","aliases":["woman_juggling"]},{"emoji":"👩‍🔧","aliases":["woman_mechanic"]},{"emoji":"👩‍💼","aliases":["woman_office_worker"]},{"emoji":"👩‍✈️","aliases":["woman_pilot"]},{"emoji":"🤾‍♀️","aliases":["woman_playing_handball"]},{"emoji":"🤽‍♀️","aliases":["woman_playing_water_polo"]},{"emoji":"👩‍🔬","aliases":["woman_scientist"]},{"emoji":"🤷‍♀️","aliases":["woman_shrugging"]},{"emoji":"👩‍🎤","aliases":["woman_singer"]},{"emoji":"👩‍🎓","aliases":["woman_student"]},{"emoji":"👩‍🏫","aliases":["woman_teacher"]},{"emoji":"👩‍💻","aliases":["woman_technologist"]},{"emoji":"🧕","aliases":["woman_with_headscarf"]},{"emoji":"👩‍🦯","aliases":["woman_with_probing_cane"]},{"emoji":"👳‍♀️","aliases":["woman_with_turban"]},{"emoji":"👰‍♀️","aliases":["woman_with_veil","bride_with_veil"]},{"emoji":"👚","aliases":["womans_clothes"]},{"emoji":"👒","aliases":["womans_hat"]},{"emoji":"🤼‍♀️","aliases":["women_wrestling"]},{"emoji":"🚺","aliases":["womens"]},{"emoji":"🪵","aliases":["wood"]},{"emoji":"🥴","aliases":["woozy_face"]},{"emoji":"🗺️","aliases":["world_map"]},{"emoji":"🪱","aliases":["worm"]},{"emoji":"😟","aliases":["worried"]},{"emoji":"🔧","aliases":["wrench"]},{"emoji":"🤼","aliases":["wrestling"]},{"emoji":"✍️","aliases":["writing_hand"]},{"emoji":"❌","aliases":["x"]},{"emoji":"🩻","aliases":["x_ray"]},{"emoji":"🧶","aliases":["yarn"]},{"emoji":"🥱","aliases":["yawning_face"]},{"emoji":"🟡","aliases":["yellow_circle"]},{"emoji":"💛","aliases":["yellow_heart"]},{"emoji":"🟨","aliases":["yellow_square"]},{"emoji":"🇾🇪","aliases":["yemen"]},{"emoji":"💴","aliases":["yen"]},{"emoji":"☯️","aliases":["yin_yang"]},{"emoji":"🪀","aliases":["yo_yo"]},{"emoji":"😋","aliases":["yum"]},{"emoji":"🇿🇲","aliases":["zambia"]},{"emoji":"🤪","aliases":["zany_face"]},{"emoji":"⚡","aliases":["zap"]},{"emoji":"🦓","aliases":["zebra"]},{"emoji":"0️⃣","aliases":["zero"]},{"emoji":"🇿🇼","aliases":["zimbabwe"]},{"emoji":"🤐","aliases":["zipper_mouth_face"]},{"emoji":"🧟","aliases":["zombie"]},{"emoji":"🧟‍♂️","aliases":["zombie_man"]},{"emoji":"🧟‍♀️","aliases":["zombie_woman"]},{"emoji":"💤","aliases":["zzz"]}] \ No newline at end of file +[ + { + "emoji": "👍", + "aliases": [ + "+1", + "thumbsup" + ] + }, + { + "emoji": "👎", + "aliases": [ + "-1", + "thumbsdown" + ] + }, + { + "emoji": "💯", + "aliases": [ + "100" + ] + }, + { + "emoji": "🔢", + "aliases": [ + "1234" + ] + }, + { + "emoji": "🥇", + "aliases": [ + "1st_place_medal" + ] + }, + { + "emoji": "🥈", + "aliases": [ + "2nd_place_medal" + ] + }, + { + "emoji": "🥉", + "aliases": [ + "3rd_place_medal" + ] + }, + { + "emoji": "🎱", + "aliases": [ + "8ball" + ] + }, + { + "emoji": "🅰️", + "aliases": [ + "a" + ] + }, + { + "emoji": "🆎", + "aliases": [ + "ab" + ] + }, + { + "emoji": "🧮", + "aliases": [ + "abacus" + ] + }, + { + "emoji": "🔤", + "aliases": [ + "abc" + ] + }, + { + "emoji": "🔡", + "aliases": [ + "abcd" + ] + }, + { + "emoji": "🉑", + "aliases": [ + "accept" + ] + }, + { + "emoji": "🪗", + "aliases": [ + "accordion" + ] + }, + { + "emoji": "🩹", + "aliases": [ + "adhesive_bandage" + ] + }, + { + "emoji": "🧑", + "aliases": [ + "adult" + ] + }, + { + "emoji": "🚡", + "aliases": [ + "aerial_tramway" + ] + }, + { + "emoji": "🇦🇫", + "aliases": [ + "afghanistan" + ] + }, + { + "emoji": "✈️", + "aliases": [ + "airplane" + ] + }, + { + "emoji": "🇦🇽", + "aliases": [ + "aland_islands" + ] + }, + { + "emoji": "⏰", + "aliases": [ + "alarm_clock" + ] + }, + { + "emoji": "🇦🇱", + "aliases": [ + "albania" + ] + }, + { + "emoji": "⚗️", + "aliases": [ + "alembic" + ] + }, + { + "emoji": "🇩🇿", + "aliases": [ + "algeria" + ] + }, + { + "emoji": "👽", + "aliases": [ + "alien" + ] + }, + { + "emoji": "🚑", + "aliases": [ + "ambulance" + ] + }, + { + "emoji": "🇦🇸", + "aliases": [ + "american_samoa" + ] + }, + { + "emoji": "🏺", + "aliases": [ + "amphora" + ] + }, + { + "emoji": "🫀", + "aliases": [ + "anatomical_heart" + ] + }, + { + "emoji": "⚓", + "aliases": [ + "anchor" + ] + }, + { + "emoji": "🇦🇩", + "aliases": [ + "andorra" + ] + }, + { + "emoji": "👼", + "aliases": [ + "angel" + ] + }, + { + "emoji": "💢", + "aliases": [ + "anger" + ] + }, + { + "emoji": "🇦🇴", + "aliases": [ + "angola" + ] + }, + { + "emoji": "😠", + "aliases": [ + "angry" + ] + }, + { + "emoji": "🇦🇮", + "aliases": [ + "anguilla" + ] + }, + { + "emoji": "😧", + "aliases": [ + "anguished" + ] + }, + { + "emoji": "🐜", + "aliases": [ + "ant" + ] + }, + { + "emoji": "🇦🇶", + "aliases": [ + "antarctica" + ] + }, + { + "emoji": "🇦🇬", + "aliases": [ + "antigua_barbuda" + ] + }, + { + "emoji": "🍎", + "aliases": [ + "apple" + ] + }, + { + "emoji": "♒", + "aliases": [ + "aquarius" + ] + }, + { + "emoji": "🇦🇷", + "aliases": [ + "argentina" + ] + }, + { + "emoji": "♈", + "aliases": [ + "aries" + ] + }, + { + "emoji": "🇦🇲", + "aliases": [ + "armenia" + ] + }, + { + "emoji": "◀️", + "aliases": [ + "arrow_backward" + ] + }, + { + "emoji": "⏬", + "aliases": [ + "arrow_double_down" + ] + }, + { + "emoji": "⏫", + "aliases": [ + "arrow_double_up" + ] + }, + { + "emoji": "⬇️", + "aliases": [ + "arrow_down" + ] + }, + { + "emoji": "🔽", + "aliases": [ + "arrow_down_small" + ] + }, + { + "emoji": "▶️", + "aliases": [ + "arrow_forward" + ] + }, + { + "emoji": "⤵️", + "aliases": [ + "arrow_heading_down" + ] + }, + { + "emoji": "⤴️", + "aliases": [ + "arrow_heading_up" + ] + }, + { + "emoji": "⬅️", + "aliases": [ + "arrow_left" + ] + }, + { + "emoji": "↙️", + "aliases": [ + "arrow_lower_left" + ] + }, + { + "emoji": "↘️", + "aliases": [ + "arrow_lower_right" + ] + }, + { + "emoji": "➡️", + "aliases": [ + "arrow_right" + ] + }, + { + "emoji": "↪️", + "aliases": [ + "arrow_right_hook" + ] + }, + { + "emoji": "⬆️", + "aliases": [ + "arrow_up" + ] + }, + { + "emoji": "↕️", + "aliases": [ + "arrow_up_down" + ] + }, + { + "emoji": "🔼", + "aliases": [ + "arrow_up_small" + ] + }, + { + "emoji": "↖️", + "aliases": [ + "arrow_upper_left" + ] + }, + { + "emoji": "↗️", + "aliases": [ + "arrow_upper_right" + ] + }, + { + "emoji": "🔃", + "aliases": [ + "arrows_clockwise" + ] + }, + { + "emoji": "🔄", + "aliases": [ + "arrows_counterclockwise" + ] + }, + { + "emoji": "🎨", + "aliases": [ + "art" + ] + }, + { + "emoji": "🚛", + "aliases": [ + "articulated_lorry" + ] + }, + { + "emoji": "🛰️", + "aliases": [ + "artificial_satellite" + ] + }, + { + "emoji": "🧑‍🎨", + "aliases": [ + "artist" + ] + }, + { + "emoji": "🇦🇼", + "aliases": [ + "aruba" + ] + }, + { + "emoji": "🇦🇨", + "aliases": [ + "ascension_island" + ] + }, + { + "emoji": "*️⃣", + "aliases": [ + "asterisk" + ] + }, + { + "emoji": "😲", + "aliases": [ + "astonished" + ] + }, + { + "emoji": "🧑‍🚀", + "aliases": [ + "astronaut" + ] + }, + { + "emoji": "👟", + "aliases": [ + "athletic_shoe" + ] + }, + { + "emoji": "🏧", + "aliases": [ + "atm" + ] + }, + { + "emoji": "⚛️", + "aliases": [ + "atom_symbol" + ] + }, + { + "emoji": "🇦🇺", + "aliases": [ + "australia" + ] + }, + { + "emoji": "🇦🇹", + "aliases": [ + "austria" + ] + }, + { + "emoji": "🛺", + "aliases": [ + "auto_rickshaw" + ] + }, + { + "emoji": "🥑", + "aliases": [ + "avocado" + ] + }, + { + "emoji": "🪓", + "aliases": [ + "axe" + ] + }, + { + "emoji": "🇦🇿", + "aliases": [ + "azerbaijan" + ] + }, + { + "emoji": "🅱️", + "aliases": [ + "b" + ] + }, + { + "emoji": "👶", + "aliases": [ + "baby" + ] + }, + { + "emoji": "🍼", + "aliases": [ + "baby_bottle" + ] + }, + { + "emoji": "🐤", + "aliases": [ + "baby_chick" + ] + }, + { + "emoji": "🚼", + "aliases": [ + "baby_symbol" + ] + }, + { + "emoji": "🔙", + "aliases": [ + "back" + ] + }, + { + "emoji": "🥓", + "aliases": [ + "bacon" + ] + }, + { + "emoji": "🦡", + "aliases": [ + "badger" + ] + }, + { + "emoji": "🏸", + "aliases": [ + "badminton" + ] + }, + { + "emoji": "🥯", + "aliases": [ + "bagel" + ] + }, + { + "emoji": "🛄", + "aliases": [ + "baggage_claim" + ] + }, + { + "emoji": "🥖", + "aliases": [ + "baguette_bread" + ] + }, + { + "emoji": "🇧🇸", + "aliases": [ + "bahamas" + ] + }, + { + "emoji": "🇧🇭", + "aliases": [ + "bahrain" + ] + }, + { + "emoji": "⚖️", + "aliases": [ + "balance_scale" + ] + }, + { + "emoji": "👨‍🦲", + "aliases": [ + "bald_man" + ] + }, + { + "emoji": "👩‍🦲", + "aliases": [ + "bald_woman" + ] + }, + { + "emoji": "🩰", + "aliases": [ + "ballet_shoes" + ] + }, + { + "emoji": "🎈", + "aliases": [ + "balloon" + ] + }, + { + "emoji": "🗳️", + "aliases": [ + "ballot_box" + ] + }, + { + "emoji": "☑️", + "aliases": [ + "ballot_box_with_check" + ] + }, + { + "emoji": "🎍", + "aliases": [ + "bamboo" + ] + }, + { + "emoji": "🍌", + "aliases": [ + "banana" + ] + }, + { + "emoji": "‼️", + "aliases": [ + "bangbang" + ] + }, + { + "emoji": "🇧🇩", + "aliases": [ + "bangladesh" + ] + }, + { + "emoji": "🪕", + "aliases": [ + "banjo" + ] + }, + { + "emoji": "🏦", + "aliases": [ + "bank" + ] + }, + { + "emoji": "📊", + "aliases": [ + "bar_chart" + ] + }, + { + "emoji": "🇧🇧", + "aliases": [ + "barbados" + ] + }, + { + "emoji": "💈", + "aliases": [ + "barber" + ] + }, + { + "emoji": "⚾", + "aliases": [ + "baseball" + ] + }, + { + "emoji": "🧺", + "aliases": [ + "basket" + ] + }, + { + "emoji": "🏀", + "aliases": [ + "basketball" + ] + }, + { + "emoji": "🦇", + "aliases": [ + "bat" + ] + }, + { + "emoji": "🛀", + "aliases": [ + "bath" + ] + }, + { + "emoji": "🛁", + "aliases": [ + "bathtub" + ] + }, + { + "emoji": "🔋", + "aliases": [ + "battery" + ] + }, + { + "emoji": "🏖️", + "aliases": [ + "beach_umbrella" + ] + }, + { + "emoji": "🫘", + "aliases": [ + "beans" + ] + }, + { + "emoji": "🐻", + "aliases": [ + "bear" + ] + }, + { + "emoji": "🧔", + "aliases": [ + "bearded_person" + ] + }, + { + "emoji": "🦫", + "aliases": [ + "beaver" + ] + }, + { + "emoji": "🛏️", + "aliases": [ + "bed" + ] + }, + { + "emoji": "🐝", + "aliases": [ + "bee", + "honeybee" + ] + }, + { + "emoji": "🍺", + "aliases": [ + "beer" + ] + }, + { + "emoji": "🍻", + "aliases": [ + "beers" + ] + }, + { + "emoji": "🪲", + "aliases": [ + "beetle" + ] + }, + { + "emoji": "🔰", + "aliases": [ + "beginner" + ] + }, + { + "emoji": "🇧🇾", + "aliases": [ + "belarus" + ] + }, + { + "emoji": "🇧🇪", + "aliases": [ + "belgium" + ] + }, + { + "emoji": "🇧🇿", + "aliases": [ + "belize" + ] + }, + { + "emoji": "🔔", + "aliases": [ + "bell" + ] + }, + { + "emoji": "🫑", + "aliases": [ + "bell_pepper" + ] + }, + { + "emoji": "🛎️", + "aliases": [ + "bellhop_bell" + ] + }, + { + "emoji": "🇧🇯", + "aliases": [ + "benin" + ] + }, + { + "emoji": "🍱", + "aliases": [ + "bento" + ] + }, + { + "emoji": "🇧🇲", + "aliases": [ + "bermuda" + ] + }, + { + "emoji": "🧃", + "aliases": [ + "beverage_box" + ] + }, + { + "emoji": "🇧🇹", + "aliases": [ + "bhutan" + ] + }, + { + "emoji": "🚴", + "aliases": [ + "bicyclist" + ] + }, + { + "emoji": "🚲", + "aliases": [ + "bike" + ] + }, + { + "emoji": "🚴‍♂️", + "aliases": [ + "biking_man" + ] + }, + { + "emoji": "🚴‍♀️", + "aliases": [ + "biking_woman" + ] + }, + { + "emoji": "👙", + "aliases": [ + "bikini" + ] + }, + { + "emoji": "🧢", + "aliases": [ + "billed_cap" + ] + }, + { + "emoji": "☣️", + "aliases": [ + "biohazard" + ] + }, + { + "emoji": "🐦", + "aliases": [ + "bird" + ] + }, + { + "emoji": "🎂", + "aliases": [ + "birthday" + ] + }, + { + "emoji": "🦬", + "aliases": [ + "bison" + ] + }, + { + "emoji": "🫦", + "aliases": [ + "biting_lip" + ] + }, + { + "emoji": "🐦‍⬛", + "aliases": [ + "black_bird" + ] + }, + { + "emoji": "🐈‍⬛", + "aliases": [ + "black_cat" + ] + }, + { + "emoji": "⚫", + "aliases": [ + "black_circle" + ] + }, + { + "emoji": "🏴", + "aliases": [ + "black_flag" + ] + }, + { + "emoji": "🖤", + "aliases": [ + "black_heart" + ] + }, + { + "emoji": "🃏", + "aliases": [ + "black_joker" + ] + }, + { + "emoji": "⬛", + "aliases": [ + "black_large_square" + ] + }, + { + "emoji": "◾", + "aliases": [ + "black_medium_small_square" + ] + }, + { + "emoji": "◼️", + "aliases": [ + "black_medium_square" + ] + }, + { + "emoji": "✒️", + "aliases": [ + "black_nib" + ] + }, + { + "emoji": "▪️", + "aliases": [ + "black_small_square" + ] + }, + { + "emoji": "🔲", + "aliases": [ + "black_square_button" + ] + }, + { + "emoji": "👱‍♂️", + "aliases": [ + "blond_haired_man" + ] + }, + { + "emoji": "👱", + "aliases": [ + "blond_haired_person" + ] + }, + { + "emoji": "👱‍♀️", + "aliases": [ + "blond_haired_woman", + "blonde_woman" + ] + }, + { + "emoji": "🌼", + "aliases": [ + "blossom" + ] + }, + { + "emoji": "🐡", + "aliases": [ + "blowfish" + ] + }, + { + "emoji": "📘", + "aliases": [ + "blue_book" + ] + }, + { + "emoji": "🚙", + "aliases": [ + "blue_car" + ] + }, + { + "emoji": "💙", + "aliases": [ + "blue_heart" + ] + }, + { + "emoji": "🟦", + "aliases": [ + "blue_square" + ] + }, + { + "emoji": "🫐", + "aliases": [ + "blueberries" + ] + }, + { + "emoji": "😊", + "aliases": [ + "blush" + ] + }, + { + "emoji": "🐗", + "aliases": [ + "boar" + ] + }, + { + "emoji": "⛵", + "aliases": [ + "boat", + "sailboat" + ] + }, + { + "emoji": "🇧🇴", + "aliases": [ + "bolivia" + ] + }, + { + "emoji": "💣", + "aliases": [ + "bomb" + ] + }, + { + "emoji": "🦴", + "aliases": [ + "bone" + ] + }, + { + "emoji": "📖", + "aliases": [ + "book", + "open_book" + ] + }, + { + "emoji": "🔖", + "aliases": [ + "bookmark" + ] + }, + { + "emoji": "📑", + "aliases": [ + "bookmark_tabs" + ] + }, + { + "emoji": "📚", + "aliases": [ + "books" + ] + }, + { + "emoji": "💥", + "aliases": [ + "boom", + "collision" + ] + }, + { + "emoji": "🪃", + "aliases": [ + "boomerang" + ] + }, + { + "emoji": "👢", + "aliases": [ + "boot" + ] + }, + { + "emoji": "🇧🇦", + "aliases": [ + "bosnia_herzegovina" + ] + }, + { + "emoji": "🇧🇼", + "aliases": [ + "botswana" + ] + }, + { + "emoji": "⛹️‍♂️", + "aliases": [ + "bouncing_ball_man", + "basketball_man" + ] + }, + { + "emoji": "⛹️", + "aliases": [ + "bouncing_ball_person" + ] + }, + { + "emoji": "⛹️‍♀️", + "aliases": [ + "bouncing_ball_woman", + "basketball_woman" + ] + }, + { + "emoji": "💐", + "aliases": [ + "bouquet" + ] + }, + { + "emoji": "🇧🇻", + "aliases": [ + "bouvet_island" + ] + }, + { + "emoji": "🙇", + "aliases": [ + "bow" + ] + }, + { + "emoji": "🏹", + "aliases": [ + "bow_and_arrow" + ] + }, + { + "emoji": "🙇‍♂️", + "aliases": [ + "bowing_man" + ] + }, + { + "emoji": "🙇‍♀️", + "aliases": [ + "bowing_woman" + ] + }, + { + "emoji": "🥣", + "aliases": [ + "bowl_with_spoon" + ] + }, + { + "emoji": "🎳", + "aliases": [ + "bowling" + ] + }, + { + "emoji": "🥊", + "aliases": [ + "boxing_glove" + ] + }, + { + "emoji": "👦", + "aliases": [ + "boy" + ] + }, + { + "emoji": "🧠", + "aliases": [ + "brain" + ] + }, + { + "emoji": "🇧🇷", + "aliases": [ + "brazil" + ] + }, + { + "emoji": "🍞", + "aliases": [ + "bread" + ] + }, + { + "emoji": "🤱", + "aliases": [ + "breast_feeding" + ] + }, + { + "emoji": "🧱", + "aliases": [ + "bricks" + ] + }, + { + "emoji": "🌉", + "aliases": [ + "bridge_at_night" + ] + }, + { + "emoji": "💼", + "aliases": [ + "briefcase" + ] + }, + { + "emoji": "🇮🇴", + "aliases": [ + "british_indian_ocean_territory" + ] + }, + { + "emoji": "🇻🇬", + "aliases": [ + "british_virgin_islands" + ] + }, + { + "emoji": "🥦", + "aliases": [ + "broccoli" + ] + }, + { + "emoji": "⛓️‍💥", + "aliases": [ + "broken_chain" + ] + }, + { + "emoji": "💔", + "aliases": [ + "broken_heart" + ] + }, + { + "emoji": "🧹", + "aliases": [ + "broom" + ] + }, + { + "emoji": "🟤", + "aliases": [ + "brown_circle" + ] + }, + { + "emoji": "🤎", + "aliases": [ + "brown_heart" + ] + }, + { + "emoji": "🍄‍🟫", + "aliases": [ + "brown_mushroom" + ] + }, + { + "emoji": "🟫", + "aliases": [ + "brown_square" + ] + }, + { + "emoji": "🇧🇳", + "aliases": [ + "brunei" + ] + }, + { + "emoji": "🧋", + "aliases": [ + "bubble_tea" + ] + }, + { + "emoji": "🫧", + "aliases": [ + "bubbles" + ] + }, + { + "emoji": "🪣", + "aliases": [ + "bucket" + ] + }, + { + "emoji": "🐛", + "aliases": [ + "bug" + ] + }, + { + "emoji": "🏗️", + "aliases": [ + "building_construction" + ] + }, + { + "emoji": "💡", + "aliases": [ + "bulb" + ] + }, + { + "emoji": "🇧🇬", + "aliases": [ + "bulgaria" + ] + }, + { + "emoji": "🚅", + "aliases": [ + "bullettrain_front" + ] + }, + { + "emoji": "🚄", + "aliases": [ + "bullettrain_side" + ] + }, + { + "emoji": "🇧🇫", + "aliases": [ + "burkina_faso" + ] + }, + { + "emoji": "🌯", + "aliases": [ + "burrito" + ] + }, + { + "emoji": "🇧🇮", + "aliases": [ + "burundi" + ] + }, + { + "emoji": "🚌", + "aliases": [ + "bus" + ] + }, + { + "emoji": "🕴️", + "aliases": [ + "business_suit_levitating" + ] + }, + { + "emoji": "🚏", + "aliases": [ + "busstop" + ] + }, + { + "emoji": "👤", + "aliases": [ + "bust_in_silhouette" + ] + }, + { + "emoji": "👥", + "aliases": [ + "busts_in_silhouette" + ] + }, + { + "emoji": "🧈", + "aliases": [ + "butter" + ] + }, + { + "emoji": "🦋", + "aliases": [ + "butterfly" + ] + }, + { + "emoji": "🌵", + "aliases": [ + "cactus" + ] + }, + { + "emoji": "🍰", + "aliases": [ + "cake" + ] + }, + { + "emoji": "📆", + "aliases": [ + "calendar" + ] + }, + { + "emoji": "🤙", + "aliases": [ + "call_me_hand" + ] + }, + { + "emoji": "📲", + "aliases": [ + "calling" + ] + }, + { + "emoji": "🇰🇭", + "aliases": [ + "cambodia" + ] + }, + { + "emoji": "🐫", + "aliases": [ + "camel" + ] + }, + { + "emoji": "📷", + "aliases": [ + "camera" + ] + }, + { + "emoji": "📸", + "aliases": [ + "camera_flash" + ] + }, + { + "emoji": "🇨🇲", + "aliases": [ + "cameroon" + ] + }, + { + "emoji": "🏕️", + "aliases": [ + "camping" + ] + }, + { + "emoji": "🇨🇦", + "aliases": [ + "canada" + ] + }, + { + "emoji": "🇮🇨", + "aliases": [ + "canary_islands" + ] + }, + { + "emoji": "♋", + "aliases": [ + "cancer" + ] + }, + { + "emoji": "🕯️", + "aliases": [ + "candle" + ] + }, + { + "emoji": "🍬", + "aliases": [ + "candy" + ] + }, + { + "emoji": "🥫", + "aliases": [ + "canned_food" + ] + }, + { + "emoji": "🛶", + "aliases": [ + "canoe" + ] + }, + { + "emoji": "🇨🇻", + "aliases": [ + "cape_verde" + ] + }, + { + "emoji": "🔠", + "aliases": [ + "capital_abcd" + ] + }, + { + "emoji": "♑", + "aliases": [ + "capricorn" + ] + }, + { + "emoji": "🚗", + "aliases": [ + "car", + "red_car" + ] + }, + { + "emoji": "🗃️", + "aliases": [ + "card_file_box" + ] + }, + { + "emoji": "📇", + "aliases": [ + "card_index" + ] + }, + { + "emoji": "🗂️", + "aliases": [ + "card_index_dividers" + ] + }, + { + "emoji": "🇧🇶", + "aliases": [ + "caribbean_netherlands" + ] + }, + { + "emoji": "🎠", + "aliases": [ + "carousel_horse" + ] + }, + { + "emoji": "🪚", + "aliases": [ + "carpentry_saw" + ] + }, + { + "emoji": "🥕", + "aliases": [ + "carrot" + ] + }, + { + "emoji": "🤸", + "aliases": [ + "cartwheeling" + ] + }, + { + "emoji": "🐱", + "aliases": [ + "cat" + ] + }, + { + "emoji": "🐈", + "aliases": [ + "cat2" + ] + }, + { + "emoji": "🇰🇾", + "aliases": [ + "cayman_islands" + ] + }, + { + "emoji": "💿", + "aliases": [ + "cd" + ] + }, + { + "emoji": "🇨🇫", + "aliases": [ + "central_african_republic" + ] + }, + { + "emoji": "🇪🇦", + "aliases": [ + "ceuta_melilla" + ] + }, + { + "emoji": "🇹🇩", + "aliases": [ + "chad" + ] + }, + { + "emoji": "⛓️", + "aliases": [ + "chains" + ] + }, + { + "emoji": "🪑", + "aliases": [ + "chair" + ] + }, + { + "emoji": "🍾", + "aliases": [ + "champagne" + ] + }, + { + "emoji": "💹", + "aliases": [ + "chart" + ] + }, + { + "emoji": "📉", + "aliases": [ + "chart_with_downwards_trend" + ] + }, + { + "emoji": "📈", + "aliases": [ + "chart_with_upwards_trend" + ] + }, + { + "emoji": "🏁", + "aliases": [ + "checkered_flag" + ] + }, + { + "emoji": "🧀", + "aliases": [ + "cheese" + ] + }, + { + "emoji": "🍒", + "aliases": [ + "cherries" + ] + }, + { + "emoji": "🌸", + "aliases": [ + "cherry_blossom" + ] + }, + { + "emoji": "♟️", + "aliases": [ + "chess_pawn" + ] + }, + { + "emoji": "🌰", + "aliases": [ + "chestnut" + ] + }, + { + "emoji": "🐔", + "aliases": [ + "chicken" + ] + }, + { + "emoji": "🧒", + "aliases": [ + "child" + ] + }, + { + "emoji": "🚸", + "aliases": [ + "children_crossing" + ] + }, + { + "emoji": "🇨🇱", + "aliases": [ + "chile" + ] + }, + { + "emoji": "🐿️", + "aliases": [ + "chipmunk" + ] + }, + { + "emoji": "🍫", + "aliases": [ + "chocolate_bar" + ] + }, + { + "emoji": "🥢", + "aliases": [ + "chopsticks" + ] + }, + { + "emoji": "🇨🇽", + "aliases": [ + "christmas_island" + ] + }, + { + "emoji": "🎄", + "aliases": [ + "christmas_tree" + ] + }, + { + "emoji": "⛪", + "aliases": [ + "church" + ] + }, + { + "emoji": "🎦", + "aliases": [ + "cinema" + ] + }, + { + "emoji": "🎪", + "aliases": [ + "circus_tent" + ] + }, + { + "emoji": "🌇", + "aliases": [ + "city_sunrise" + ] + }, + { + "emoji": "🌆", + "aliases": [ + "city_sunset" + ] + }, + { + "emoji": "🏙️", + "aliases": [ + "cityscape" + ] + }, + { + "emoji": "🆑", + "aliases": [ + "cl" + ] + }, + { + "emoji": "🗜️", + "aliases": [ + "clamp" + ] + }, + { + "emoji": "👏", + "aliases": [ + "clap" + ] + }, + { + "emoji": "🎬", + "aliases": [ + "clapper" + ] + }, + { + "emoji": "🏛️", + "aliases": [ + "classical_building" + ] + }, + { + "emoji": "🧗", + "aliases": [ + "climbing" + ] + }, + { + "emoji": "🧗‍♂️", + "aliases": [ + "climbing_man" + ] + }, + { + "emoji": "🧗‍♀️", + "aliases": [ + "climbing_woman" + ] + }, + { + "emoji": "🥂", + "aliases": [ + "clinking_glasses" + ] + }, + { + "emoji": "📋", + "aliases": [ + "clipboard" + ] + }, + { + "emoji": "🇨🇵", + "aliases": [ + "clipperton_island" + ] + }, + { + "emoji": "🕐", + "aliases": [ + "clock1" + ] + }, + { + "emoji": "🕙", + "aliases": [ + "clock10" + ] + }, + { + "emoji": "🕥", + "aliases": [ + "clock1030" + ] + }, + { + "emoji": "🕚", + "aliases": [ + "clock11" + ] + }, + { + "emoji": "🕦", + "aliases": [ + "clock1130" + ] + }, + { + "emoji": "🕛", + "aliases": [ + "clock12" + ] + }, + { + "emoji": "🕧", + "aliases": [ + "clock1230" + ] + }, + { + "emoji": "🕜", + "aliases": [ + "clock130" + ] + }, + { + "emoji": "🕑", + "aliases": [ + "clock2" + ] + }, + { + "emoji": "🕝", + "aliases": [ + "clock230" + ] + }, + { + "emoji": "🕒", + "aliases": [ + "clock3" + ] + }, + { + "emoji": "🕞", + "aliases": [ + "clock330" + ] + }, + { + "emoji": "🕓", + "aliases": [ + "clock4" + ] + }, + { + "emoji": "🕟", + "aliases": [ + "clock430" + ] + }, + { + "emoji": "🕔", + "aliases": [ + "clock5" + ] + }, + { + "emoji": "🕠", + "aliases": [ + "clock530" + ] + }, + { + "emoji": "🕕", + "aliases": [ + "clock6" + ] + }, + { + "emoji": "🕡", + "aliases": [ + "clock630" + ] + }, + { + "emoji": "🕖", + "aliases": [ + "clock7" + ] + }, + { + "emoji": "🕢", + "aliases": [ + "clock730" + ] + }, + { + "emoji": "🕗", + "aliases": [ + "clock8" + ] + }, + { + "emoji": "🕣", + "aliases": [ + "clock830" + ] + }, + { + "emoji": "🕘", + "aliases": [ + "clock9" + ] + }, + { + "emoji": "🕤", + "aliases": [ + "clock930" + ] + }, + { + "emoji": "📕", + "aliases": [ + "closed_book" + ] + }, + { + "emoji": "🔐", + "aliases": [ + "closed_lock_with_key" + ] + }, + { + "emoji": "🌂", + "aliases": [ + "closed_umbrella" + ] + }, + { + "emoji": "☁️", + "aliases": [ + "cloud" + ] + }, + { + "emoji": "🌩️", + "aliases": [ + "cloud_with_lightning" + ] + }, + { + "emoji": "⛈️", + "aliases": [ + "cloud_with_lightning_and_rain" + ] + }, + { + "emoji": "🌧️", + "aliases": [ + "cloud_with_rain" + ] + }, + { + "emoji": "🌨️", + "aliases": [ + "cloud_with_snow" + ] + }, + { + "emoji": "🤡", + "aliases": [ + "clown_face" + ] + }, + { + "emoji": "♣️", + "aliases": [ + "clubs" + ] + }, + { + "emoji": "🇨🇳", + "aliases": [ + "cn" + ] + }, + { + "emoji": "🧥", + "aliases": [ + "coat" + ] + }, + { + "emoji": "🪳", + "aliases": [ + "cockroach" + ] + }, + { + "emoji": "🍸", + "aliases": [ + "cocktail" + ] + }, + { + "emoji": "🥥", + "aliases": [ + "coconut" + ] + }, + { + "emoji": "🇨🇨", + "aliases": [ + "cocos_islands" + ] + }, + { + "emoji": "☕", + "aliases": [ + "coffee" + ] + }, + { + "emoji": "⚰️", + "aliases": [ + "coffin" + ] + }, + { + "emoji": "🪙", + "aliases": [ + "coin" + ] + }, + { + "emoji": "🥶", + "aliases": [ + "cold_face" + ] + }, + { + "emoji": "😰", + "aliases": [ + "cold_sweat" + ] + }, + { + "emoji": "🇨🇴", + "aliases": [ + "colombia" + ] + }, + { + "emoji": "☄️", + "aliases": [ + "comet" + ] + }, + { + "emoji": "🇰🇲", + "aliases": [ + "comoros" + ] + }, + { + "emoji": "🧭", + "aliases": [ + "compass" + ] + }, + { + "emoji": "💻", + "aliases": [ + "computer" + ] + }, + { + "emoji": "🖱️", + "aliases": [ + "computer_mouse" + ] + }, + { + "emoji": "🎊", + "aliases": [ + "confetti_ball" + ] + }, + { + "emoji": "😖", + "aliases": [ + "confounded" + ] + }, + { + "emoji": "😕", + "aliases": [ + "confused" + ] + }, + { + "emoji": "🇨🇬", + "aliases": [ + "congo_brazzaville" + ] + }, + { + "emoji": "🇨🇩", + "aliases": [ + "congo_kinshasa" + ] + }, + { + "emoji": "㊗️", + "aliases": [ + "congratulations" + ] + }, + { + "emoji": "🚧", + "aliases": [ + "construction" + ] + }, + { + "emoji": "👷", + "aliases": [ + "construction_worker" + ] + }, + { + "emoji": "👷‍♂️", + "aliases": [ + "construction_worker_man" + ] + }, + { + "emoji": "👷‍♀️", + "aliases": [ + "construction_worker_woman" + ] + }, + { + "emoji": "🎛️", + "aliases": [ + "control_knobs" + ] + }, + { + "emoji": "🏪", + "aliases": [ + "convenience_store" + ] + }, + { + "emoji": "🧑‍🍳", + "aliases": [ + "cook" + ] + }, + { + "emoji": "🇨🇰", + "aliases": [ + "cook_islands" + ] + }, + { + "emoji": "🍪", + "aliases": [ + "cookie" + ] + }, + { + "emoji": "🆒", + "aliases": [ + "cool" + ] + }, + { + "emoji": "©️", + "aliases": [ + "copyright" + ] + }, + { + "emoji": "🪸", + "aliases": [ + "coral" + ] + }, + { + "emoji": "🌽", + "aliases": [ + "corn" + ] + }, + { + "emoji": "🇨🇷", + "aliases": [ + "costa_rica" + ] + }, + { + "emoji": "🇨🇮", + "aliases": [ + "cote_divoire" + ] + }, + { + "emoji": "🛋️", + "aliases": [ + "couch_and_lamp" + ] + }, + { + "emoji": "👫", + "aliases": [ + "couple" + ] + }, + { + "emoji": "💑", + "aliases": [ + "couple_with_heart" + ] + }, + { + "emoji": "👨‍❤️‍👨", + "aliases": [ + "couple_with_heart_man_man" + ] + }, + { + "emoji": "👩‍❤️‍👨", + "aliases": [ + "couple_with_heart_woman_man" + ] + }, + { + "emoji": "👩‍❤️‍👩", + "aliases": [ + "couple_with_heart_woman_woman" + ] + }, + { + "emoji": "💏", + "aliases": [ + "couplekiss" + ] + }, + { + "emoji": "👨‍❤️‍💋‍👨", + "aliases": [ + "couplekiss_man_man" + ] + }, + { + "emoji": "👩‍❤️‍💋‍👨", + "aliases": [ + "couplekiss_man_woman" + ] + }, + { + "emoji": "👩‍❤️‍💋‍👩", + "aliases": [ + "couplekiss_woman_woman" + ] + }, + { + "emoji": "🐮", + "aliases": [ + "cow" + ] + }, + { + "emoji": "🐄", + "aliases": [ + "cow2" + ] + }, + { + "emoji": "🤠", + "aliases": [ + "cowboy_hat_face" + ] + }, + { + "emoji": "🦀", + "aliases": [ + "crab" + ] + }, + { + "emoji": "🖍️", + "aliases": [ + "crayon" + ] + }, + { + "emoji": "💳", + "aliases": [ + "credit_card" + ] + }, + { + "emoji": "🌙", + "aliases": [ + "crescent_moon" + ] + }, + { + "emoji": "🦗", + "aliases": [ + "cricket" + ] + }, + { + "emoji": "🏏", + "aliases": [ + "cricket_game" + ] + }, + { + "emoji": "🇭🇷", + "aliases": [ + "croatia" + ] + }, + { + "emoji": "🐊", + "aliases": [ + "crocodile" + ] + }, + { + "emoji": "🥐", + "aliases": [ + "croissant" + ] + }, + { + "emoji": "🤞", + "aliases": [ + "crossed_fingers" + ] + }, + { + "emoji": "🎌", + "aliases": [ + "crossed_flags" + ] + }, + { + "emoji": "⚔️", + "aliases": [ + "crossed_swords" + ] + }, + { + "emoji": "👑", + "aliases": [ + "crown" + ] + }, + { + "emoji": "🩼", + "aliases": [ + "crutch" + ] + }, + { + "emoji": "😢", + "aliases": [ + "cry" + ] + }, + { + "emoji": "😿", + "aliases": [ + "crying_cat_face" + ] + }, + { + "emoji": "🔮", + "aliases": [ + "crystal_ball" + ] + }, + { + "emoji": "🇨🇺", + "aliases": [ + "cuba" + ] + }, + { + "emoji": "🥒", + "aliases": [ + "cucumber" + ] + }, + { + "emoji": "🥤", + "aliases": [ + "cup_with_straw" + ] + }, + { + "emoji": "🧁", + "aliases": [ + "cupcake" + ] + }, + { + "emoji": "💘", + "aliases": [ + "cupid" + ] + }, + { + "emoji": "🇨🇼", + "aliases": [ + "curacao" + ] + }, + { + "emoji": "🥌", + "aliases": [ + "curling_stone" + ] + }, + { + "emoji": "👨‍🦱", + "aliases": [ + "curly_haired_man" + ] + }, + { + "emoji": "👩‍🦱", + "aliases": [ + "curly_haired_woman" + ] + }, + { + "emoji": "➰", + "aliases": [ + "curly_loop" + ] + }, + { + "emoji": "💱", + "aliases": [ + "currency_exchange" + ] + }, + { + "emoji": "🍛", + "aliases": [ + "curry" + ] + }, + { + "emoji": "🤬", + "aliases": [ + "cursing_face" + ] + }, + { + "emoji": "🍮", + "aliases": [ + "custard" + ] + }, + { + "emoji": "🛃", + "aliases": [ + "customs" + ] + }, + { + "emoji": "🥩", + "aliases": [ + "cut_of_meat" + ] + }, + { + "emoji": "🌀", + "aliases": [ + "cyclone" + ] + }, + { + "emoji": "🇨🇾", + "aliases": [ + "cyprus" + ] + }, + { + "emoji": "🇨🇿", + "aliases": [ + "czech_republic" + ] + }, + { + "emoji": "🗡️", + "aliases": [ + "dagger" + ] + }, + { + "emoji": "👯", + "aliases": [ + "dancers" + ] + }, + { + "emoji": "👯‍♂️", + "aliases": [ + "dancing_men" + ] + }, + { + "emoji": "👯‍♀️", + "aliases": [ + "dancing_women" + ] + }, + { + "emoji": "🍡", + "aliases": [ + "dango" + ] + }, + { + "emoji": "🕶️", + "aliases": [ + "dark_sunglasses" + ] + }, + { + "emoji": "🎯", + "aliases": [ + "dart" + ] + }, + { + "emoji": "💨", + "aliases": [ + "dash" + ] + }, + { + "emoji": "📅", + "aliases": [ + "date" + ] + }, + { + "emoji": "🇩🇪", + "aliases": [ + "de" + ] + }, + { + "emoji": "🧏‍♂️", + "aliases": [ + "deaf_man" + ] + }, + { + "emoji": "🧏", + "aliases": [ + "deaf_person" + ] + }, + { + "emoji": "🧏‍♀️", + "aliases": [ + "deaf_woman" + ] + }, + { + "emoji": "🌳", + "aliases": [ + "deciduous_tree" + ] + }, + { + "emoji": "🦌", + "aliases": [ + "deer" + ] + }, + { + "emoji": "🇩🇰", + "aliases": [ + "denmark" + ] + }, + { + "emoji": "🏬", + "aliases": [ + "department_store" + ] + }, + { + "emoji": "🏚️", + "aliases": [ + "derelict_house" + ] + }, + { + "emoji": "🏜️", + "aliases": [ + "desert" + ] + }, + { + "emoji": "🏝️", + "aliases": [ + "desert_island" + ] + }, + { + "emoji": "🖥️", + "aliases": [ + "desktop_computer" + ] + }, + { + "emoji": "🕵️", + "aliases": [ + "detective" + ] + }, + { + "emoji": "💠", + "aliases": [ + "diamond_shape_with_a_dot_inside" + ] + }, + { + "emoji": "♦️", + "aliases": [ + "diamonds" + ] + }, + { + "emoji": "🇩🇬", + "aliases": [ + "diego_garcia" + ] + }, + { + "emoji": "😞", + "aliases": [ + "disappointed" + ] + }, + { + "emoji": "😥", + "aliases": [ + "disappointed_relieved" + ] + }, + { + "emoji": "🥸", + "aliases": [ + "disguised_face" + ] + }, + { + "emoji": "🤿", + "aliases": [ + "diving_mask" + ] + }, + { + "emoji": "🪔", + "aliases": [ + "diya_lamp" + ] + }, + { + "emoji": "💫", + "aliases": [ + "dizzy" + ] + }, + { + "emoji": "😵", + "aliases": [ + "dizzy_face" + ] + }, + { + "emoji": "🇩🇯", + "aliases": [ + "djibouti" + ] + }, + { + "emoji": "🧬", + "aliases": [ + "dna" + ] + }, + { + "emoji": "🚯", + "aliases": [ + "do_not_litter" + ] + }, + { + "emoji": "🦤", + "aliases": [ + "dodo" + ] + }, + { + "emoji": "🐶", + "aliases": [ + "dog" + ] + }, + { + "emoji": "🐕", + "aliases": [ + "dog2" + ] + }, + { + "emoji": "💵", + "aliases": [ + "dollar" + ] + }, + { + "emoji": "🎎", + "aliases": [ + "dolls" + ] + }, + { + "emoji": "🐬", + "aliases": [ + "dolphin", + "flipper" + ] + }, + { + "emoji": "🇩🇲", + "aliases": [ + "dominica" + ] + }, + { + "emoji": "🇩🇴", + "aliases": [ + "dominican_republic" + ] + }, + { + "emoji": "🫏", + "aliases": [ + "donkey" + ] + }, + { + "emoji": "🚪", + "aliases": [ + "door" + ] + }, + { + "emoji": "🫥", + "aliases": [ + "dotted_line_face" + ] + }, + { + "emoji": "🍩", + "aliases": [ + "doughnut" + ] + }, + { + "emoji": "🕊️", + "aliases": [ + "dove" + ] + }, + { + "emoji": "🐉", + "aliases": [ + "dragon" + ] + }, + { + "emoji": "🐲", + "aliases": [ + "dragon_face" + ] + }, + { + "emoji": "👗", + "aliases": [ + "dress" + ] + }, + { + "emoji": "🐪", + "aliases": [ + "dromedary_camel" + ] + }, + { + "emoji": "🤤", + "aliases": [ + "drooling_face" + ] + }, + { + "emoji": "🩸", + "aliases": [ + "drop_of_blood" + ] + }, + { + "emoji": "💧", + "aliases": [ + "droplet" + ] + }, + { + "emoji": "🥁", + "aliases": [ + "drum" + ] + }, + { + "emoji": "🦆", + "aliases": [ + "duck" + ] + }, + { + "emoji": "🥟", + "aliases": [ + "dumpling" + ] + }, + { + "emoji": "📀", + "aliases": [ + "dvd" + ] + }, + { + "emoji": "🦅", + "aliases": [ + "eagle" + ] + }, + { + "emoji": "👂", + "aliases": [ + "ear" + ] + }, + { + "emoji": "🌾", + "aliases": [ + "ear_of_rice" + ] + }, + { + "emoji": "🦻", + "aliases": [ + "ear_with_hearing_aid" + ] + }, + { + "emoji": "🌍", + "aliases": [ + "earth_africa" + ] + }, + { + "emoji": "🌎", + "aliases": [ + "earth_americas" + ] + }, + { + "emoji": "🌏", + "aliases": [ + "earth_asia" + ] + }, + { + "emoji": "🇪🇨", + "aliases": [ + "ecuador" + ] + }, + { + "emoji": "🥚", + "aliases": [ + "egg" + ] + }, + { + "emoji": "🍆", + "aliases": [ + "eggplant" + ] + }, + { + "emoji": "🇪🇬", + "aliases": [ + "egypt" + ] + }, + { + "emoji": "8️⃣", + "aliases": [ + "eight" + ] + }, + { + "emoji": "✴️", + "aliases": [ + "eight_pointed_black_star" + ] + }, + { + "emoji": "✳️", + "aliases": [ + "eight_spoked_asterisk" + ] + }, + { + "emoji": "⏏️", + "aliases": [ + "eject_button" + ] + }, + { + "emoji": "🇸🇻", + "aliases": [ + "el_salvador" + ] + }, + { + "emoji": "🔌", + "aliases": [ + "electric_plug" + ] + }, + { + "emoji": "🐘", + "aliases": [ + "elephant" + ] + }, + { + "emoji": "🛗", + "aliases": [ + "elevator" + ] + }, + { + "emoji": "🧝", + "aliases": [ + "elf" + ] + }, + { + "emoji": "🧝‍♂️", + "aliases": [ + "elf_man" + ] + }, + { + "emoji": "🧝‍♀️", + "aliases": [ + "elf_woman" + ] + }, + { + "emoji": "📧", + "aliases": [ + "email", + "e-mail" + ] + }, + { + "emoji": "🪹", + "aliases": [ + "empty_nest" + ] + }, + { + "emoji": "🔚", + "aliases": [ + "end" + ] + }, + { + "emoji": "🏴󠁧󠁢󠁥󠁮󠁧󠁿", + "aliases": [ + "england" + ] + }, + { + "emoji": "✉️", + "aliases": [ + "envelope" + ] + }, + { + "emoji": "📩", + "aliases": [ + "envelope_with_arrow" + ] + }, + { + "emoji": "🇬🇶", + "aliases": [ + "equatorial_guinea" + ] + }, + { + "emoji": "🇪🇷", + "aliases": [ + "eritrea" + ] + }, + { + "emoji": "🇪🇸", + "aliases": [ + "es" + ] + }, + { + "emoji": "🇪🇪", + "aliases": [ + "estonia" + ] + }, + { + "emoji": "🇪🇹", + "aliases": [ + "ethiopia" + ] + }, + { + "emoji": "🇪🇺", + "aliases": [ + "eu", + "european_union" + ] + }, + { + "emoji": "💶", + "aliases": [ + "euro" + ] + }, + { + "emoji": "🏰", + "aliases": [ + "european_castle" + ] + }, + { + "emoji": "🏤", + "aliases": [ + "european_post_office" + ] + }, + { + "emoji": "🌲", + "aliases": [ + "evergreen_tree" + ] + }, + { + "emoji": "❗", + "aliases": [ + "exclamation", + "heavy_exclamation_mark" + ] + }, + { + "emoji": "🤯", + "aliases": [ + "exploding_head" + ] + }, + { + "emoji": "😑", + "aliases": [ + "expressionless" + ] + }, + { + "emoji": "👁️", + "aliases": [ + "eye" + ] + }, + { + "emoji": "👁️‍🗨️", + "aliases": [ + "eye_speech_bubble" + ] + }, + { + "emoji": "👓", + "aliases": [ + "eyeglasses" + ] + }, + { + "emoji": "👀", + "aliases": [ + "eyes" + ] + }, + { + "emoji": "😮‍💨", + "aliases": [ + "face_exhaling" + ] + }, + { + "emoji": "🥹", + "aliases": [ + "face_holding_back_tears" + ] + }, + { + "emoji": "😶‍🌫️", + "aliases": [ + "face_in_clouds" + ] + }, + { + "emoji": "🫩", + "aliases": [ + "face_with_bags_under_eyes" + ] + }, + { + "emoji": "🫤", + "aliases": [ + "face_with_diagonal_mouth" + ] + }, + { + "emoji": "🤕", + "aliases": [ + "face_with_head_bandage" + ] + }, + { + "emoji": "🫢", + "aliases": [ + "face_with_open_eyes_and_hand_over_mouth" + ] + }, + { + "emoji": "🫣", + "aliases": [ + "face_with_peeking_eye" + ] + }, + { + "emoji": "😵‍💫", + "aliases": [ + "face_with_spiral_eyes" + ] + }, + { + "emoji": "🤒", + "aliases": [ + "face_with_thermometer" + ] + }, + { + "emoji": "🤦", + "aliases": [ + "facepalm" + ] + }, + { + "emoji": "🏭", + "aliases": [ + "factory" + ] + }, + { + "emoji": "🧑‍🏭", + "aliases": [ + "factory_worker" + ] + }, + { + "emoji": "🧚", + "aliases": [ + "fairy" + ] + }, + { + "emoji": "🧚‍♂️", + "aliases": [ + "fairy_man" + ] + }, + { + "emoji": "🧚‍♀️", + "aliases": [ + "fairy_woman" + ] + }, + { + "emoji": "🧆", + "aliases": [ + "falafel" + ] + }, + { + "emoji": "🇫🇰", + "aliases": [ + "falkland_islands" + ] + }, + { + "emoji": "🍂", + "aliases": [ + "fallen_leaf" + ] + }, + { + "emoji": "👪", + "aliases": [ + "family" + ] + }, + { + "emoji": "🧑‍🧑‍🧒", + "aliases": [ + "family_adult_adult_child" + ] + }, + { + "emoji": "🧑‍🧑‍🧒‍🧒", + "aliases": [ + "family_adult_adult_child_child" + ] + }, + { + "emoji": "🧑‍🧒", + "aliases": [ + "family_adult_child" + ] + }, + { + "emoji": "🧑‍🧒‍🧒", + "aliases": [ + "family_adult_child_child" + ] + }, + { + "emoji": "👨‍👦", + "aliases": [ + "family_man_boy" + ] + }, + { + "emoji": "👨‍👦‍👦", + "aliases": [ + "family_man_boy_boy" + ] + }, + { + "emoji": "👨‍👧", + "aliases": [ + "family_man_girl" + ] + }, + { + "emoji": "👨‍👧‍👦", + "aliases": [ + "family_man_girl_boy" + ] + }, + { + "emoji": "👨‍👧‍👧", + "aliases": [ + "family_man_girl_girl" + ] + }, + { + "emoji": "👨‍👨‍👦", + "aliases": [ + "family_man_man_boy" + ] + }, + { + "emoji": "👨‍👨‍👦‍👦", + "aliases": [ + "family_man_man_boy_boy" + ] + }, + { + "emoji": "👨‍👨‍👧", + "aliases": [ + "family_man_man_girl" + ] + }, + { + "emoji": "👨‍👨‍👧‍👦", + "aliases": [ + "family_man_man_girl_boy" + ] + }, + { + "emoji": "👨‍👨‍👧‍👧", + "aliases": [ + "family_man_man_girl_girl" + ] + }, + { + "emoji": "👨‍👩‍👦", + "aliases": [ + "family_man_woman_boy" + ] + }, + { + "emoji": "👨‍👩‍👦‍👦", + "aliases": [ + "family_man_woman_boy_boy" + ] + }, + { + "emoji": "👨‍👩‍👧", + "aliases": [ + "family_man_woman_girl" + ] + }, + { + "emoji": "👨‍👩‍👧‍👦", + "aliases": [ + "family_man_woman_girl_boy" + ] + }, + { + "emoji": "👨‍👩‍👧‍👧", + "aliases": [ + "family_man_woman_girl_girl" + ] + }, + { + "emoji": "👩‍👦", + "aliases": [ + "family_woman_boy" + ] + }, + { + "emoji": "👩‍👦‍👦", + "aliases": [ + "family_woman_boy_boy" + ] + }, + { + "emoji": "👩‍👧", + "aliases": [ + "family_woman_girl" + ] + }, + { + "emoji": "👩‍👧‍👦", + "aliases": [ + "family_woman_girl_boy" + ] + }, + { + "emoji": "👩‍👧‍👧", + "aliases": [ + "family_woman_girl_girl" + ] + }, + { + "emoji": "👩‍👩‍👦", + "aliases": [ + "family_woman_woman_boy" + ] + }, + { + "emoji": "👩‍👩‍👦‍👦", + "aliases": [ + "family_woman_woman_boy_boy" + ] + }, + { + "emoji": "👩‍👩‍👧", + "aliases": [ + "family_woman_woman_girl" + ] + }, + { + "emoji": "👩‍👩‍👧‍👦", + "aliases": [ + "family_woman_woman_girl_boy" + ] + }, + { + "emoji": "👩‍👩‍👧‍👧", + "aliases": [ + "family_woman_woman_girl_girl" + ] + }, + { + "emoji": "🧑‍🌾", + "aliases": [ + "farmer" + ] + }, + { + "emoji": "🇫🇴", + "aliases": [ + "faroe_islands" + ] + }, + { + "emoji": "⏩", + "aliases": [ + "fast_forward" + ] + }, + { + "emoji": "📠", + "aliases": [ + "fax" + ] + }, + { + "emoji": "😨", + "aliases": [ + "fearful" + ] + }, + { + "emoji": "🪶", + "aliases": [ + "feather" + ] + }, + { + "emoji": "🐾", + "aliases": [ + "feet", + "paw_prints" + ] + }, + { + "emoji": "🕵️‍♀️", + "aliases": [ + "female_detective" + ] + }, + { + "emoji": "♀️", + "aliases": [ + "female_sign" + ] + }, + { + "emoji": "🎡", + "aliases": [ + "ferris_wheel" + ] + }, + { + "emoji": "⛴️", + "aliases": [ + "ferry" + ] + }, + { + "emoji": "🏑", + "aliases": [ + "field_hockey" + ] + }, + { + "emoji": "🇫🇯", + "aliases": [ + "fiji" + ] + }, + { + "emoji": "🗄️", + "aliases": [ + "file_cabinet" + ] + }, + { + "emoji": "📁", + "aliases": [ + "file_folder" + ] + }, + { + "emoji": "📽️", + "aliases": [ + "film_projector" + ] + }, + { + "emoji": "🎞️", + "aliases": [ + "film_strip" + ] + }, + { + "emoji": "🫆", + "aliases": [ + "fingerprint" + ] + }, + { + "emoji": "🇫🇮", + "aliases": [ + "finland" + ] + }, + { + "emoji": "🔥", + "aliases": [ + "fire" + ] + }, + { + "emoji": "🚒", + "aliases": [ + "fire_engine" + ] + }, + { + "emoji": "🧯", + "aliases": [ + "fire_extinguisher" + ] + }, + { + "emoji": "🧨", + "aliases": [ + "firecracker" + ] + }, + { + "emoji": "🧑‍🚒", + "aliases": [ + "firefighter" + ] + }, + { + "emoji": "🎆", + "aliases": [ + "fireworks" + ] + }, + { + "emoji": "🌓", + "aliases": [ + "first_quarter_moon" + ] + }, + { + "emoji": "🌛", + "aliases": [ + "first_quarter_moon_with_face" + ] + }, + { + "emoji": "🐟", + "aliases": [ + "fish" + ] + }, + { + "emoji": "🍥", + "aliases": [ + "fish_cake" + ] + }, + { + "emoji": "🎣", + "aliases": [ + "fishing_pole_and_fish" + ] + }, + { + "emoji": "🤛", + "aliases": [ + "fist_left" + ] + }, + { + "emoji": "👊", + "aliases": [ + "fist_oncoming", + "facepunch", + "punch" + ] + }, + { + "emoji": "✊", + "aliases": [ + "fist_raised", + "fist" + ] + }, + { + "emoji": "🤜", + "aliases": [ + "fist_right" + ] + }, + { + "emoji": "5️⃣", + "aliases": [ + "five" + ] + }, + { + "emoji": "🇨🇶", + "aliases": [ + "flag_sark" + ] + }, + { + "emoji": "🎏", + "aliases": [ + "flags" + ] + }, + { + "emoji": "🦩", + "aliases": [ + "flamingo" + ] + }, + { + "emoji": "🔦", + "aliases": [ + "flashlight" + ] + }, + { + "emoji": "🥿", + "aliases": [ + "flat_shoe" + ] + }, + { + "emoji": "🫓", + "aliases": [ + "flatbread" + ] + }, + { + "emoji": "⚜️", + "aliases": [ + "fleur_de_lis" + ] + }, + { + "emoji": "🛬", + "aliases": [ + "flight_arrival" + ] + }, + { + "emoji": "🛫", + "aliases": [ + "flight_departure" + ] + }, + { + "emoji": "💾", + "aliases": [ + "floppy_disk" + ] + }, + { + "emoji": "🎴", + "aliases": [ + "flower_playing_cards" + ] + }, + { + "emoji": "😳", + "aliases": [ + "flushed" + ] + }, + { + "emoji": "🪈", + "aliases": [ + "flute" + ] + }, + { + "emoji": "🪰", + "aliases": [ + "fly" + ] + }, + { + "emoji": "🥏", + "aliases": [ + "flying_disc" + ] + }, + { + "emoji": "🛸", + "aliases": [ + "flying_saucer" + ] + }, + { + "emoji": "🌫️", + "aliases": [ + "fog" + ] + }, + { + "emoji": "🌁", + "aliases": [ + "foggy" + ] + }, + { + "emoji": "🪭", + "aliases": [ + "folding_hand_fan" + ] + }, + { + "emoji": "🫕", + "aliases": [ + "fondue" + ] + }, + { + "emoji": "🦶", + "aliases": [ + "foot" + ] + }, + { + "emoji": "🏈", + "aliases": [ + "football" + ] + }, + { + "emoji": "👣", + "aliases": [ + "footprints" + ] + }, + { + "emoji": "🍴", + "aliases": [ + "fork_and_knife" + ] + }, + { + "emoji": "🥠", + "aliases": [ + "fortune_cookie" + ] + }, + { + "emoji": "⛲", + "aliases": [ + "fountain" + ] + }, + { + "emoji": "🖋️", + "aliases": [ + "fountain_pen" + ] + }, + { + "emoji": "4️⃣", + "aliases": [ + "four" + ] + }, + { + "emoji": "🍀", + "aliases": [ + "four_leaf_clover" + ] + }, + { + "emoji": "🦊", + "aliases": [ + "fox_face" + ] + }, + { + "emoji": "🇫🇷", + "aliases": [ + "fr" + ] + }, + { + "emoji": "🖼️", + "aliases": [ + "framed_picture" + ] + }, + { + "emoji": "🆓", + "aliases": [ + "free" + ] + }, + { + "emoji": "🇬🇫", + "aliases": [ + "french_guiana" + ] + }, + { + "emoji": "🇵🇫", + "aliases": [ + "french_polynesia" + ] + }, + { + "emoji": "🇹🇫", + "aliases": [ + "french_southern_territories" + ] + }, + { + "emoji": "🍳", + "aliases": [ + "fried_egg" + ] + }, + { + "emoji": "🍤", + "aliases": [ + "fried_shrimp" + ] + }, + { + "emoji": "🍟", + "aliases": [ + "fries" + ] + }, + { + "emoji": "🐸", + "aliases": [ + "frog" + ] + }, + { + "emoji": "😦", + "aliases": [ + "frowning" + ] + }, + { + "emoji": "☹️", + "aliases": [ + "frowning_face" + ] + }, + { + "emoji": "🙍‍♂️", + "aliases": [ + "frowning_man" + ] + }, + { + "emoji": "🙍", + "aliases": [ + "frowning_person" + ] + }, + { + "emoji": "🙍‍♀️", + "aliases": [ + "frowning_woman" + ] + }, + { + "emoji": "⛽", + "aliases": [ + "fuelpump" + ] + }, + { + "emoji": "🌕", + "aliases": [ + "full_moon" + ] + }, + { + "emoji": "🌝", + "aliases": [ + "full_moon_with_face" + ] + }, + { + "emoji": "⚱️", + "aliases": [ + "funeral_urn" + ] + }, + { + "emoji": "🇬🇦", + "aliases": [ + "gabon" + ] + }, + { + "emoji": "🇬🇲", + "aliases": [ + "gambia" + ] + }, + { + "emoji": "🎲", + "aliases": [ + "game_die" + ] + }, + { + "emoji": "🧄", + "aliases": [ + "garlic" + ] + }, + { + "emoji": "🇬🇧", + "aliases": [ + "gb", + "uk" + ] + }, + { + "emoji": "⚙️", + "aliases": [ + "gear" + ] + }, + { + "emoji": "💎", + "aliases": [ + "gem" + ] + }, + { + "emoji": "♊", + "aliases": [ + "gemini" + ] + }, + { + "emoji": "🧞", + "aliases": [ + "genie" + ] + }, + { + "emoji": "🧞‍♂️", + "aliases": [ + "genie_man" + ] + }, + { + "emoji": "🧞‍♀️", + "aliases": [ + "genie_woman" + ] + }, + { + "emoji": "🇬🇪", + "aliases": [ + "georgia" + ] + }, + { + "emoji": "🇬🇭", + "aliases": [ + "ghana" + ] + }, + { + "emoji": "👻", + "aliases": [ + "ghost" + ] + }, + { + "emoji": "🇬🇮", + "aliases": [ + "gibraltar" + ] + }, + { + "emoji": "🎁", + "aliases": [ + "gift" + ] + }, + { + "emoji": "💝", + "aliases": [ + "gift_heart" + ] + }, + { + "emoji": "🫚", + "aliases": [ + "ginger_root" + ] + }, + { + "emoji": "🦒", + "aliases": [ + "giraffe" + ] + }, + { + "emoji": "👧", + "aliases": [ + "girl" + ] + }, + { + "emoji": "🌐", + "aliases": [ + "globe_with_meridians" + ] + }, + { + "emoji": "🧤", + "aliases": [ + "gloves" + ] + }, + { + "emoji": "🥅", + "aliases": [ + "goal_net" + ] + }, + { + "emoji": "🐐", + "aliases": [ + "goat" + ] + }, + { + "emoji": "🥽", + "aliases": [ + "goggles" + ] + }, + { + "emoji": "⛳", + "aliases": [ + "golf" + ] + }, + { + "emoji": "🏌️", + "aliases": [ + "golfing" + ] + }, + { + "emoji": "🏌️‍♂️", + "aliases": [ + "golfing_man" + ] + }, + { + "emoji": "🏌️‍♀️", + "aliases": [ + "golfing_woman" + ] + }, + { + "emoji": "🪿", + "aliases": [ + "goose" + ] + }, + { + "emoji": "🦍", + "aliases": [ + "gorilla" + ] + }, + { + "emoji": "🍇", + "aliases": [ + "grapes" + ] + }, + { + "emoji": "🇬🇷", + "aliases": [ + "greece" + ] + }, + { + "emoji": "🍏", + "aliases": [ + "green_apple" + ] + }, + { + "emoji": "📗", + "aliases": [ + "green_book" + ] + }, + { + "emoji": "🟢", + "aliases": [ + "green_circle" + ] + }, + { + "emoji": "💚", + "aliases": [ + "green_heart" + ] + }, + { + "emoji": "🥗", + "aliases": [ + "green_salad" + ] + }, + { + "emoji": "🟩", + "aliases": [ + "green_square" + ] + }, + { + "emoji": "🇬🇱", + "aliases": [ + "greenland" + ] + }, + { + "emoji": "🇬🇩", + "aliases": [ + "grenada" + ] + }, + { + "emoji": "❕", + "aliases": [ + "grey_exclamation" + ] + }, + { + "emoji": "🩶", + "aliases": [ + "grey_heart" + ] + }, + { + "emoji": "❔", + "aliases": [ + "grey_question" + ] + }, + { + "emoji": "😬", + "aliases": [ + "grimacing" + ] + }, + { + "emoji": "😁", + "aliases": [ + "grin" + ] + }, + { + "emoji": "😀", + "aliases": [ + "grinning" + ] + }, + { + "emoji": "🇬🇵", + "aliases": [ + "guadeloupe" + ] + }, + { + "emoji": "🇬🇺", + "aliases": [ + "guam" + ] + }, + { + "emoji": "💂", + "aliases": [ + "guard" + ] + }, + { + "emoji": "💂‍♂️", + "aliases": [ + "guardsman" + ] + }, + { + "emoji": "💂‍♀️", + "aliases": [ + "guardswoman" + ] + }, + { + "emoji": "🇬🇹", + "aliases": [ + "guatemala" + ] + }, + { + "emoji": "🇬🇬", + "aliases": [ + "guernsey" + ] + }, + { + "emoji": "🦮", + "aliases": [ + "guide_dog" + ] + }, + { + "emoji": "🇬🇳", + "aliases": [ + "guinea" + ] + }, + { + "emoji": "🇬🇼", + "aliases": [ + "guinea_bissau" + ] + }, + { + "emoji": "🎸", + "aliases": [ + "guitar" + ] + }, + { + "emoji": "🔫", + "aliases": [ + "gun" + ] + }, + { + "emoji": "🇬🇾", + "aliases": [ + "guyana" + ] + }, + { + "emoji": "🪮", + "aliases": [ + "hair_pick" + ] + }, + { + "emoji": "💇", + "aliases": [ + "haircut" + ] + }, + { + "emoji": "💇‍♂️", + "aliases": [ + "haircut_man" + ] + }, + { + "emoji": "💇‍♀️", + "aliases": [ + "haircut_woman" + ] + }, + { + "emoji": "🇭🇹", + "aliases": [ + "haiti" + ] + }, + { + "emoji": "🍔", + "aliases": [ + "hamburger" + ] + }, + { + "emoji": "🔨", + "aliases": [ + "hammer" + ] + }, + { + "emoji": "⚒️", + "aliases": [ + "hammer_and_pick" + ] + }, + { + "emoji": "🛠️", + "aliases": [ + "hammer_and_wrench" + ] + }, + { + "emoji": "🪬", + "aliases": [ + "hamsa" + ] + }, + { + "emoji": "🐹", + "aliases": [ + "hamster" + ] + }, + { + "emoji": "✋", + "aliases": [ + "hand", + "raised_hand" + ] + }, + { + "emoji": "🤭", + "aliases": [ + "hand_over_mouth" + ] + }, + { + "emoji": "🫰", + "aliases": [ + "hand_with_index_finger_and_thumb_crossed" + ] + }, + { + "emoji": "👜", + "aliases": [ + "handbag" + ] + }, + { + "emoji": "🤾", + "aliases": [ + "handball_person" + ] + }, + { + "emoji": "🤝", + "aliases": [ + "handshake" + ] + }, + { + "emoji": "💩", + "aliases": [ + "hankey", + "poop", + "shit" + ] + }, + { + "emoji": "🪉", + "aliases": [ + "harp" + ] + }, + { + "emoji": "#️⃣", + "aliases": [ + "hash" + ] + }, + { + "emoji": "🐥", + "aliases": [ + "hatched_chick" + ] + }, + { + "emoji": "🐣", + "aliases": [ + "hatching_chick" + ] + }, + { + "emoji": "🙂‍↔️", + "aliases": [ + "head_shaking_horizontally" + ] + }, + { + "emoji": "🙂‍↕️", + "aliases": [ + "head_shaking_vertically" + ] + }, + { + "emoji": "🎧", + "aliases": [ + "headphones" + ] + }, + { + "emoji": "🪦", + "aliases": [ + "headstone" + ] + }, + { + "emoji": "🧑‍⚕️", + "aliases": [ + "health_worker" + ] + }, + { + "emoji": "🙉", + "aliases": [ + "hear_no_evil" + ] + }, + { + "emoji": "🇭🇲", + "aliases": [ + "heard_mcdonald_islands" + ] + }, + { + "emoji": "❤️", + "aliases": [ + "heart" + ] + }, + { + "emoji": "💟", + "aliases": [ + "heart_decoration" + ] + }, + { + "emoji": "😍", + "aliases": [ + "heart_eyes" + ] + }, + { + "emoji": "😻", + "aliases": [ + "heart_eyes_cat" + ] + }, + { + "emoji": "🫶", + "aliases": [ + "heart_hands" + ] + }, + { + "emoji": "❤️‍🔥", + "aliases": [ + "heart_on_fire" + ] + }, + { + "emoji": "💓", + "aliases": [ + "heartbeat" + ] + }, + { + "emoji": "💗", + "aliases": [ + "heartpulse" + ] + }, + { + "emoji": "♥️", + "aliases": [ + "hearts" + ] + }, + { + "emoji": "✔️", + "aliases": [ + "heavy_check_mark" + ] + }, + { + "emoji": "➗", + "aliases": [ + "heavy_division_sign" + ] + }, + { + "emoji": "💲", + "aliases": [ + "heavy_dollar_sign" + ] + }, + { + "emoji": "🟰", + "aliases": [ + "heavy_equals_sign" + ] + }, + { + "emoji": "❣️", + "aliases": [ + "heavy_heart_exclamation" + ] + }, + { + "emoji": "➖", + "aliases": [ + "heavy_minus_sign" + ] + }, + { + "emoji": "✖️", + "aliases": [ + "heavy_multiplication_x" + ] + }, + { + "emoji": "➕", + "aliases": [ + "heavy_plus_sign" + ] + }, + { + "emoji": "🦔", + "aliases": [ + "hedgehog" + ] + }, + { + "emoji": "🚁", + "aliases": [ + "helicopter" + ] + }, + { + "emoji": "🌿", + "aliases": [ + "herb" + ] + }, + { + "emoji": "🌺", + "aliases": [ + "hibiscus" + ] + }, + { + "emoji": "🔆", + "aliases": [ + "high_brightness" + ] + }, + { + "emoji": "👠", + "aliases": [ + "high_heel" + ] + }, + { + "emoji": "🥾", + "aliases": [ + "hiking_boot" + ] + }, + { + "emoji": "🛕", + "aliases": [ + "hindu_temple" + ] + }, + { + "emoji": "🦛", + "aliases": [ + "hippopotamus" + ] + }, + { + "emoji": "🔪", + "aliases": [ + "hocho", + "knife" + ] + }, + { + "emoji": "🕳️", + "aliases": [ + "hole" + ] + }, + { + "emoji": "🇭🇳", + "aliases": [ + "honduras" + ] + }, + { + "emoji": "🍯", + "aliases": [ + "honey_pot" + ] + }, + { + "emoji": "🇭🇰", + "aliases": [ + "hong_kong" + ] + }, + { + "emoji": "🪝", + "aliases": [ + "hook" + ] + }, + { + "emoji": "🐴", + "aliases": [ + "horse" + ] + }, + { + "emoji": "🏇", + "aliases": [ + "horse_racing" + ] + }, + { + "emoji": "🏥", + "aliases": [ + "hospital" + ] + }, + { + "emoji": "🥵", + "aliases": [ + "hot_face" + ] + }, + { + "emoji": "🌶️", + "aliases": [ + "hot_pepper" + ] + }, + { + "emoji": "🌭", + "aliases": [ + "hotdog" + ] + }, + { + "emoji": "🏨", + "aliases": [ + "hotel" + ] + }, + { + "emoji": "♨️", + "aliases": [ + "hotsprings" + ] + }, + { + "emoji": "⌛", + "aliases": [ + "hourglass" + ] + }, + { + "emoji": "⏳", + "aliases": [ + "hourglass_flowing_sand" + ] + }, + { + "emoji": "🏠", + "aliases": [ + "house" + ] + }, + { + "emoji": "🏡", + "aliases": [ + "house_with_garden" + ] + }, + { + "emoji": "🏘️", + "aliases": [ + "houses" + ] + }, + { + "emoji": "🤗", + "aliases": [ + "hugs" + ] + }, + { + "emoji": "🇭🇺", + "aliases": [ + "hungary" + ] + }, + { + "emoji": "😯", + "aliases": [ + "hushed" + ] + }, + { + "emoji": "🛖", + "aliases": [ + "hut" + ] + }, + { + "emoji": "🪻", + "aliases": [ + "hyacinth" + ] + }, + { + "emoji": "🍨", + "aliases": [ + "ice_cream" + ] + }, + { + "emoji": "🧊", + "aliases": [ + "ice_cube" + ] + }, + { + "emoji": "🏒", + "aliases": [ + "ice_hockey" + ] + }, + { + "emoji": "⛸️", + "aliases": [ + "ice_skate" + ] + }, + { + "emoji": "🍦", + "aliases": [ + "icecream" + ] + }, + { + "emoji": "🇮🇸", + "aliases": [ + "iceland" + ] + }, + { + "emoji": "🆔", + "aliases": [ + "id" + ] + }, + { + "emoji": "🪪", + "aliases": [ + "identification_card" + ] + }, + { + "emoji": "🉐", + "aliases": [ + "ideograph_advantage" + ] + }, + { + "emoji": "👿", + "aliases": [ + "imp" + ] + }, + { + "emoji": "📥", + "aliases": [ + "inbox_tray" + ] + }, + { + "emoji": "📨", + "aliases": [ + "incoming_envelope" + ] + }, + { + "emoji": "🫵", + "aliases": [ + "index_pointing_at_the_viewer" + ] + }, + { + "emoji": "🇮🇳", + "aliases": [ + "india" + ] + }, + { + "emoji": "🇮🇩", + "aliases": [ + "indonesia" + ] + }, + { + "emoji": "♾️", + "aliases": [ + "infinity" + ] + }, + { + "emoji": "ℹ️", + "aliases": [ + "information_source" + ] + }, + { + "emoji": "😇", + "aliases": [ + "innocent" + ] + }, + { + "emoji": "⁉️", + "aliases": [ + "interrobang" + ] + }, + { + "emoji": "📱", + "aliases": [ + "iphone" + ] + }, + { + "emoji": "🇮🇷", + "aliases": [ + "iran" + ] + }, + { + "emoji": "🇮🇶", + "aliases": [ + "iraq" + ] + }, + { + "emoji": "🇮🇪", + "aliases": [ + "ireland" + ] + }, + { + "emoji": "🇮🇲", + "aliases": [ + "isle_of_man" + ] + }, + { + "emoji": "🇮🇱", + "aliases": [ + "israel" + ] + }, + { + "emoji": "🇮🇹", + "aliases": [ + "it" + ] + }, + { + "emoji": "🏮", + "aliases": [ + "izakaya_lantern", + "lantern" + ] + }, + { + "emoji": "🎃", + "aliases": [ + "jack_o_lantern" + ] + }, + { + "emoji": "🇯🇲", + "aliases": [ + "jamaica" + ] + }, + { + "emoji": "🗾", + "aliases": [ + "japan" + ] + }, + { + "emoji": "🏯", + "aliases": [ + "japanese_castle" + ] + }, + { + "emoji": "👺", + "aliases": [ + "japanese_goblin" + ] + }, + { + "emoji": "👹", + "aliases": [ + "japanese_ogre" + ] + }, + { + "emoji": "🫙", + "aliases": [ + "jar" + ] + }, + { + "emoji": "👖", + "aliases": [ + "jeans" + ] + }, + { + "emoji": "🪼", + "aliases": [ + "jellyfish" + ] + }, + { + "emoji": "🇯🇪", + "aliases": [ + "jersey" + ] + }, + { + "emoji": "🧩", + "aliases": [ + "jigsaw" + ] + }, + { + "emoji": "🇯🇴", + "aliases": [ + "jordan" + ] + }, + { + "emoji": "😂", + "aliases": [ + "joy" + ] + }, + { + "emoji": "😹", + "aliases": [ + "joy_cat" + ] + }, + { + "emoji": "🕹️", + "aliases": [ + "joystick" + ] + }, + { + "emoji": "🇯🇵", + "aliases": [ + "jp" + ] + }, + { + "emoji": "🧑‍⚖️", + "aliases": [ + "judge" + ] + }, + { + "emoji": "🤹", + "aliases": [ + "juggling_person" + ] + }, + { + "emoji": "🕋", + "aliases": [ + "kaaba" + ] + }, + { + "emoji": "🦘", + "aliases": [ + "kangaroo" + ] + }, + { + "emoji": "🇰🇿", + "aliases": [ + "kazakhstan" + ] + }, + { + "emoji": "🇰🇪", + "aliases": [ + "kenya" + ] + }, + { + "emoji": "🔑", + "aliases": [ + "key" + ] + }, + { + "emoji": "⌨️", + "aliases": [ + "keyboard" + ] + }, + { + "emoji": "🔟", + "aliases": [ + "keycap_ten" + ] + }, + { + "emoji": "🪯", + "aliases": [ + "khanda" + ] + }, + { + "emoji": "🛴", + "aliases": [ + "kick_scooter" + ] + }, + { + "emoji": "👘", + "aliases": [ + "kimono" + ] + }, + { + "emoji": "🇰🇮", + "aliases": [ + "kiribati" + ] + }, + { + "emoji": "💋", + "aliases": [ + "kiss" + ] + }, + { + "emoji": "😗", + "aliases": [ + "kissing" + ] + }, + { + "emoji": "😽", + "aliases": [ + "kissing_cat" + ] + }, + { + "emoji": "😚", + "aliases": [ + "kissing_closed_eyes" + ] + }, + { + "emoji": "😘", + "aliases": [ + "kissing_heart" + ] + }, + { + "emoji": "😙", + "aliases": [ + "kissing_smiling_eyes" + ] + }, + { + "emoji": "🪁", + "aliases": [ + "kite" + ] + }, + { + "emoji": "🥝", + "aliases": [ + "kiwi_fruit" + ] + }, + { + "emoji": "🧎‍♂️", + "aliases": [ + "kneeling_man" + ] + }, + { + "emoji": "🧎", + "aliases": [ + "kneeling_person" + ] + }, + { + "emoji": "🧎‍♀️", + "aliases": [ + "kneeling_woman" + ] + }, + { + "emoji": "🪢", + "aliases": [ + "knot" + ] + }, + { + "emoji": "🐨", + "aliases": [ + "koala" + ] + }, + { + "emoji": "🈁", + "aliases": [ + "koko" + ] + }, + { + "emoji": "🇽🇰", + "aliases": [ + "kosovo" + ] + }, + { + "emoji": "🇰🇷", + "aliases": [ + "kr" + ] + }, + { + "emoji": "🇰🇼", + "aliases": [ + "kuwait" + ] + }, + { + "emoji": "🇰🇬", + "aliases": [ + "kyrgyzstan" + ] + }, + { + "emoji": "🥼", + "aliases": [ + "lab_coat" + ] + }, + { + "emoji": "🏷️", + "aliases": [ + "label" + ] + }, + { + "emoji": "🥍", + "aliases": [ + "lacrosse" + ] + }, + { + "emoji": "🪜", + "aliases": [ + "ladder" + ] + }, + { + "emoji": "🐞", + "aliases": [ + "lady_beetle" + ] + }, + { + "emoji": "🇱🇦", + "aliases": [ + "laos" + ] + }, + { + "emoji": "🔵", + "aliases": [ + "large_blue_circle" + ] + }, + { + "emoji": "🔷", + "aliases": [ + "large_blue_diamond" + ] + }, + { + "emoji": "🔶", + "aliases": [ + "large_orange_diamond" + ] + }, + { + "emoji": "🌗", + "aliases": [ + "last_quarter_moon" + ] + }, + { + "emoji": "🌜", + "aliases": [ + "last_quarter_moon_with_face" + ] + }, + { + "emoji": "✝️", + "aliases": [ + "latin_cross" + ] + }, + { + "emoji": "🇱🇻", + "aliases": [ + "latvia" + ] + }, + { + "emoji": "😆", + "aliases": [ + "laughing", + "satisfied", + "laugh" + ] + }, + { + "emoji": "🪾", + "aliases": [ + "leafless_tree" + ] + }, + { + "emoji": "🥬", + "aliases": [ + "leafy_green" + ] + }, + { + "emoji": "🍃", + "aliases": [ + "leaves" + ] + }, + { + "emoji": "🇱🇧", + "aliases": [ + "lebanon" + ] + }, + { + "emoji": "📒", + "aliases": [ + "ledger" + ] + }, + { + "emoji": "🛅", + "aliases": [ + "left_luggage" + ] + }, + { + "emoji": "↔️", + "aliases": [ + "left_right_arrow" + ] + }, + { + "emoji": "🗨️", + "aliases": [ + "left_speech_bubble" + ] + }, + { + "emoji": "↩️", + "aliases": [ + "leftwards_arrow_with_hook" + ] + }, + { + "emoji": "🫲", + "aliases": [ + "leftwards_hand" + ] + }, + { + "emoji": "🫷", + "aliases": [ + "leftwards_pushing_hand" + ] + }, + { + "emoji": "🦵", + "aliases": [ + "leg" + ] + }, + { + "emoji": "🍋", + "aliases": [ + "lemon" + ] + }, + { + "emoji": "♌", + "aliases": [ + "leo" + ] + }, + { + "emoji": "🐆", + "aliases": [ + "leopard" + ] + }, + { + "emoji": "🇱🇸", + "aliases": [ + "lesotho" + ] + }, + { + "emoji": "🎚️", + "aliases": [ + "level_slider" + ] + }, + { + "emoji": "🇱🇷", + "aliases": [ + "liberia" + ] + }, + { + "emoji": "♎", + "aliases": [ + "libra" + ] + }, + { + "emoji": "🇱🇾", + "aliases": [ + "libya" + ] + }, + { + "emoji": "🇱🇮", + "aliases": [ + "liechtenstein" + ] + }, + { + "emoji": "🩵", + "aliases": [ + "light_blue_heart" + ] + }, + { + "emoji": "🚈", + "aliases": [ + "light_rail" + ] + }, + { + "emoji": "🍋‍🟩", + "aliases": [ + "lime" + ] + }, + { + "emoji": "🔗", + "aliases": [ + "link" + ] + }, + { + "emoji": "🦁", + "aliases": [ + "lion" + ] + }, + { + "emoji": "👄", + "aliases": [ + "lips" + ] + }, + { + "emoji": "💄", + "aliases": [ + "lipstick" + ] + }, + { + "emoji": "🇱🇹", + "aliases": [ + "lithuania" + ] + }, + { + "emoji": "🦎", + "aliases": [ + "lizard" + ] + }, + { + "emoji": "🦙", + "aliases": [ + "llama" + ] + }, + { + "emoji": "🦞", + "aliases": [ + "lobster" + ] + }, + { + "emoji": "🔒", + "aliases": [ + "lock" + ] + }, + { + "emoji": "🔏", + "aliases": [ + "lock_with_ink_pen" + ] + }, + { + "emoji": "🍭", + "aliases": [ + "lollipop" + ] + }, + { + "emoji": "🪘", + "aliases": [ + "long_drum" + ] + }, + { + "emoji": "➿", + "aliases": [ + "loop" + ] + }, + { + "emoji": "🧴", + "aliases": [ + "lotion_bottle" + ] + }, + { + "emoji": "🪷", + "aliases": [ + "lotus" + ] + }, + { + "emoji": "🧘", + "aliases": [ + "lotus_position" + ] + }, + { + "emoji": "🧘‍♂️", + "aliases": [ + "lotus_position_man" + ] + }, + { + "emoji": "🧘‍♀️", + "aliases": [ + "lotus_position_woman" + ] + }, + { + "emoji": "🔊", + "aliases": [ + "loud_sound" + ] + }, + { + "emoji": "📢", + "aliases": [ + "loudspeaker" + ] + }, + { + "emoji": "🏩", + "aliases": [ + "love_hotel" + ] + }, + { + "emoji": "💌", + "aliases": [ + "love_letter" + ] + }, + { + "emoji": "🤟", + "aliases": [ + "love_you_gesture" + ] + }, + { + "emoji": "🪫", + "aliases": [ + "low_battery" + ] + }, + { + "emoji": "🔅", + "aliases": [ + "low_brightness" + ] + }, + { + "emoji": "🧳", + "aliases": [ + "luggage" + ] + }, + { + "emoji": "🫁", + "aliases": [ + "lungs" + ] + }, + { + "emoji": "🇱🇺", + "aliases": [ + "luxembourg" + ] + }, + { + "emoji": "🤥", + "aliases": [ + "lying_face" + ] + }, + { + "emoji": "Ⓜ️", + "aliases": [ + "m" + ] + }, + { + "emoji": "🇲🇴", + "aliases": [ + "macau" + ] + }, + { + "emoji": "🇲🇰", + "aliases": [ + "macedonia" + ] + }, + { + "emoji": "🇲🇬", + "aliases": [ + "madagascar" + ] + }, + { + "emoji": "🔍", + "aliases": [ + "mag" + ] + }, + { + "emoji": "🔎", + "aliases": [ + "mag_right" + ] + }, + { + "emoji": "🧙", + "aliases": [ + "mage" + ] + }, + { + "emoji": "🧙‍♂️", + "aliases": [ + "mage_man" + ] + }, + { + "emoji": "🧙‍♀️", + "aliases": [ + "mage_woman" + ] + }, + { + "emoji": "🪄", + "aliases": [ + "magic_wand" + ] + }, + { + "emoji": "🧲", + "aliases": [ + "magnet" + ] + }, + { + "emoji": "🀄", + "aliases": [ + "mahjong" + ] + }, + { + "emoji": "📫", + "aliases": [ + "mailbox" + ] + }, + { + "emoji": "📪", + "aliases": [ + "mailbox_closed" + ] + }, + { + "emoji": "📬", + "aliases": [ + "mailbox_with_mail" + ] + }, + { + "emoji": "📭", + "aliases": [ + "mailbox_with_no_mail" + ] + }, + { + "emoji": "🇲🇼", + "aliases": [ + "malawi" + ] + }, + { + "emoji": "🇲🇾", + "aliases": [ + "malaysia" + ] + }, + { + "emoji": "🇲🇻", + "aliases": [ + "maldives" + ] + }, + { + "emoji": "🕵️‍♂️", + "aliases": [ + "male_detective" + ] + }, + { + "emoji": "♂️", + "aliases": [ + "male_sign" + ] + }, + { + "emoji": "🇲🇱", + "aliases": [ + "mali" + ] + }, + { + "emoji": "🇲🇹", + "aliases": [ + "malta" + ] + }, + { + "emoji": "🦣", + "aliases": [ + "mammoth" + ] + }, + { + "emoji": "👨", + "aliases": [ + "man" + ] + }, + { + "emoji": "👨‍🎨", + "aliases": [ + "man_artist" + ] + }, + { + "emoji": "👨‍🚀", + "aliases": [ + "man_astronaut" + ] + }, + { + "emoji": "🧔‍♂️", + "aliases": [ + "man_beard" + ] + }, + { + "emoji": "🤸‍♂️", + "aliases": [ + "man_cartwheeling" + ] + }, + { + "emoji": "👨‍🍳", + "aliases": [ + "man_cook" + ] + }, + { + "emoji": "🕺", + "aliases": [ + "man_dancing" + ] + }, + { + "emoji": "🤦‍♂️", + "aliases": [ + "man_facepalming" + ] + }, + { + "emoji": "👨‍🏭", + "aliases": [ + "man_factory_worker" + ] + }, + { + "emoji": "👨‍🌾", + "aliases": [ + "man_farmer" + ] + }, + { + "emoji": "👨‍🍼", + "aliases": [ + "man_feeding_baby" + ] + }, + { + "emoji": "👨‍🚒", + "aliases": [ + "man_firefighter" + ] + }, + { + "emoji": "👨‍⚕️", + "aliases": [ + "man_health_worker" + ] + }, + { + "emoji": "👨‍🦽", + "aliases": [ + "man_in_manual_wheelchair" + ] + }, + { + "emoji": "👨‍🦽‍➡️", + "aliases": [ + "man_in_manual_wheelchair_facing_right" + ] + }, + { + "emoji": "👨‍🦼", + "aliases": [ + "man_in_motorized_wheelchair" + ] + }, + { + "emoji": "👨‍🦼‍➡️", + "aliases": [ + "man_in_motorized_wheelchair_facing_right" + ] + }, + { + "emoji": "🤵‍♂️", + "aliases": [ + "man_in_tuxedo" + ] + }, + { + "emoji": "👨‍⚖️", + "aliases": [ + "man_judge" + ] + }, + { + "emoji": "🤹‍♂️", + "aliases": [ + "man_juggling" + ] + }, + { + "emoji": "🧎‍♂️‍➡️", + "aliases": [ + "man_kneeling_facing_right" + ] + }, + { + "emoji": "👨‍🔧", + "aliases": [ + "man_mechanic" + ] + }, + { + "emoji": "👨‍💼", + "aliases": [ + "man_office_worker" + ] + }, + { + "emoji": "👨‍✈️", + "aliases": [ + "man_pilot" + ] + }, + { + "emoji": "🤾‍♂️", + "aliases": [ + "man_playing_handball" + ] + }, + { + "emoji": "🤽‍♂️", + "aliases": [ + "man_playing_water_polo" + ] + }, + { + "emoji": "🏃‍♂️‍➡️", + "aliases": [ + "man_running_facing_right" + ] + }, + { + "emoji": "👨‍🔬", + "aliases": [ + "man_scientist" + ] + }, + { + "emoji": "🤷‍♂️", + "aliases": [ + "man_shrugging" + ] + }, + { + "emoji": "👨‍🎤", + "aliases": [ + "man_singer" + ] + }, + { + "emoji": "👨‍🎓", + "aliases": [ + "man_student" + ] + }, + { + "emoji": "👨‍🏫", + "aliases": [ + "man_teacher" + ] + }, + { + "emoji": "👨‍💻", + "aliases": [ + "man_technologist" + ] + }, + { + "emoji": "🚶‍♂️‍➡️", + "aliases": [ + "man_walking_facing_right" + ] + }, + { + "emoji": "👲", + "aliases": [ + "man_with_gua_pi_mao" + ] + }, + { + "emoji": "👨‍🦯", + "aliases": [ + "man_with_probing_cane" + ] + }, + { + "emoji": "👳‍♂️", + "aliases": [ + "man_with_turban" + ] + }, + { + "emoji": "👰‍♂️", + "aliases": [ + "man_with_veil" + ] + }, + { + "emoji": "👨‍🦯‍➡️", + "aliases": [ + "man_with_white_cane_facing_right" + ] + }, + { + "emoji": "🥭", + "aliases": [ + "mango" + ] + }, + { + "emoji": "👞", + "aliases": [ + "mans_shoe", + "shoe" + ] + }, + { + "emoji": "🕰️", + "aliases": [ + "mantelpiece_clock" + ] + }, + { + "emoji": "🦽", + "aliases": [ + "manual_wheelchair" + ] + }, + { + "emoji": "🍁", + "aliases": [ + "maple_leaf" + ] + }, + { + "emoji": "🪇", + "aliases": [ + "maracas" + ] + }, + { + "emoji": "🇲🇭", + "aliases": [ + "marshall_islands" + ] + }, + { + "emoji": "🥋", + "aliases": [ + "martial_arts_uniform" + ] + }, + { + "emoji": "🇲🇶", + "aliases": [ + "martinique" + ] + }, + { + "emoji": "😷", + "aliases": [ + "mask" + ] + }, + { + "emoji": "💆", + "aliases": [ + "massage" + ] + }, + { + "emoji": "💆‍♂️", + "aliases": [ + "massage_man" + ] + }, + { + "emoji": "💆‍♀️", + "aliases": [ + "massage_woman" + ] + }, + { + "emoji": "🧉", + "aliases": [ + "mate" + ] + }, + { + "emoji": "🇲🇷", + "aliases": [ + "mauritania" + ] + }, + { + "emoji": "🇲🇺", + "aliases": [ + "mauritius" + ] + }, + { + "emoji": "🇾🇹", + "aliases": [ + "mayotte" + ] + }, + { + "emoji": "🍖", + "aliases": [ + "meat_on_bone" + ] + }, + { + "emoji": "🧑‍🔧", + "aliases": [ + "mechanic" + ] + }, + { + "emoji": "🦾", + "aliases": [ + "mechanical_arm" + ] + }, + { + "emoji": "🦿", + "aliases": [ + "mechanical_leg" + ] + }, + { + "emoji": "🎖️", + "aliases": [ + "medal_military" + ] + }, + { + "emoji": "🏅", + "aliases": [ + "medal_sports" + ] + }, + { + "emoji": "⚕️", + "aliases": [ + "medical_symbol" + ] + }, + { + "emoji": "📣", + "aliases": [ + "mega" + ] + }, + { + "emoji": "🍈", + "aliases": [ + "melon" + ] + }, + { + "emoji": "🫠", + "aliases": [ + "melting_face" + ] + }, + { + "emoji": "📝", + "aliases": [ + "memo", + "pencil" + ] + }, + { + "emoji": "🤼‍♂️", + "aliases": [ + "men_wrestling" + ] + }, + { + "emoji": "❤️‍🩹", + "aliases": [ + "mending_heart" + ] + }, + { + "emoji": "🕎", + "aliases": [ + "menorah" + ] + }, + { + "emoji": "🚹", + "aliases": [ + "mens" + ] + }, + { + "emoji": "🧜‍♀️", + "aliases": [ + "mermaid" + ] + }, + { + "emoji": "🧜‍♂️", + "aliases": [ + "merman" + ] + }, + { + "emoji": "🧜", + "aliases": [ + "merperson" + ] + }, + { + "emoji": "🤘", + "aliases": [ + "metal" + ] + }, + { + "emoji": "🚇", + "aliases": [ + "metro" + ] + }, + { + "emoji": "🇲🇽", + "aliases": [ + "mexico" + ] + }, + { + "emoji": "🦠", + "aliases": [ + "microbe" + ] + }, + { + "emoji": "🇫🇲", + "aliases": [ + "micronesia" + ] + }, + { + "emoji": "🎤", + "aliases": [ + "microphone" + ] + }, + { + "emoji": "🔬", + "aliases": [ + "microscope" + ] + }, + { + "emoji": "🖕", + "aliases": [ + "middle_finger", + "fu" + ] + }, + { + "emoji": "🪖", + "aliases": [ + "military_helmet" + ] + }, + { + "emoji": "🥛", + "aliases": [ + "milk_glass" + ] + }, + { + "emoji": "🌌", + "aliases": [ + "milky_way" + ] + }, + { + "emoji": "🚐", + "aliases": [ + "minibus" + ] + }, + { + "emoji": "💽", + "aliases": [ + "minidisc" + ] + }, + { + "emoji": "🪞", + "aliases": [ + "mirror" + ] + }, + { + "emoji": "🪩", + "aliases": [ + "mirror_ball" + ] + }, + { + "emoji": "📴", + "aliases": [ + "mobile_phone_off" + ] + }, + { + "emoji": "🇲🇩", + "aliases": [ + "moldova" + ] + }, + { + "emoji": "🇲🇨", + "aliases": [ + "monaco" + ] + }, + { + "emoji": "🤑", + "aliases": [ + "money_mouth_face" + ] + }, + { + "emoji": "💸", + "aliases": [ + "money_with_wings" + ] + }, + { + "emoji": "💰", + "aliases": [ + "moneybag" + ] + }, + { + "emoji": "🇲🇳", + "aliases": [ + "mongolia" + ] + }, + { + "emoji": "🐒", + "aliases": [ + "monkey" + ] + }, + { + "emoji": "🐵", + "aliases": [ + "monkey_face" + ] + }, + { + "emoji": "🧐", + "aliases": [ + "monocle_face" + ] + }, + { + "emoji": "🚝", + "aliases": [ + "monorail" + ] + }, + { + "emoji": "🇲🇪", + "aliases": [ + "montenegro" + ] + }, + { + "emoji": "🇲🇸", + "aliases": [ + "montserrat" + ] + }, + { + "emoji": "🌔", + "aliases": [ + "moon", + "waxing_gibbous_moon" + ] + }, + { + "emoji": "🥮", + "aliases": [ + "moon_cake" + ] + }, + { + "emoji": "🫎", + "aliases": [ + "moose" + ] + }, + { + "emoji": "🇲🇦", + "aliases": [ + "morocco" + ] + }, + { + "emoji": "🎓", + "aliases": [ + "mortar_board" + ] + }, + { + "emoji": "🕌", + "aliases": [ + "mosque" + ] + }, + { + "emoji": "🦟", + "aliases": [ + "mosquito" + ] + }, + { + "emoji": "🛥️", + "aliases": [ + "motor_boat" + ] + }, + { + "emoji": "🛵", + "aliases": [ + "motor_scooter" + ] + }, + { + "emoji": "🏍️", + "aliases": [ + "motorcycle" + ] + }, + { + "emoji": "🦼", + "aliases": [ + "motorized_wheelchair" + ] + }, + { + "emoji": "🛣️", + "aliases": [ + "motorway" + ] + }, + { + "emoji": "🗻", + "aliases": [ + "mount_fuji" + ] + }, + { + "emoji": "⛰️", + "aliases": [ + "mountain" + ] + }, + { + "emoji": "🚵", + "aliases": [ + "mountain_bicyclist" + ] + }, + { + "emoji": "🚵‍♂️", + "aliases": [ + "mountain_biking_man" + ] + }, + { + "emoji": "🚵‍♀️", + "aliases": [ + "mountain_biking_woman" + ] + }, + { + "emoji": "🚠", + "aliases": [ + "mountain_cableway" + ] + }, + { + "emoji": "🚞", + "aliases": [ + "mountain_railway" + ] + }, + { + "emoji": "🏔️", + "aliases": [ + "mountain_snow" + ] + }, + { + "emoji": "🐭", + "aliases": [ + "mouse" + ] + }, + { + "emoji": "🐁", + "aliases": [ + "mouse2" + ] + }, + { + "emoji": "🪤", + "aliases": [ + "mouse_trap" + ] + }, + { + "emoji": "🎥", + "aliases": [ + "movie_camera" + ] + }, + { + "emoji": "🗿", + "aliases": [ + "moyai" + ] + }, + { + "emoji": "🇲🇿", + "aliases": [ + "mozambique" + ] + }, + { + "emoji": "🤶", + "aliases": [ + "mrs_claus" + ] + }, + { + "emoji": "💪", + "aliases": [ + "muscle" + ] + }, + { + "emoji": "🍄", + "aliases": [ + "mushroom" + ] + }, + { + "emoji": "🎹", + "aliases": [ + "musical_keyboard" + ] + }, + { + "emoji": "🎵", + "aliases": [ + "musical_note" + ] + }, + { + "emoji": "🎼", + "aliases": [ + "musical_score" + ] + }, + { + "emoji": "🔇", + "aliases": [ + "mute" + ] + }, + { + "emoji": "🧑‍🎄", + "aliases": [ + "mx_claus" + ] + }, + { + "emoji": "🇲🇲", + "aliases": [ + "myanmar" + ] + }, + { + "emoji": "💅", + "aliases": [ + "nail_care" + ] + }, + { + "emoji": "📛", + "aliases": [ + "name_badge" + ] + }, + { + "emoji": "🇳🇦", + "aliases": [ + "namibia" + ] + }, + { + "emoji": "🏞️", + "aliases": [ + "national_park" + ] + }, + { + "emoji": "🇳🇷", + "aliases": [ + "nauru" + ] + }, + { + "emoji": "🤢", + "aliases": [ + "nauseated_face" + ] + }, + { + "emoji": "🧿", + "aliases": [ + "nazar_amulet" + ] + }, + { + "emoji": "👔", + "aliases": [ + "necktie" + ] + }, + { + "emoji": "❎", + "aliases": [ + "negative_squared_cross_mark" + ] + }, + { + "emoji": "🇳🇵", + "aliases": [ + "nepal" + ] + }, + { + "emoji": "🤓", + "aliases": [ + "nerd_face" + ] + }, + { + "emoji": "🪺", + "aliases": [ + "nest_with_eggs" + ] + }, + { + "emoji": "🪆", + "aliases": [ + "nesting_dolls" + ] + }, + { + "emoji": "🇳🇱", + "aliases": [ + "netherlands" + ] + }, + { + "emoji": "😐", + "aliases": [ + "neutral_face" + ] + }, + { + "emoji": "🆕", + "aliases": [ + "new" + ] + }, + { + "emoji": "🇳🇨", + "aliases": [ + "new_caledonia" + ] + }, + { + "emoji": "🌑", + "aliases": [ + "new_moon" + ] + }, + { + "emoji": "🌚", + "aliases": [ + "new_moon_with_face" + ] + }, + { + "emoji": "🇳🇿", + "aliases": [ + "new_zealand" + ] + }, + { + "emoji": "📰", + "aliases": [ + "newspaper" + ] + }, + { + "emoji": "🗞️", + "aliases": [ + "newspaper_roll" + ] + }, + { + "emoji": "⏭️", + "aliases": [ + "next_track_button" + ] + }, + { + "emoji": "🆖", + "aliases": [ + "ng" + ] + }, + { + "emoji": "🇳🇮", + "aliases": [ + "nicaragua" + ] + }, + { + "emoji": "🇳🇪", + "aliases": [ + "niger" + ] + }, + { + "emoji": "🇳🇬", + "aliases": [ + "nigeria" + ] + }, + { + "emoji": "🌃", + "aliases": [ + "night_with_stars" + ] + }, + { + "emoji": "9️⃣", + "aliases": [ + "nine" + ] + }, + { + "emoji": "🥷", + "aliases": [ + "ninja" + ] + }, + { + "emoji": "🇳🇺", + "aliases": [ + "niue" + ] + }, + { + "emoji": "🔕", + "aliases": [ + "no_bell" + ] + }, + { + "emoji": "🚳", + "aliases": [ + "no_bicycles" + ] + }, + { + "emoji": "⛔", + "aliases": [ + "no_entry" + ] + }, + { + "emoji": "🚫", + "aliases": [ + "no_entry_sign" + ] + }, + { + "emoji": "🙅", + "aliases": [ + "no_good" + ] + }, + { + "emoji": "🙅‍♂️", + "aliases": [ + "no_good_man", + "ng_man" + ] + }, + { + "emoji": "🙅‍♀️", + "aliases": [ + "no_good_woman", + "ng_woman" + ] + }, + { + "emoji": "📵", + "aliases": [ + "no_mobile_phones" + ] + }, + { + "emoji": "😶", + "aliases": [ + "no_mouth" + ] + }, + { + "emoji": "🚷", + "aliases": [ + "no_pedestrians" + ] + }, + { + "emoji": "🚭", + "aliases": [ + "no_smoking" + ] + }, + { + "emoji": "🚱", + "aliases": [ + "non-potable_water" + ] + }, + { + "emoji": "🇳🇫", + "aliases": [ + "norfolk_island" + ] + }, + { + "emoji": "🇰🇵", + "aliases": [ + "north_korea" + ] + }, + { + "emoji": "🇲🇵", + "aliases": [ + "northern_mariana_islands" + ] + }, + { + "emoji": "🇳🇴", + "aliases": [ + "norway" + ] + }, + { + "emoji": "👃", + "aliases": [ + "nose" + ] + }, + { + "emoji": "📓", + "aliases": [ + "notebook" + ] + }, + { + "emoji": "📔", + "aliases": [ + "notebook_with_decorative_cover" + ] + }, + { + "emoji": "🎶", + "aliases": [ + "notes" + ] + }, + { + "emoji": "🔩", + "aliases": [ + "nut_and_bolt" + ] + }, + { + "emoji": "⭕", + "aliases": [ + "o" + ] + }, + { + "emoji": "🅾️", + "aliases": [ + "o2" + ] + }, + { + "emoji": "🌊", + "aliases": [ + "ocean" + ] + }, + { + "emoji": "🐙", + "aliases": [ + "octopus" + ] + }, + { + "emoji": "🍢", + "aliases": [ + "oden" + ] + }, + { + "emoji": "🏢", + "aliases": [ + "office" + ] + }, + { + "emoji": "🧑‍💼", + "aliases": [ + "office_worker" + ] + }, + { + "emoji": "🛢️", + "aliases": [ + "oil_drum" + ] + }, + { + "emoji": "🆗", + "aliases": [ + "ok" + ] + }, + { + "emoji": "👌", + "aliases": [ + "ok_hand" + ] + }, + { + "emoji": "🙆‍♂️", + "aliases": [ + "ok_man" + ] + }, + { + "emoji": "🙆", + "aliases": [ + "ok_person" + ] + }, + { + "emoji": "🙆‍♀️", + "aliases": [ + "ok_woman" + ] + }, + { + "emoji": "🗝️", + "aliases": [ + "old_key" + ] + }, + { + "emoji": "🧓", + "aliases": [ + "older_adult" + ] + }, + { + "emoji": "👴", + "aliases": [ + "older_man" + ] + }, + { + "emoji": "👵", + "aliases": [ + "older_woman" + ] + }, + { + "emoji": "🫒", + "aliases": [ + "olive" + ] + }, + { + "emoji": "🕉️", + "aliases": [ + "om" + ] + }, + { + "emoji": "🇴🇲", + "aliases": [ + "oman" + ] + }, + { + "emoji": "🔛", + "aliases": [ + "on" + ] + }, + { + "emoji": "🚘", + "aliases": [ + "oncoming_automobile" + ] + }, + { + "emoji": "🚍", + "aliases": [ + "oncoming_bus" + ] + }, + { + "emoji": "🚔", + "aliases": [ + "oncoming_police_car" + ] + }, + { + "emoji": "🚖", + "aliases": [ + "oncoming_taxi" + ] + }, + { + "emoji": "1️⃣", + "aliases": [ + "one" + ] + }, + { + "emoji": "🩱", + "aliases": [ + "one_piece_swimsuit" + ] + }, + { + "emoji": "🧅", + "aliases": [ + "onion" + ] + }, + { + "emoji": "📂", + "aliases": [ + "open_file_folder" + ] + }, + { + "emoji": "👐", + "aliases": [ + "open_hands" + ] + }, + { + "emoji": "😮", + "aliases": [ + "open_mouth" + ] + }, + { + "emoji": "☂️", + "aliases": [ + "open_umbrella" + ] + }, + { + "emoji": "⛎", + "aliases": [ + "ophiuchus" + ] + }, + { + "emoji": "📙", + "aliases": [ + "orange_book" + ] + }, + { + "emoji": "🟠", + "aliases": [ + "orange_circle" + ] + }, + { + "emoji": "🧡", + "aliases": [ + "orange_heart" + ] + }, + { + "emoji": "🟧", + "aliases": [ + "orange_square" + ] + }, + { + "emoji": "🦧", + "aliases": [ + "orangutan" + ] + }, + { + "emoji": "☦️", + "aliases": [ + "orthodox_cross" + ] + }, + { + "emoji": "🦦", + "aliases": [ + "otter" + ] + }, + { + "emoji": "📤", + "aliases": [ + "outbox_tray" + ] + }, + { + "emoji": "🦉", + "aliases": [ + "owl" + ] + }, + { + "emoji": "🐂", + "aliases": [ + "ox" + ] + }, + { + "emoji": "🦪", + "aliases": [ + "oyster" + ] + }, + { + "emoji": "📦", + "aliases": [ + "package" + ] + }, + { + "emoji": "📄", + "aliases": [ + "page_facing_up" + ] + }, + { + "emoji": "📃", + "aliases": [ + "page_with_curl" + ] + }, + { + "emoji": "📟", + "aliases": [ + "pager" + ] + }, + { + "emoji": "🖌️", + "aliases": [ + "paintbrush" + ] + }, + { + "emoji": "🇵🇰", + "aliases": [ + "pakistan" + ] + }, + { + "emoji": "🇵🇼", + "aliases": [ + "palau" + ] + }, + { + "emoji": "🇵🇸", + "aliases": [ + "palestinian_territories" + ] + }, + { + "emoji": "🫳", + "aliases": [ + "palm_down_hand" + ] + }, + { + "emoji": "🌴", + "aliases": [ + "palm_tree" + ] + }, + { + "emoji": "🫴", + "aliases": [ + "palm_up_hand" + ] + }, + { + "emoji": "🤲", + "aliases": [ + "palms_up_together" + ] + }, + { + "emoji": "🇵🇦", + "aliases": [ + "panama" + ] + }, + { + "emoji": "🥞", + "aliases": [ + "pancakes" + ] + }, + { + "emoji": "🐼", + "aliases": [ + "panda_face" + ] + }, + { + "emoji": "📎", + "aliases": [ + "paperclip" + ] + }, + { + "emoji": "🖇️", + "aliases": [ + "paperclips" + ] + }, + { + "emoji": "🇵🇬", + "aliases": [ + "papua_new_guinea" + ] + }, + { + "emoji": "🪂", + "aliases": [ + "parachute" + ] + }, + { + "emoji": "🇵🇾", + "aliases": [ + "paraguay" + ] + }, + { + "emoji": "⛱️", + "aliases": [ + "parasol_on_ground" + ] + }, + { + "emoji": "🅿️", + "aliases": [ + "parking" + ] + }, + { + "emoji": "🦜", + "aliases": [ + "parrot" + ] + }, + { + "emoji": "〽️", + "aliases": [ + "part_alternation_mark" + ] + }, + { + "emoji": "⛅", + "aliases": [ + "partly_sunny" + ] + }, + { + "emoji": "🥳", + "aliases": [ + "partying_face" + ] + }, + { + "emoji": "🛳️", + "aliases": [ + "passenger_ship" + ] + }, + { + "emoji": "🛂", + "aliases": [ + "passport_control" + ] + }, + { + "emoji": "⏸️", + "aliases": [ + "pause_button" + ] + }, + { + "emoji": "🫛", + "aliases": [ + "pea_pod" + ] + }, + { + "emoji": "☮️", + "aliases": [ + "peace_symbol" + ] + }, + { + "emoji": "🍑", + "aliases": [ + "peach" + ] + }, + { + "emoji": "🦚", + "aliases": [ + "peacock" + ] + }, + { + "emoji": "🥜", + "aliases": [ + "peanuts" + ] + }, + { + "emoji": "🍐", + "aliases": [ + "pear" + ] + }, + { + "emoji": "🖊️", + "aliases": [ + "pen" + ] + }, + { + "emoji": "✏️", + "aliases": [ + "pencil2" + ] + }, + { + "emoji": "🐧", + "aliases": [ + "penguin" + ] + }, + { + "emoji": "😔", + "aliases": [ + "pensive" + ] + }, + { + "emoji": "🧑‍🤝‍🧑", + "aliases": [ + "people_holding_hands" + ] + }, + { + "emoji": "🫂", + "aliases": [ + "people_hugging" + ] + }, + { + "emoji": "🎭", + "aliases": [ + "performing_arts" + ] + }, + { + "emoji": "😣", + "aliases": [ + "persevere" + ] + }, + { + "emoji": "🧑‍🦲", + "aliases": [ + "person_bald" + ] + }, + { + "emoji": "🧑‍🦱", + "aliases": [ + "person_curly_hair" + ] + }, + { + "emoji": "🧑‍🍼", + "aliases": [ + "person_feeding_baby" + ] + }, + { + "emoji": "🤺", + "aliases": [ + "person_fencing" + ] + }, + { + "emoji": "🧑‍🦽", + "aliases": [ + "person_in_manual_wheelchair" + ] + }, + { + "emoji": "🧑‍🦽‍➡️", + "aliases": [ + "person_in_manual_wheelchair_facing_right" + ] + }, + { + "emoji": "🧑‍🦼", + "aliases": [ + "person_in_motorized_wheelchair" + ] + }, + { + "emoji": "🧑‍🦼‍➡️", + "aliases": [ + "person_in_motorized_wheelchair_facing_right" + ] + }, + { + "emoji": "🤵", + "aliases": [ + "person_in_tuxedo" + ] + }, + { + "emoji": "🧎‍➡️", + "aliases": [ + "person_kneeling_facing_right" + ] + }, + { + "emoji": "🧑‍🦰", + "aliases": [ + "person_red_hair" + ] + }, + { + "emoji": "🏃‍➡️", + "aliases": [ + "person_running_facing_right" + ] + }, + { + "emoji": "🚶‍➡️", + "aliases": [ + "person_walking_facing_right" + ] + }, + { + "emoji": "🧑‍🦳", + "aliases": [ + "person_white_hair" + ] + }, + { + "emoji": "🫅", + "aliases": [ + "person_with_crown" + ] + }, + { + "emoji": "🧑‍🦯", + "aliases": [ + "person_with_probing_cane" + ] + }, + { + "emoji": "👳", + "aliases": [ + "person_with_turban" + ] + }, + { + "emoji": "👰", + "aliases": [ + "person_with_veil" + ] + }, + { + "emoji": "🧑‍🦯‍➡️", + "aliases": [ + "person_with_white_cane_facing_right" + ] + }, + { + "emoji": "🇵🇪", + "aliases": [ + "peru" + ] + }, + { + "emoji": "🧫", + "aliases": [ + "petri_dish" + ] + }, + { + "emoji": "🇵🇭", + "aliases": [ + "philippines" + ] + }, + { + "emoji": "🐦‍🔥", + "aliases": [ + "phoenix" + ] + }, + { + "emoji": "☎️", + "aliases": [ + "phone", + "telephone" + ] + }, + { + "emoji": "⛏️", + "aliases": [ + "pick" + ] + }, + { + "emoji": "🛻", + "aliases": [ + "pickup_truck" + ] + }, + { + "emoji": "🥧", + "aliases": [ + "pie" + ] + }, + { + "emoji": "🐷", + "aliases": [ + "pig" + ] + }, + { + "emoji": "🐖", + "aliases": [ + "pig2" + ] + }, + { + "emoji": "🐽", + "aliases": [ + "pig_nose" + ] + }, + { + "emoji": "💊", + "aliases": [ + "pill" + ] + }, + { + "emoji": "🧑‍✈️", + "aliases": [ + "pilot" + ] + }, + { + "emoji": "🪅", + "aliases": [ + "pinata" + ] + }, + { + "emoji": "🤌", + "aliases": [ + "pinched_fingers" + ] + }, + { + "emoji": "🤏", + "aliases": [ + "pinching_hand" + ] + }, + { + "emoji": "🍍", + "aliases": [ + "pineapple" + ] + }, + { + "emoji": "🏓", + "aliases": [ + "ping_pong" + ] + }, + { + "emoji": "🩷", + "aliases": [ + "pink_heart" + ] + }, + { + "emoji": "🏴‍☠️", + "aliases": [ + "pirate_flag" + ] + }, + { + "emoji": "♓", + "aliases": [ + "pisces" + ] + }, + { + "emoji": "🇵🇳", + "aliases": [ + "pitcairn_islands" + ] + }, + { + "emoji": "🍕", + "aliases": [ + "pizza" + ] + }, + { + "emoji": "🪧", + "aliases": [ + "placard" + ] + }, + { + "emoji": "🛐", + "aliases": [ + "place_of_worship" + ] + }, + { + "emoji": "🍽️", + "aliases": [ + "plate_with_cutlery" + ] + }, + { + "emoji": "⏯️", + "aliases": [ + "play_or_pause_button" + ] + }, + { + "emoji": "🛝", + "aliases": [ + "playground_slide" + ] + }, + { + "emoji": "🥺", + "aliases": [ + "pleading_face" + ] + }, + { + "emoji": "🪠", + "aliases": [ + "plunger" + ] + }, + { + "emoji": "👇", + "aliases": [ + "point_down" + ] + }, + { + "emoji": "👈", + "aliases": [ + "point_left" + ] + }, + { + "emoji": "👉", + "aliases": [ + "point_right" + ] + }, + { + "emoji": "☝️", + "aliases": [ + "point_up" + ] + }, + { + "emoji": "👆", + "aliases": [ + "point_up_2" + ] + }, + { + "emoji": "🇵🇱", + "aliases": [ + "poland" + ] + }, + { + "emoji": "🐻‍❄️", + "aliases": [ + "polar_bear" + ] + }, + { + "emoji": "🚓", + "aliases": [ + "police_car" + ] + }, + { + "emoji": "👮", + "aliases": [ + "police_officer", + "cop" + ] + }, + { + "emoji": "👮‍♂️", + "aliases": [ + "policeman" + ] + }, + { + "emoji": "👮‍♀️", + "aliases": [ + "policewoman" + ] + }, + { + "emoji": "🐩", + "aliases": [ + "poodle" + ] + }, + { + "emoji": "🍿", + "aliases": [ + "popcorn" + ] + }, + { + "emoji": "🇵🇹", + "aliases": [ + "portugal" + ] + }, + { + "emoji": "🏣", + "aliases": [ + "post_office" + ] + }, + { + "emoji": "📯", + "aliases": [ + "postal_horn" + ] + }, + { + "emoji": "📮", + "aliases": [ + "postbox" + ] + }, + { + "emoji": "🚰", + "aliases": [ + "potable_water" + ] + }, + { + "emoji": "🥔", + "aliases": [ + "potato" + ] + }, + { + "emoji": "🪴", + "aliases": [ + "potted_plant" + ] + }, + { + "emoji": "👝", + "aliases": [ + "pouch" + ] + }, + { + "emoji": "🍗", + "aliases": [ + "poultry_leg" + ] + }, + { + "emoji": "💷", + "aliases": [ + "pound" + ] + }, + { + "emoji": "🫗", + "aliases": [ + "pouring_liquid" + ] + }, + { + "emoji": "😾", + "aliases": [ + "pouting_cat" + ] + }, + { + "emoji": "🙎", + "aliases": [ + "pouting_face" + ] + }, + { + "emoji": "🙎‍♂️", + "aliases": [ + "pouting_man" + ] + }, + { + "emoji": "🙎‍♀️", + "aliases": [ + "pouting_woman" + ] + }, + { + "emoji": "🙏", + "aliases": [ + "pray" + ] + }, + { + "emoji": "📿", + "aliases": [ + "prayer_beads" + ] + }, + { + "emoji": "🫃", + "aliases": [ + "pregnant_man" + ] + }, + { + "emoji": "🫄", + "aliases": [ + "pregnant_person" + ] + }, + { + "emoji": "🤰", + "aliases": [ + "pregnant_woman" + ] + }, + { + "emoji": "🥨", + "aliases": [ + "pretzel" + ] + }, + { + "emoji": "⏮️", + "aliases": [ + "previous_track_button" + ] + }, + { + "emoji": "🤴", + "aliases": [ + "prince" + ] + }, + { + "emoji": "👸", + "aliases": [ + "princess" + ] + }, + { + "emoji": "🖨️", + "aliases": [ + "printer" + ] + }, + { + "emoji": "🦯", + "aliases": [ + "probing_cane" + ] + }, + { + "emoji": "🇵🇷", + "aliases": [ + "puerto_rico" + ] + }, + { + "emoji": "🟣", + "aliases": [ + "purple_circle" + ] + }, + { + "emoji": "💜", + "aliases": [ + "purple_heart" + ] + }, + { + "emoji": "🟪", + "aliases": [ + "purple_square" + ] + }, + { + "emoji": "👛", + "aliases": [ + "purse" + ] + }, + { + "emoji": "📌", + "aliases": [ + "pushpin" + ] + }, + { + "emoji": "🚮", + "aliases": [ + "put_litter_in_its_place" + ] + }, + { + "emoji": "🇶🇦", + "aliases": [ + "qatar" + ] + }, + { + "emoji": "❓", + "aliases": [ + "question" + ] + }, + { + "emoji": "🐰", + "aliases": [ + "rabbit" + ] + }, + { + "emoji": "🐇", + "aliases": [ + "rabbit2" + ] + }, + { + "emoji": "🦝", + "aliases": [ + "raccoon" + ] + }, + { + "emoji": "🐎", + "aliases": [ + "racehorse" + ] + }, + { + "emoji": "🏎️", + "aliases": [ + "racing_car" + ] + }, + { + "emoji": "📻", + "aliases": [ + "radio" + ] + }, + { + "emoji": "🔘", + "aliases": [ + "radio_button" + ] + }, + { + "emoji": "☢️", + "aliases": [ + "radioactive" + ] + }, + { + "emoji": "😡", + "aliases": [ + "rage", + "pout" + ] + }, + { + "emoji": "🚃", + "aliases": [ + "railway_car" + ] + }, + { + "emoji": "🛤️", + "aliases": [ + "railway_track" + ] + }, + { + "emoji": "🌈", + "aliases": [ + "rainbow" + ] + }, + { + "emoji": "🏳️‍🌈", + "aliases": [ + "rainbow_flag" + ] + }, + { + "emoji": "🤚", + "aliases": [ + "raised_back_of_hand" + ] + }, + { + "emoji": "🤨", + "aliases": [ + "raised_eyebrow" + ] + }, + { + "emoji": "🖐️", + "aliases": [ + "raised_hand_with_fingers_splayed" + ] + }, + { + "emoji": "🙌", + "aliases": [ + "raised_hands" + ] + }, + { + "emoji": "🙋", + "aliases": [ + "raising_hand" + ] + }, + { + "emoji": "🙋‍♂️", + "aliases": [ + "raising_hand_man" + ] + }, + { + "emoji": "🙋‍♀️", + "aliases": [ + "raising_hand_woman" + ] + }, + { + "emoji": "🐏", + "aliases": [ + "ram" + ] + }, + { + "emoji": "🍜", + "aliases": [ + "ramen" + ] + }, + { + "emoji": "🐀", + "aliases": [ + "rat" + ] + }, + { + "emoji": "🪒", + "aliases": [ + "razor" + ] + }, + { + "emoji": "🧾", + "aliases": [ + "receipt" + ] + }, + { + "emoji": "⏺️", + "aliases": [ + "record_button" + ] + }, + { + "emoji": "♻️", + "aliases": [ + "recycle" + ] + }, + { + "emoji": "🔴", + "aliases": [ + "red_circle" + ] + }, + { + "emoji": "🧧", + "aliases": [ + "red_envelope" + ] + }, + { + "emoji": "👨‍🦰", + "aliases": [ + "red_haired_man" + ] + }, + { + "emoji": "👩‍🦰", + "aliases": [ + "red_haired_woman" + ] + }, + { + "emoji": "🟥", + "aliases": [ + "red_square" + ] + }, + { + "emoji": "®️", + "aliases": [ + "registered" + ] + }, + { + "emoji": "☺️", + "aliases": [ + "relaxed" + ] + }, + { + "emoji": "😌", + "aliases": [ + "relieved" + ] + }, + { + "emoji": "🎗️", + "aliases": [ + "reminder_ribbon" + ] + }, + { + "emoji": "🔁", + "aliases": [ + "repeat" + ] + }, + { + "emoji": "🔂", + "aliases": [ + "repeat_one" + ] + }, + { + "emoji": "⛑️", + "aliases": [ + "rescue_worker_helmet" + ] + }, + { + "emoji": "🚻", + "aliases": [ + "restroom" + ] + }, + { + "emoji": "🇷🇪", + "aliases": [ + "reunion" + ] + }, + { + "emoji": "💞", + "aliases": [ + "revolving_hearts" + ] + }, + { + "emoji": "⏪", + "aliases": [ + "rewind" + ] + }, + { + "emoji": "🦏", + "aliases": [ + "rhinoceros" + ] + }, + { + "emoji": "🎀", + "aliases": [ + "ribbon" + ] + }, + { + "emoji": "🍚", + "aliases": [ + "rice" + ] + }, + { + "emoji": "🍙", + "aliases": [ + "rice_ball" + ] + }, + { + "emoji": "🍘", + "aliases": [ + "rice_cracker" + ] + }, + { + "emoji": "🎑", + "aliases": [ + "rice_scene" + ] + }, + { + "emoji": "🗯️", + "aliases": [ + "right_anger_bubble" + ] + }, + { + "emoji": "🫱", + "aliases": [ + "rightwards_hand" + ] + }, + { + "emoji": "🫸", + "aliases": [ + "rightwards_pushing_hand" + ] + }, + { + "emoji": "💍", + "aliases": [ + "ring" + ] + }, + { + "emoji": "🛟", + "aliases": [ + "ring_buoy" + ] + }, + { + "emoji": "🪐", + "aliases": [ + "ringed_planet" + ] + }, + { + "emoji": "🤖", + "aliases": [ + "robot" + ] + }, + { + "emoji": "🪨", + "aliases": [ + "rock" + ] + }, + { + "emoji": "🚀", + "aliases": [ + "rocket" + ] + }, + { + "emoji": "🤣", + "aliases": [ + "rofl" + ] + }, + { + "emoji": "🙄", + "aliases": [ + "roll_eyes" + ] + }, + { + "emoji": "🧻", + "aliases": [ + "roll_of_paper" + ] + }, + { + "emoji": "🎢", + "aliases": [ + "roller_coaster" + ] + }, + { + "emoji": "🛼", + "aliases": [ + "roller_skate" + ] + }, + { + "emoji": "🇷🇴", + "aliases": [ + "romania" + ] + }, + { + "emoji": "🐓", + "aliases": [ + "rooster" + ] + }, + { + "emoji": "🫜", + "aliases": [ + "root_vegetable" + ] + }, + { + "emoji": "🌹", + "aliases": [ + "rose" + ] + }, + { + "emoji": "🏵️", + "aliases": [ + "rosette" + ] + }, + { + "emoji": "🚨", + "aliases": [ + "rotating_light" + ] + }, + { + "emoji": "📍", + "aliases": [ + "round_pushpin" + ] + }, + { + "emoji": "🚣", + "aliases": [ + "rowboat" + ] + }, + { + "emoji": "🚣‍♂️", + "aliases": [ + "rowing_man" + ] + }, + { + "emoji": "🚣‍♀️", + "aliases": [ + "rowing_woman" + ] + }, + { + "emoji": "🇷🇺", + "aliases": [ + "ru" + ] + }, + { + "emoji": "🏉", + "aliases": [ + "rugby_football" + ] + }, + { + "emoji": "🏃", + "aliases": [ + "runner", + "running" + ] + }, + { + "emoji": "🏃‍♂️", + "aliases": [ + "running_man" + ] + }, + { + "emoji": "🎽", + "aliases": [ + "running_shirt_with_sash" + ] + }, + { + "emoji": "🏃‍♀️", + "aliases": [ + "running_woman" + ] + }, + { + "emoji": "🇷🇼", + "aliases": [ + "rwanda" + ] + }, + { + "emoji": "🈂️", + "aliases": [ + "sa" + ] + }, + { + "emoji": "🧷", + "aliases": [ + "safety_pin" + ] + }, + { + "emoji": "🦺", + "aliases": [ + "safety_vest" + ] + }, + { + "emoji": "♐", + "aliases": [ + "sagittarius" + ] + }, + { + "emoji": "🍶", + "aliases": [ + "sake" + ] + }, + { + "emoji": "🧂", + "aliases": [ + "salt" + ] + }, + { + "emoji": "🫡", + "aliases": [ + "saluting_face" + ] + }, + { + "emoji": "🇼🇸", + "aliases": [ + "samoa" + ] + }, + { + "emoji": "🇸🇲", + "aliases": [ + "san_marino" + ] + }, + { + "emoji": "👡", + "aliases": [ + "sandal" + ] + }, + { + "emoji": "🥪", + "aliases": [ + "sandwich" + ] + }, + { + "emoji": "🎅", + "aliases": [ + "santa" + ] + }, + { + "emoji": "🇸🇹", + "aliases": [ + "sao_tome_principe" + ] + }, + { + "emoji": "🥻", + "aliases": [ + "sari" + ] + }, + { + "emoji": "📡", + "aliases": [ + "satellite" + ] + }, + { + "emoji": "🇸🇦", + "aliases": [ + "saudi_arabia" + ] + }, + { + "emoji": "🧖‍♂️", + "aliases": [ + "sauna_man" + ] + }, + { + "emoji": "🧖", + "aliases": [ + "sauna_person" + ] + }, + { + "emoji": "🧖‍♀️", + "aliases": [ + "sauna_woman" + ] + }, + { + "emoji": "🦕", + "aliases": [ + "sauropod" + ] + }, + { + "emoji": "🎷", + "aliases": [ + "saxophone" + ] + }, + { + "emoji": "🧣", + "aliases": [ + "scarf" + ] + }, + { + "emoji": "🏫", + "aliases": [ + "school" + ] + }, + { + "emoji": "🎒", + "aliases": [ + "school_satchel" + ] + }, + { + "emoji": "🧑‍🔬", + "aliases": [ + "scientist" + ] + }, + { + "emoji": "✂️", + "aliases": [ + "scissors" + ] + }, + { + "emoji": "🦂", + "aliases": [ + "scorpion" + ] + }, + { + "emoji": "♏", + "aliases": [ + "scorpius" + ] + }, + { + "emoji": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", + "aliases": [ + "scotland" + ] + }, + { + "emoji": "😱", + "aliases": [ + "scream" + ] + }, + { + "emoji": "🙀", + "aliases": [ + "scream_cat" + ] + }, + { + "emoji": "🪛", + "aliases": [ + "screwdriver" + ] + }, + { + "emoji": "📜", + "aliases": [ + "scroll" + ] + }, + { + "emoji": "🦭", + "aliases": [ + "seal" + ] + }, + { + "emoji": "💺", + "aliases": [ + "seat" + ] + }, + { + "emoji": "㊙️", + "aliases": [ + "secret" + ] + }, + { + "emoji": "🙈", + "aliases": [ + "see_no_evil" + ] + }, + { + "emoji": "🌱", + "aliases": [ + "seedling" + ] + }, + { + "emoji": "🤳", + "aliases": [ + "selfie" + ] + }, + { + "emoji": "🇸🇳", + "aliases": [ + "senegal" + ] + }, + { + "emoji": "🇷🇸", + "aliases": [ + "serbia" + ] + }, + { + "emoji": "🐕‍🦺", + "aliases": [ + "service_dog" + ] + }, + { + "emoji": "7️⃣", + "aliases": [ + "seven" + ] + }, + { + "emoji": "🪡", + "aliases": [ + "sewing_needle" + ] + }, + { + "emoji": "🇸🇨", + "aliases": [ + "seychelles" + ] + }, + { + "emoji": "🫨", + "aliases": [ + "shaking_face" + ] + }, + { + "emoji": "🥘", + "aliases": [ + "shallow_pan_of_food" + ] + }, + { + "emoji": "☘️", + "aliases": [ + "shamrock" + ] + }, + { + "emoji": "🦈", + "aliases": [ + "shark" + ] + }, + { + "emoji": "🍧", + "aliases": [ + "shaved_ice" + ] + }, + { + "emoji": "🐑", + "aliases": [ + "sheep" + ] + }, + { + "emoji": "🐚", + "aliases": [ + "shell" + ] + }, + { + "emoji": "🛡️", + "aliases": [ + "shield" + ] + }, + { + "emoji": "⛩️", + "aliases": [ + "shinto_shrine" + ] + }, + { + "emoji": "🚢", + "aliases": [ + "ship" + ] + }, + { + "emoji": "👕", + "aliases": [ + "shirt", + "tshirt" + ] + }, + { + "emoji": "🛍️", + "aliases": [ + "shopping" + ] + }, + { + "emoji": "🛒", + "aliases": [ + "shopping_cart" + ] + }, + { + "emoji": "🩳", + "aliases": [ + "shorts" + ] + }, + { + "emoji": "🪏", + "aliases": [ + "shovel" + ] + }, + { + "emoji": "🚿", + "aliases": [ + "shower" + ] + }, + { + "emoji": "🦐", + "aliases": [ + "shrimp" + ] + }, + { + "emoji": "🤷", + "aliases": [ + "shrug" + ] + }, + { + "emoji": "🤫", + "aliases": [ + "shushing_face" + ] + }, + { + "emoji": "🇸🇱", + "aliases": [ + "sierra_leone" + ] + }, + { + "emoji": "📶", + "aliases": [ + "signal_strength" + ] + }, + { + "emoji": "🇸🇬", + "aliases": [ + "singapore" + ] + }, + { + "emoji": "🧑‍🎤", + "aliases": [ + "singer" + ] + }, + { + "emoji": "🇸🇽", + "aliases": [ + "sint_maarten" + ] + }, + { + "emoji": "6️⃣", + "aliases": [ + "six" + ] + }, + { + "emoji": "🔯", + "aliases": [ + "six_pointed_star" + ] + }, + { + "emoji": "🛹", + "aliases": [ + "skateboard" + ] + }, + { + "emoji": "🎿", + "aliases": [ + "ski" + ] + }, + { + "emoji": "⛷️", + "aliases": [ + "skier" + ] + }, + { + "emoji": "💀", + "aliases": [ + "skull" + ] + }, + { + "emoji": "☠️", + "aliases": [ + "skull_and_crossbones" + ] + }, + { + "emoji": "🦨", + "aliases": [ + "skunk" + ] + }, + { + "emoji": "🛷", + "aliases": [ + "sled" + ] + }, + { + "emoji": "😴", + "aliases": [ + "sleeping" + ] + }, + { + "emoji": "🛌", + "aliases": [ + "sleeping_bed" + ] + }, + { + "emoji": "😪", + "aliases": [ + "sleepy" + ] + }, + { + "emoji": "🙁", + "aliases": [ + "slightly_frowning_face" + ] + }, + { + "emoji": "🙂", + "aliases": [ + "slightly_smiling_face" + ] + }, + { + "emoji": "🎰", + "aliases": [ + "slot_machine" + ] + }, + { + "emoji": "🦥", + "aliases": [ + "sloth" + ] + }, + { + "emoji": "🇸🇰", + "aliases": [ + "slovakia" + ] + }, + { + "emoji": "🇸🇮", + "aliases": [ + "slovenia" + ] + }, + { + "emoji": "🛩️", + "aliases": [ + "small_airplane" + ] + }, + { + "emoji": "🔹", + "aliases": [ + "small_blue_diamond" + ] + }, + { + "emoji": "🔸", + "aliases": [ + "small_orange_diamond" + ] + }, + { + "emoji": "🔺", + "aliases": [ + "small_red_triangle" + ] + }, + { + "emoji": "🔻", + "aliases": [ + "small_red_triangle_down" + ] + }, + { + "emoji": "😄", + "aliases": [ + "smile" + ] + }, + { + "emoji": "😸", + "aliases": [ + "smile_cat" + ] + }, + { + "emoji": "😃", + "aliases": [ + "smiley" + ] + }, + { + "emoji": "😺", + "aliases": [ + "smiley_cat" + ] + }, + { + "emoji": "🥲", + "aliases": [ + "smiling_face_with_tear" + ] + }, + { + "emoji": "🥰", + "aliases": [ + "smiling_face_with_three_hearts" + ] + }, + { + "emoji": "😈", + "aliases": [ + "smiling_imp" + ] + }, + { + "emoji": "😏", + "aliases": [ + "smirk" + ] + }, + { + "emoji": "😼", + "aliases": [ + "smirk_cat" + ] + }, + { + "emoji": "🚬", + "aliases": [ + "smoking" + ] + }, + { + "emoji": "🐌", + "aliases": [ + "snail" + ] + }, + { + "emoji": "🐍", + "aliases": [ + "snake" + ] + }, + { + "emoji": "🤧", + "aliases": [ + "sneezing_face" + ] + }, + { + "emoji": "🏂", + "aliases": [ + "snowboarder" + ] + }, + { + "emoji": "❄️", + "aliases": [ + "snowflake" + ] + }, + { + "emoji": "⛄", + "aliases": [ + "snowman" + ] + }, + { + "emoji": "☃️", + "aliases": [ + "snowman_with_snow" + ] + }, + { + "emoji": "🧼", + "aliases": [ + "soap" + ] + }, + { + "emoji": "😭", + "aliases": [ + "sob" + ] + }, + { + "emoji": "⚽", + "aliases": [ + "soccer" + ] + }, + { + "emoji": "🧦", + "aliases": [ + "socks" + ] + }, + { + "emoji": "🥎", + "aliases": [ + "softball" + ] + }, + { + "emoji": "🇸🇧", + "aliases": [ + "solomon_islands" + ] + }, + { + "emoji": "🇸🇴", + "aliases": [ + "somalia" + ] + }, + { + "emoji": "🔜", + "aliases": [ + "soon" + ] + }, + { + "emoji": "🆘", + "aliases": [ + "sos" + ] + }, + { + "emoji": "🔉", + "aliases": [ + "sound" + ] + }, + { + "emoji": "🇿🇦", + "aliases": [ + "south_africa" + ] + }, + { + "emoji": "🇬🇸", + "aliases": [ + "south_georgia_south_sandwich_islands" + ] + }, + { + "emoji": "🇸🇸", + "aliases": [ + "south_sudan" + ] + }, + { + "emoji": "👾", + "aliases": [ + "space_invader" + ] + }, + { + "emoji": "♠️", + "aliases": [ + "spades" + ] + }, + { + "emoji": "🍝", + "aliases": [ + "spaghetti" + ] + }, + { + "emoji": "❇️", + "aliases": [ + "sparkle" + ] + }, + { + "emoji": "🎇", + "aliases": [ + "sparkler" + ] + }, + { + "emoji": "✨", + "aliases": [ + "sparkles" + ] + }, + { + "emoji": "💖", + "aliases": [ + "sparkling_heart" + ] + }, + { + "emoji": "🙊", + "aliases": [ + "speak_no_evil" + ] + }, + { + "emoji": "🔈", + "aliases": [ + "speaker" + ] + }, + { + "emoji": "🗣️", + "aliases": [ + "speaking_head" + ] + }, + { + "emoji": "💬", + "aliases": [ + "speech_balloon" + ] + }, + { + "emoji": "🚤", + "aliases": [ + "speedboat" + ] + }, + { + "emoji": "🕷️", + "aliases": [ + "spider" + ] + }, + { + "emoji": "🕸️", + "aliases": [ + "spider_web" + ] + }, + { + "emoji": "🗓️", + "aliases": [ + "spiral_calendar" + ] + }, + { + "emoji": "🗒️", + "aliases": [ + "spiral_notepad" + ] + }, + { + "emoji": "🫟", + "aliases": [ + "splatter" + ] + }, + { + "emoji": "🧽", + "aliases": [ + "sponge" + ] + }, + { + "emoji": "🥄", + "aliases": [ + "spoon" + ] + }, + { + "emoji": "🦑", + "aliases": [ + "squid" + ] + }, + { + "emoji": "🇱🇰", + "aliases": [ + "sri_lanka" + ] + }, + { + "emoji": "🇧🇱", + "aliases": [ + "st_barthelemy" + ] + }, + { + "emoji": "🇸🇭", + "aliases": [ + "st_helena" + ] + }, + { + "emoji": "🇰🇳", + "aliases": [ + "st_kitts_nevis" + ] + }, + { + "emoji": "🇱🇨", + "aliases": [ + "st_lucia" + ] + }, + { + "emoji": "🇲🇫", + "aliases": [ + "st_martin" + ] + }, + { + "emoji": "🇵🇲", + "aliases": [ + "st_pierre_miquelon" + ] + }, + { + "emoji": "🇻🇨", + "aliases": [ + "st_vincent_grenadines" + ] + }, + { + "emoji": "🏟️", + "aliases": [ + "stadium" + ] + }, + { + "emoji": "🧍‍♂️", + "aliases": [ + "standing_man" + ] + }, + { + "emoji": "🧍", + "aliases": [ + "standing_person" + ] + }, + { + "emoji": "🧍‍♀️", + "aliases": [ + "standing_woman" + ] + }, + { + "emoji": "⭐", + "aliases": [ + "star" + ] + }, + { + "emoji": "🌟", + "aliases": [ + "star2" + ] + }, + { + "emoji": "☪️", + "aliases": [ + "star_and_crescent" + ] + }, + { + "emoji": "✡️", + "aliases": [ + "star_of_david" + ] + }, + { + "emoji": "🤩", + "aliases": [ + "star_struck" + ] + }, + { + "emoji": "🌠", + "aliases": [ + "stars" + ] + }, + { + "emoji": "🚉", + "aliases": [ + "station" + ] + }, + { + "emoji": "🗽", + "aliases": [ + "statue_of_liberty" + ] + }, + { + "emoji": "🚂", + "aliases": [ + "steam_locomotive" + ] + }, + { + "emoji": "🩺", + "aliases": [ + "stethoscope" + ] + }, + { + "emoji": "🍲", + "aliases": [ + "stew" + ] + }, + { + "emoji": "⏹️", + "aliases": [ + "stop_button" + ] + }, + { + "emoji": "🛑", + "aliases": [ + "stop_sign" + ] + }, + { + "emoji": "⏱️", + "aliases": [ + "stopwatch" + ] + }, + { + "emoji": "📏", + "aliases": [ + "straight_ruler" + ] + }, + { + "emoji": "🍓", + "aliases": [ + "strawberry" + ] + }, + { + "emoji": "😛", + "aliases": [ + "stuck_out_tongue" + ] + }, + { + "emoji": "😝", + "aliases": [ + "stuck_out_tongue_closed_eyes" + ] + }, + { + "emoji": "😜", + "aliases": [ + "stuck_out_tongue_winking_eye" + ] + }, + { + "emoji": "🧑‍🎓", + "aliases": [ + "student" + ] + }, + { + "emoji": "🎙️", + "aliases": [ + "studio_microphone" + ] + }, + { + "emoji": "🥙", + "aliases": [ + "stuffed_flatbread" + ] + }, + { + "emoji": "🇸🇩", + "aliases": [ + "sudan" + ] + }, + { + "emoji": "🌥️", + "aliases": [ + "sun_behind_large_cloud" + ] + }, + { + "emoji": "🌦️", + "aliases": [ + "sun_behind_rain_cloud" + ] + }, + { + "emoji": "🌤️", + "aliases": [ + "sun_behind_small_cloud" + ] + }, + { + "emoji": "🌞", + "aliases": [ + "sun_with_face" + ] + }, + { + "emoji": "🌻", + "aliases": [ + "sunflower" + ] + }, + { + "emoji": "😎", + "aliases": [ + "sunglasses" + ] + }, + { + "emoji": "☀️", + "aliases": [ + "sunny" + ] + }, + { + "emoji": "🌅", + "aliases": [ + "sunrise" + ] + }, + { + "emoji": "🌄", + "aliases": [ + "sunrise_over_mountains" + ] + }, + { + "emoji": "🦸", + "aliases": [ + "superhero" + ] + }, + { + "emoji": "🦸‍♂️", + "aliases": [ + "superhero_man" + ] + }, + { + "emoji": "🦸‍♀️", + "aliases": [ + "superhero_woman" + ] + }, + { + "emoji": "🦹", + "aliases": [ + "supervillain" + ] + }, + { + "emoji": "🦹‍♂️", + "aliases": [ + "supervillain_man" + ] + }, + { + "emoji": "🦹‍♀️", + "aliases": [ + "supervillain_woman" + ] + }, + { + "emoji": "🏄", + "aliases": [ + "surfer" + ] + }, + { + "emoji": "🏄‍♂️", + "aliases": [ + "surfing_man" + ] + }, + { + "emoji": "🏄‍♀️", + "aliases": [ + "surfing_woman" + ] + }, + { + "emoji": "🇸🇷", + "aliases": [ + "suriname" + ] + }, + { + "emoji": "🍣", + "aliases": [ + "sushi" + ] + }, + { + "emoji": "🚟", + "aliases": [ + "suspension_railway" + ] + }, + { + "emoji": "🇸🇯", + "aliases": [ + "svalbard_jan_mayen" + ] + }, + { + "emoji": "🦢", + "aliases": [ + "swan" + ] + }, + { + "emoji": "🇸🇿", + "aliases": [ + "swaziland" + ] + }, + { + "emoji": "😓", + "aliases": [ + "sweat" + ] + }, + { + "emoji": "💦", + "aliases": [ + "sweat_drops" + ] + }, + { + "emoji": "😅", + "aliases": [ + "sweat_smile" + ] + }, + { + "emoji": "🇸🇪", + "aliases": [ + "sweden" + ] + }, + { + "emoji": "🍠", + "aliases": [ + "sweet_potato" + ] + }, + { + "emoji": "🩲", + "aliases": [ + "swim_brief" + ] + }, + { + "emoji": "🏊", + "aliases": [ + "swimmer" + ] + }, + { + "emoji": "🏊‍♂️", + "aliases": [ + "swimming_man" + ] + }, + { + "emoji": "🏊‍♀️", + "aliases": [ + "swimming_woman" + ] + }, + { + "emoji": "🇨🇭", + "aliases": [ + "switzerland" + ] + }, + { + "emoji": "🔣", + "aliases": [ + "symbols" + ] + }, + { + "emoji": "🕍", + "aliases": [ + "synagogue" + ] + }, + { + "emoji": "🇸🇾", + "aliases": [ + "syria" + ] + }, + { + "emoji": "💉", + "aliases": [ + "syringe" + ] + }, + { + "emoji": "🦖", + "aliases": [ + "t-rex" + ] + }, + { + "emoji": "🌮", + "aliases": [ + "taco" + ] + }, + { + "emoji": "🎉", + "aliases": [ + "tada", + "hooray" + ] + }, + { + "emoji": "🇹🇼", + "aliases": [ + "taiwan" + ] + }, + { + "emoji": "🇹🇯", + "aliases": [ + "tajikistan" + ] + }, + { + "emoji": "🥡", + "aliases": [ + "takeout_box" + ] + }, + { + "emoji": "🫔", + "aliases": [ + "tamale" + ] + }, + { + "emoji": "🎋", + "aliases": [ + "tanabata_tree" + ] + }, + { + "emoji": "🍊", + "aliases": [ + "tangerine", + "orange", + "mandarin" + ] + }, + { + "emoji": "🇹🇿", + "aliases": [ + "tanzania" + ] + }, + { + "emoji": "♉", + "aliases": [ + "taurus" + ] + }, + { + "emoji": "🚕", + "aliases": [ + "taxi" + ] + }, + { + "emoji": "🍵", + "aliases": [ + "tea" + ] + }, + { + "emoji": "🧑‍🏫", + "aliases": [ + "teacher" + ] + }, + { + "emoji": "🫖", + "aliases": [ + "teapot" + ] + }, + { + "emoji": "🧑‍💻", + "aliases": [ + "technologist" + ] + }, + { + "emoji": "🧸", + "aliases": [ + "teddy_bear" + ] + }, + { + "emoji": "📞", + "aliases": [ + "telephone_receiver" + ] + }, + { + "emoji": "🔭", + "aliases": [ + "telescope" + ] + }, + { + "emoji": "🎾", + "aliases": [ + "tennis" + ] + }, + { + "emoji": "⛺", + "aliases": [ + "tent" + ] + }, + { + "emoji": "🧪", + "aliases": [ + "test_tube" + ] + }, + { + "emoji": "🇹🇭", + "aliases": [ + "thailand" + ] + }, + { + "emoji": "🌡️", + "aliases": [ + "thermometer" + ] + }, + { + "emoji": "🤔", + "aliases": [ + "thinking" + ] + }, + { + "emoji": "🩴", + "aliases": [ + "thong_sandal" + ] + }, + { + "emoji": "💭", + "aliases": [ + "thought_balloon" + ] + }, + { + "emoji": "🧵", + "aliases": [ + "thread" + ] + }, + { + "emoji": "3️⃣", + "aliases": [ + "three" + ] + }, + { + "emoji": "🎫", + "aliases": [ + "ticket" + ] + }, + { + "emoji": "🎟️", + "aliases": [ + "tickets" + ] + }, + { + "emoji": "🐯", + "aliases": [ + "tiger" + ] + }, + { + "emoji": "🐅", + "aliases": [ + "tiger2" + ] + }, + { + "emoji": "⏲️", + "aliases": [ + "timer_clock" + ] + }, + { + "emoji": "🇹🇱", + "aliases": [ + "timor_leste" + ] + }, + { + "emoji": "💁‍♂️", + "aliases": [ + "tipping_hand_man", + "sassy_man" + ] + }, + { + "emoji": "💁", + "aliases": [ + "tipping_hand_person", + "information_desk_person" + ] + }, + { + "emoji": "💁‍♀️", + "aliases": [ + "tipping_hand_woman", + "sassy_woman" + ] + }, + { + "emoji": "😫", + "aliases": [ + "tired_face" + ] + }, + { + "emoji": "™️", + "aliases": [ + "tm" + ] + }, + { + "emoji": "🇹🇬", + "aliases": [ + "togo" + ] + }, + { + "emoji": "🚽", + "aliases": [ + "toilet" + ] + }, + { + "emoji": "🇹🇰", + "aliases": [ + "tokelau" + ] + }, + { + "emoji": "🗼", + "aliases": [ + "tokyo_tower" + ] + }, + { + "emoji": "🍅", + "aliases": [ + "tomato" + ] + }, + { + "emoji": "🇹🇴", + "aliases": [ + "tonga" + ] + }, + { + "emoji": "👅", + "aliases": [ + "tongue" + ] + }, + { + "emoji": "🧰", + "aliases": [ + "toolbox" + ] + }, + { + "emoji": "🦷", + "aliases": [ + "tooth" + ] + }, + { + "emoji": "🪥", + "aliases": [ + "toothbrush" + ] + }, + { + "emoji": "🔝", + "aliases": [ + "top" + ] + }, + { + "emoji": "🎩", + "aliases": [ + "tophat" + ] + }, + { + "emoji": "🌪️", + "aliases": [ + "tornado" + ] + }, + { + "emoji": "🇹🇷", + "aliases": [ + "tr" + ] + }, + { + "emoji": "🖲️", + "aliases": [ + "trackball" + ] + }, + { + "emoji": "🚜", + "aliases": [ + "tractor" + ] + }, + { + "emoji": "🚥", + "aliases": [ + "traffic_light" + ] + }, + { + "emoji": "🚋", + "aliases": [ + "train" + ] + }, + { + "emoji": "🚆", + "aliases": [ + "train2" + ] + }, + { + "emoji": "🚊", + "aliases": [ + "tram" + ] + }, + { + "emoji": "🏳️‍⚧️", + "aliases": [ + "transgender_flag" + ] + }, + { + "emoji": "⚧️", + "aliases": [ + "transgender_symbol" + ] + }, + { + "emoji": "🚩", + "aliases": [ + "triangular_flag_on_post" + ] + }, + { + "emoji": "📐", + "aliases": [ + "triangular_ruler" + ] + }, + { + "emoji": "🔱", + "aliases": [ + "trident" + ] + }, + { + "emoji": "🇹🇹", + "aliases": [ + "trinidad_tobago" + ] + }, + { + "emoji": "🇹🇦", + "aliases": [ + "tristan_da_cunha" + ] + }, + { + "emoji": "😤", + "aliases": [ + "triumph" + ] + }, + { + "emoji": "🧌", + "aliases": [ + "troll" + ] + }, + { + "emoji": "🚎", + "aliases": [ + "trolleybus" + ] + }, + { + "emoji": "🏆", + "aliases": [ + "trophy" + ] + }, + { + "emoji": "🍹", + "aliases": [ + "tropical_drink" + ] + }, + { + "emoji": "🐠", + "aliases": [ + "tropical_fish" + ] + }, + { + "emoji": "🚚", + "aliases": [ + "truck" + ] + }, + { + "emoji": "🎺", + "aliases": [ + "trumpet" + ] + }, + { + "emoji": "🌷", + "aliases": [ + "tulip" + ] + }, + { + "emoji": "🥃", + "aliases": [ + "tumbler_glass" + ] + }, + { + "emoji": "🇹🇳", + "aliases": [ + "tunisia" + ] + }, + { + "emoji": "🦃", + "aliases": [ + "turkey" + ] + }, + { + "emoji": "🇹🇲", + "aliases": [ + "turkmenistan" + ] + }, + { + "emoji": "🇹🇨", + "aliases": [ + "turks_caicos_islands" + ] + }, + { + "emoji": "🐢", + "aliases": [ + "turtle" + ] + }, + { + "emoji": "🇹🇻", + "aliases": [ + "tuvalu" + ] + }, + { + "emoji": "📺", + "aliases": [ + "tv" + ] + }, + { + "emoji": "🔀", + "aliases": [ + "twisted_rightwards_arrows" + ] + }, + { + "emoji": "2️⃣", + "aliases": [ + "two" + ] + }, + { + "emoji": "💕", + "aliases": [ + "two_hearts" + ] + }, + { + "emoji": "👬", + "aliases": [ + "two_men_holding_hands" + ] + }, + { + "emoji": "👭", + "aliases": [ + "two_women_holding_hands" + ] + }, + { + "emoji": "🈹", + "aliases": [ + "u5272" + ] + }, + { + "emoji": "🈴", + "aliases": [ + "u5408" + ] + }, + { + "emoji": "🈺", + "aliases": [ + "u55b6" + ] + }, + { + "emoji": "🈯", + "aliases": [ + "u6307" + ] + }, + { + "emoji": "🈷️", + "aliases": [ + "u6708" + ] + }, + { + "emoji": "🈶", + "aliases": [ + "u6709" + ] + }, + { + "emoji": "🈵", + "aliases": [ + "u6e80" + ] + }, + { + "emoji": "🈚", + "aliases": [ + "u7121" + ] + }, + { + "emoji": "🈸", + "aliases": [ + "u7533" + ] + }, + { + "emoji": "🈲", + "aliases": [ + "u7981" + ] + }, + { + "emoji": "🈳", + "aliases": [ + "u7a7a" + ] + }, + { + "emoji": "🇺🇬", + "aliases": [ + "uganda" + ] + }, + { + "emoji": "🇺🇦", + "aliases": [ + "ukraine" + ] + }, + { + "emoji": "☔", + "aliases": [ + "umbrella" + ] + }, + { + "emoji": "😒", + "aliases": [ + "unamused" + ] + }, + { + "emoji": "🔞", + "aliases": [ + "underage" + ] + }, + { + "emoji": "🦄", + "aliases": [ + "unicorn" + ] + }, + { + "emoji": "🇦🇪", + "aliases": [ + "united_arab_emirates" + ] + }, + { + "emoji": "🇺🇳", + "aliases": [ + "united_nations" + ] + }, + { + "emoji": "🔓", + "aliases": [ + "unlock" + ] + }, + { + "emoji": "🆙", + "aliases": [ + "up" + ] + }, + { + "emoji": "🙃", + "aliases": [ + "upside_down_face" + ] + }, + { + "emoji": "🇺🇾", + "aliases": [ + "uruguay" + ] + }, + { + "emoji": "🇺🇸", + "aliases": [ + "us" + ] + }, + { + "emoji": "🇺🇲", + "aliases": [ + "us_outlying_islands" + ] + }, + { + "emoji": "🇻🇮", + "aliases": [ + "us_virgin_islands" + ] + }, + { + "emoji": "🇺🇿", + "aliases": [ + "uzbekistan" + ] + }, + { + "emoji": "✌️", + "aliases": [ + "v" + ] + }, + { + "emoji": "🧛", + "aliases": [ + "vampire" + ] + }, + { + "emoji": "🧛‍♂️", + "aliases": [ + "vampire_man" + ] + }, + { + "emoji": "🧛‍♀️", + "aliases": [ + "vampire_woman" + ] + }, + { + "emoji": "🇻🇺", + "aliases": [ + "vanuatu" + ] + }, + { + "emoji": "🇻🇦", + "aliases": [ + "vatican_city" + ] + }, + { + "emoji": "🇻🇪", + "aliases": [ + "venezuela" + ] + }, + { + "emoji": "🚦", + "aliases": [ + "vertical_traffic_light" + ] + }, + { + "emoji": "📼", + "aliases": [ + "vhs" + ] + }, + { + "emoji": "📳", + "aliases": [ + "vibration_mode" + ] + }, + { + "emoji": "📹", + "aliases": [ + "video_camera" + ] + }, + { + "emoji": "🎮", + "aliases": [ + "video_game" + ] + }, + { + "emoji": "🇻🇳", + "aliases": [ + "vietnam" + ] + }, + { + "emoji": "🎻", + "aliases": [ + "violin" + ] + }, + { + "emoji": "♍", + "aliases": [ + "virgo" + ] + }, + { + "emoji": "🌋", + "aliases": [ + "volcano" + ] + }, + { + "emoji": "🏐", + "aliases": [ + "volleyball" + ] + }, + { + "emoji": "🤮", + "aliases": [ + "vomiting_face" + ] + }, + { + "emoji": "🆚", + "aliases": [ + "vs" + ] + }, + { + "emoji": "🖖", + "aliases": [ + "vulcan_salute" + ] + }, + { + "emoji": "🧇", + "aliases": [ + "waffle" + ] + }, + { + "emoji": "🏴󠁧󠁢󠁷󠁬󠁳󠁿", + "aliases": [ + "wales" + ] + }, + { + "emoji": "🚶", + "aliases": [ + "walking" + ] + }, + { + "emoji": "🚶‍♂️", + "aliases": [ + "walking_man" + ] + }, + { + "emoji": "🚶‍♀️", + "aliases": [ + "walking_woman" + ] + }, + { + "emoji": "🇼🇫", + "aliases": [ + "wallis_futuna" + ] + }, + { + "emoji": "🌘", + "aliases": [ + "waning_crescent_moon" + ] + }, + { + "emoji": "🌖", + "aliases": [ + "waning_gibbous_moon" + ] + }, + { + "emoji": "⚠️", + "aliases": [ + "warning" + ] + }, + { + "emoji": "🗑️", + "aliases": [ + "wastebasket" + ] + }, + { + "emoji": "⌚", + "aliases": [ + "watch" + ] + }, + { + "emoji": "🐃", + "aliases": [ + "water_buffalo" + ] + }, + { + "emoji": "🤽", + "aliases": [ + "water_polo" + ] + }, + { + "emoji": "🍉", + "aliases": [ + "watermelon" + ] + }, + { + "emoji": "👋", + "aliases": [ + "wave" + ] + }, + { + "emoji": "〰️", + "aliases": [ + "wavy_dash" + ] + }, + { + "emoji": "🌒", + "aliases": [ + "waxing_crescent_moon" + ] + }, + { + "emoji": "🚾", + "aliases": [ + "wc" + ] + }, + { + "emoji": "😩", + "aliases": [ + "weary" + ] + }, + { + "emoji": "💒", + "aliases": [ + "wedding" + ] + }, + { + "emoji": "🏋️", + "aliases": [ + "weight_lifting" + ] + }, + { + "emoji": "🏋️‍♂️", + "aliases": [ + "weight_lifting_man" + ] + }, + { + "emoji": "🏋️‍♀️", + "aliases": [ + "weight_lifting_woman" + ] + }, + { + "emoji": "🇪🇭", + "aliases": [ + "western_sahara" + ] + }, + { + "emoji": "🐳", + "aliases": [ + "whale" + ] + }, + { + "emoji": "🐋", + "aliases": [ + "whale2" + ] + }, + { + "emoji": "🛞", + "aliases": [ + "wheel" + ] + }, + { + "emoji": "☸️", + "aliases": [ + "wheel_of_dharma" + ] + }, + { + "emoji": "♿", + "aliases": [ + "wheelchair" + ] + }, + { + "emoji": "✅", + "aliases": [ + "white_check_mark" + ] + }, + { + "emoji": "⚪", + "aliases": [ + "white_circle" + ] + }, + { + "emoji": "🏳️", + "aliases": [ + "white_flag" + ] + }, + { + "emoji": "💮", + "aliases": [ + "white_flower" + ] + }, + { + "emoji": "👨‍🦳", + "aliases": [ + "white_haired_man" + ] + }, + { + "emoji": "👩‍🦳", + "aliases": [ + "white_haired_woman" + ] + }, + { + "emoji": "🤍", + "aliases": [ + "white_heart" + ] + }, + { + "emoji": "⬜", + "aliases": [ + "white_large_square" + ] + }, + { + "emoji": "◽", + "aliases": [ + "white_medium_small_square" + ] + }, + { + "emoji": "◻️", + "aliases": [ + "white_medium_square" + ] + }, + { + "emoji": "▫️", + "aliases": [ + "white_small_square" + ] + }, + { + "emoji": "🔳", + "aliases": [ + "white_square_button" + ] + }, + { + "emoji": "🥀", + "aliases": [ + "wilted_flower" + ] + }, + { + "emoji": "🎐", + "aliases": [ + "wind_chime" + ] + }, + { + "emoji": "🌬️", + "aliases": [ + "wind_face" + ] + }, + { + "emoji": "🪟", + "aliases": [ + "window" + ] + }, + { + "emoji": "🍷", + "aliases": [ + "wine_glass" + ] + }, + { + "emoji": "🪽", + "aliases": [ + "wing" + ] + }, + { + "emoji": "😉", + "aliases": [ + "wink" + ] + }, + { + "emoji": "🛜", + "aliases": [ + "wireless" + ] + }, + { + "emoji": "🐺", + "aliases": [ + "wolf" + ] + }, + { + "emoji": "👩", + "aliases": [ + "woman" + ] + }, + { + "emoji": "👩‍🎨", + "aliases": [ + "woman_artist" + ] + }, + { + "emoji": "👩‍🚀", + "aliases": [ + "woman_astronaut" + ] + }, + { + "emoji": "🧔‍♀️", + "aliases": [ + "woman_beard" + ] + }, + { + "emoji": "🤸‍♀️", + "aliases": [ + "woman_cartwheeling" + ] + }, + { + "emoji": "👩‍🍳", + "aliases": [ + "woman_cook" + ] + }, + { + "emoji": "💃", + "aliases": [ + "woman_dancing", + "dancer" + ] + }, + { + "emoji": "🤦‍♀️", + "aliases": [ + "woman_facepalming" + ] + }, + { + "emoji": "👩‍🏭", + "aliases": [ + "woman_factory_worker" + ] + }, + { + "emoji": "👩‍🌾", + "aliases": [ + "woman_farmer" + ] + }, + { + "emoji": "👩‍🍼", + "aliases": [ + "woman_feeding_baby" + ] + }, + { + "emoji": "👩‍🚒", + "aliases": [ + "woman_firefighter" + ] + }, + { + "emoji": "👩‍⚕️", + "aliases": [ + "woman_health_worker" + ] + }, + { + "emoji": "👩‍🦽", + "aliases": [ + "woman_in_manual_wheelchair" + ] + }, + { + "emoji": "👩‍🦽‍➡️", + "aliases": [ + "woman_in_manual_wheelchair_facing_right" + ] + }, + { + "emoji": "👩‍🦼", + "aliases": [ + "woman_in_motorized_wheelchair" + ] + }, + { + "emoji": "👩‍🦼‍➡️", + "aliases": [ + "woman_in_motorized_wheelchair_facing_right" + ] + }, + { + "emoji": "🤵‍♀️", + "aliases": [ + "woman_in_tuxedo" + ] + }, + { + "emoji": "👩‍⚖️", + "aliases": [ + "woman_judge" + ] + }, + { + "emoji": "🤹‍♀️", + "aliases": [ + "woman_juggling" + ] + }, + { + "emoji": "🧎‍♀️‍➡️", + "aliases": [ + "woman_kneeling_facing_right" + ] + }, + { + "emoji": "👩‍🔧", + "aliases": [ + "woman_mechanic" + ] + }, + { + "emoji": "👩‍💼", + "aliases": [ + "woman_office_worker" + ] + }, + { + "emoji": "👩‍✈️", + "aliases": [ + "woman_pilot" + ] + }, + { + "emoji": "🤾‍♀️", + "aliases": [ + "woman_playing_handball" + ] + }, + { + "emoji": "🤽‍♀️", + "aliases": [ + "woman_playing_water_polo" + ] + }, + { + "emoji": "🏃‍♀️‍➡️", + "aliases": [ + "woman_running_facing_right" + ] + }, + { + "emoji": "👩‍🔬", + "aliases": [ + "woman_scientist" + ] + }, + { + "emoji": "🤷‍♀️", + "aliases": [ + "woman_shrugging" + ] + }, + { + "emoji": "👩‍🎤", + "aliases": [ + "woman_singer" + ] + }, + { + "emoji": "👩‍🎓", + "aliases": [ + "woman_student" + ] + }, + { + "emoji": "👩‍🏫", + "aliases": [ + "woman_teacher" + ] + }, + { + "emoji": "👩‍💻", + "aliases": [ + "woman_technologist" + ] + }, + { + "emoji": "🚶‍♀️‍➡️", + "aliases": [ + "woman_walking_facing_right" + ] + }, + { + "emoji": "🧕", + "aliases": [ + "woman_with_headscarf" + ] + }, + { + "emoji": "👩‍🦯", + "aliases": [ + "woman_with_probing_cane" + ] + }, + { + "emoji": "👳‍♀️", + "aliases": [ + "woman_with_turban" + ] + }, + { + "emoji": "👰‍♀️", + "aliases": [ + "woman_with_veil", + "bride_with_veil" + ] + }, + { + "emoji": "👩‍🦯‍➡️", + "aliases": [ + "woman_with_white_cane_facing_right" + ] + }, + { + "emoji": "👚", + "aliases": [ + "womans_clothes" + ] + }, + { + "emoji": "👒", + "aliases": [ + "womans_hat" + ] + }, + { + "emoji": "🤼‍♀️", + "aliases": [ + "women_wrestling" + ] + }, + { + "emoji": "🚺", + "aliases": [ + "womens" + ] + }, + { + "emoji": "🪵", + "aliases": [ + "wood" + ] + }, + { + "emoji": "🥴", + "aliases": [ + "woozy_face" + ] + }, + { + "emoji": "🗺️", + "aliases": [ + "world_map" + ] + }, + { + "emoji": "🪱", + "aliases": [ + "worm" + ] + }, + { + "emoji": "😟", + "aliases": [ + "worried" + ] + }, + { + "emoji": "🔧", + "aliases": [ + "wrench" + ] + }, + { + "emoji": "🤼", + "aliases": [ + "wrestling" + ] + }, + { + "emoji": "✍️", + "aliases": [ + "writing_hand" + ] + }, + { + "emoji": "❌", + "aliases": [ + "x" + ] + }, + { + "emoji": "🩻", + "aliases": [ + "x_ray" + ] + }, + { + "emoji": "🧶", + "aliases": [ + "yarn" + ] + }, + { + "emoji": "🥱", + "aliases": [ + "yawning_face" + ] + }, + { + "emoji": "🟡", + "aliases": [ + "yellow_circle" + ] + }, + { + "emoji": "💛", + "aliases": [ + "yellow_heart" + ] + }, + { + "emoji": "🟨", + "aliases": [ + "yellow_square" + ] + }, + { + "emoji": "🇾🇪", + "aliases": [ + "yemen" + ] + }, + { + "emoji": "💴", + "aliases": [ + "yen" + ] + }, + { + "emoji": "☯️", + "aliases": [ + "yin_yang" + ] + }, + { + "emoji": "🪀", + "aliases": [ + "yo_yo" + ] + }, + { + "emoji": "😋", + "aliases": [ + "yum" + ] + }, + { + "emoji": "🇿🇲", + "aliases": [ + "zambia" + ] + }, + { + "emoji": "🤪", + "aliases": [ + "zany_face" + ] + }, + { + "emoji": "⚡", + "aliases": [ + "zap" + ] + }, + { + "emoji": "🦓", + "aliases": [ + "zebra" + ] + }, + { + "emoji": "0️⃣", + "aliases": [ + "zero" + ] + }, + { + "emoji": "🇿🇼", + "aliases": [ + "zimbabwe" + ] + }, + { + "emoji": "🤐", + "aliases": [ + "zipper_mouth_face" + ] + }, + { + "emoji": "🧟", + "aliases": [ + "zombie" + ] + }, + { + "emoji": "🧟‍♂️", + "aliases": [ + "zombie_man" + ] + }, + { + "emoji": "🧟‍♀️", + "aliases": [ + "zombie_woman" + ] + }, + { + "emoji": "💤", + "aliases": [ + "zzz" + ] + } +] diff --git a/assets/go-licenses.json b/assets/go-licenses.json index 9c19080e24..bf0c174811 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -27,7 +27,7 @@ { "name": "connectrpc.com/connect", "path": "connectrpc.com/connect/LICENSE", - "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright 2021-2024 The Connect Authors\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" + "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright 2021-2025 The Connect Authors\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, { "name": "dario.cat/mergo", @@ -409,16 +409,6 @@ "path": "github.com/dimiro1/reply/LICENSE", "licenseText": "MIT License\n\nCopyright (c) Discourse\nCopyright (c) Claudemiro\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, - { - "name": "github.com/djherbis/buffer", - "path": "github.com/djherbis/buffer/LICENSE.txt", - "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2015 Dustin H\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" - }, - { - "name": "github.com/djherbis/nio/v3", - "path": "github.com/djherbis/nio/v3/LICENSE.txt", - "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2015 Dustin H\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" - }, { "name": "github.com/dlclark/regexp2", "path": "github.com/dlclark/regexp2/LICENSE", @@ -594,11 +584,6 @@ "path": "github.com/gogs/go-gogs-client/LICENSE", "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2014 Go Git Service\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n" }, - { - "name": "github.com/golang-jwt/jwt/v4", - "path": "github.com/golang-jwt/jwt/v4/LICENSE", - "licenseText": "Copyright (c) 2012 Dave Grijalva\nCopyright (c) 2021 golang-jwt maintainers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n" - }, { "name": "github.com/golang-jwt/jwt/v5", "path": "github.com/golang-jwt/jwt/v5/LICENSE", @@ -619,11 +604,6 @@ "path": "github.com/golang/groupcache/lru/LICENSE", "licenseText": "Apache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n\"License\" shall mean the terms and conditions for use, reproduction, and\ndistribution as defined by Sections 1 through 9 of this document.\n\n\"Licensor\" shall mean the copyright owner or entity authorized by the copyright\nowner that is granting the License.\n\n\"Legal Entity\" shall mean the union of the acting entity and all other entities\nthat control, are controlled by, or are under common control with that entity.\nFor the purposes of this definition, \"control\" means (i) the power, direct or\nindirect, to cause the direction or management of such entity, whether by\ncontract or otherwise, or (ii) ownership of fifty percent (50%) or more of the\noutstanding shares, or (iii) beneficial ownership of such entity.\n\n\"You\" (or \"Your\") shall mean an individual or Legal Entity exercising\npermissions granted by this License.\n\n\"Source\" form shall mean the preferred form for making modifications, including\nbut not limited to software source code, documentation source, and configuration\nfiles.\n\n\"Object\" form shall mean any form resulting from mechanical transformation or\ntranslation of a Source form, including but not limited to compiled object code,\ngenerated documentation, and conversions to other media types.\n\n\"Work\" shall mean the work of authorship, whether in Source or Object form, made\navailable under the License, as indicated by a copyright notice that is included\nin or attached to the work (an example is provided in the Appendix below).\n\n\"Derivative Works\" shall mean any work, whether in Source or Object form, that\nis based on (or derived from) the Work and for which the editorial revisions,\nannotations, elaborations, or other modifications represent, as a whole, an\noriginal work of authorship. For the purposes of this License, Derivative Works\nshall not include works that remain separable from, or merely link (or bind by\nname) to the interfaces of, the Work and Derivative Works thereof.\n\n\"Contribution\" shall mean any work of authorship, including the original version\nof the Work and any modifications or additions to that Work or Derivative Works\nthereof, that is intentionally submitted to Licensor for inclusion in the Work\nby the copyright owner or by an individual or Legal Entity authorized to submit\non behalf of the copyright owner. For the purposes of this definition,\n\"submitted\" means any form of electronic, verbal, or written communication sent\nto the Licensor or its representatives, including but not limited to\ncommunication on electronic mailing lists, source code control systems, and\nissue tracking systems that are managed by, or on behalf of, the Licensor for\nthe purpose of discussing and improving the Work, but excluding communication\nthat is conspicuously marked or otherwise designated in writing by the copyright\nowner as \"Not a Contribution.\"\n\n\"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf\nof whom a Contribution has been received by Licensor and subsequently\nincorporated within the Work.\n\n2. Grant of Copyright License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable copyright license to reproduce, prepare Derivative Works of,\npublicly display, publicly perform, sublicense, and distribute the Work and such\nDerivative Works in Source or Object form.\n\n3. Grant of Patent License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable (except as stated in this section) patent license to make, have\nmade, use, offer to sell, sell, import, and otherwise transfer the Work, where\nsuch license applies only to those patent claims licensable by such Contributor\nthat are necessarily infringed by their Contribution(s) alone or by combination\nof their Contribution(s) with the Work to which such Contribution(s) was\nsubmitted. If You institute patent litigation against any entity (including a\ncross-claim or counterclaim in a lawsuit) alleging that the Work or a\nContribution incorporated within the Work constitutes direct or contributory\npatent infringement, then any patent licenses granted to You under this License\nfor that Work shall terminate as of the date such litigation is filed.\n\n4. Redistribution.\n\nYou may reproduce and distribute copies of the Work or Derivative Works thereof\nin any medium, with or without modifications, and in Source or Object form,\nprovided that You meet the following conditions:\n\nYou must give any other recipients of the Work or Derivative Works a copy of\nthis License; and\nYou must cause any modified files to carry prominent notices stating that You\nchanged the files; and\nYou must retain, in the Source form of any Derivative Works that You distribute,\nall copyright, patent, trademark, and attribution notices from the Source form\nof the Work, excluding those notices that do not pertain to any part of the\nDerivative Works; and\nIf the Work includes a \"NOTICE\" text file as part of its distribution, then any\nDerivative Works that You distribute must include a readable copy of the\nattribution notices contained within such NOTICE file, excluding those notices\nthat do not pertain to any part of the Derivative Works, in at least one of the\nfollowing places: within a NOTICE text file distributed as part of the\nDerivative Works; within the Source form or documentation, if provided along\nwith the Derivative Works; or, within a display generated by the Derivative\nWorks, if and wherever such third-party notices normally appear. The contents of\nthe NOTICE file are for informational purposes only and do not modify the\nLicense. You may add Your own attribution notices within Derivative Works that\nYou distribute, alongside or as an addendum to the NOTICE text from the Work,\nprovided that such additional attribution notices cannot be construed as\nmodifying the License.\nYou may add Your own copyright statement to Your modifications and may provide\nadditional or different license terms and conditions for use, reproduction, or\ndistribution of Your modifications, or for any such Derivative Works as a whole,\nprovided Your use, reproduction, and distribution of the Work otherwise complies\nwith the conditions stated in this License.\n\n5. Submission of Contributions.\n\nUnless You explicitly state otherwise, any Contribution intentionally submitted\nfor inclusion in the Work by You to the Licensor shall be under the terms and\nconditions of this License, without any additional terms or conditions.\nNotwithstanding the above, nothing herein shall supersede or modify the terms of\nany separate license agreement you may have executed with Licensor regarding\nsuch Contributions.\n\n6. Trademarks.\n\nThis License does not grant permission to use the trade names, trademarks,\nservice marks, or product names of the Licensor, except as required for\nreasonable and customary use in describing the origin of the Work and\nreproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty.\n\nUnless required by applicable law or agreed to in writing, Licensor provides the\nWork (and each Contributor provides its Contributions) on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,\nincluding, without limitation, any warranties or conditions of TITLE,\nNON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are\nsolely responsible for determining the appropriateness of using or\nredistributing the Work and assume any risks associated with Your exercise of\npermissions under this License.\n\n8. Limitation of Liability.\n\nIn no event and under no legal theory, whether in tort (including negligence),\ncontract, or otherwise, unless required by applicable law (such as deliberate\nand grossly negligent acts) or agreed to in writing, shall any Contributor be\nliable to You for damages, including any direct, indirect, special, incidental,\nor consequential damages of any character arising as a result of this License or\nout of the use or inability to use the Work (including but not limited to\ndamages for loss of goodwill, work stoppage, computer failure or malfunction, or\nany and all other commercial damages or losses), even if such Contributor has\nbeen advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability.\n\nWhile redistributing the Work or Derivative Works thereof, You may choose to\noffer, and charge a fee for, acceptance of support, warranty, indemnity, or\nother liability obligations and/or rights consistent with this License. However,\nin accepting such obligations, You may act only on Your own behalf and on Your\nsole responsibility, not on behalf of any other Contributor, and only if You\nagree to indemnify, defend, and hold each Contributor harmless for any liability\nincurred by, or claims asserted against, such Contributor by reason of your\naccepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work\n\nTo apply the Apache License to your work, attach the following boilerplate\nnotice, with the fields enclosed by brackets \"[]\" replaced with your own\nidentifying information. (Don't include the brackets!) The text should be\nenclosed in the appropriate comment syntax for the file format. We also\nrecommend that a file or class name and description of purpose be included on\nthe same \"printed page\" as the copyright notice for easier identification within\nthird-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, - { - "name": "github.com/golang/protobuf/proto", - "path": "github.com/golang/protobuf/proto/LICENSE", - "licenseText": "Copyright 2010 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n" - }, { "name": "github.com/golang/snappy", "path": "github.com/golang/snappy/LICENSE", @@ -717,7 +697,7 @@ { "name": "github.com/hashicorp/go-version", "path": "github.com/hashicorp/go-version/LICENSE", - "licenseText": "Mozilla Public License, version 2.0\n\n1. Definitions\n\n1.1. “Contributor”\n\n means each individual or legal entity that creates, contributes to the\n creation of, or owns Covered Software.\n\n1.2. “Contributor Version”\n\n means the combination of the Contributions of others (if any) used by a\n Contributor and that particular Contributor’s Contribution.\n\n1.3. “Contribution”\n\n means Covered Software of a particular Contributor.\n\n1.4. “Covered Software”\n\n means Source Code Form to which the initial Contributor has attached the\n notice in Exhibit A, the Executable Form of such Source Code Form, and\n Modifications of such Source Code Form, in each case including portions\n thereof.\n\n1.5. “Incompatible With Secondary Licenses”\n means\n\n a. that the initial Contributor has attached the notice described in\n Exhibit B to the Covered Software; or\n\n b. that the Covered Software was made available under the terms of version\n 1.1 or earlier of the License, but not also under the terms of a\n Secondary License.\n\n1.6. “Executable Form”\n\n means any form of the work other than Source Code Form.\n\n1.7. “Larger Work”\n\n means a work that combines Covered Software with other material, in a separate\n file or files, that is not Covered Software.\n\n1.8. “License”\n\n means this document.\n\n1.9. “Licensable”\n\n means having the right to grant, to the maximum extent possible, whether at the\n time of the initial grant or subsequently, any and all of the rights conveyed by\n this License.\n\n1.10. “Modifications”\n\n means any of the following:\n\n a. any file in Source Code Form that results from an addition to, deletion\n from, or modification of the contents of Covered Software; or\n\n b. any new file in Source Code Form that contains any Covered Software.\n\n1.11. “Patent Claims” of a Contributor\n\n means any patent claim(s), including without limitation, method, process,\n and apparatus claims, in any patent Licensable by such Contributor that\n would be infringed, but for the grant of the License, by the making,\n using, selling, offering for sale, having made, import, or transfer of\n either its Contributions or its Contributor Version.\n\n1.12. “Secondary License”\n\n means either the GNU General Public License, Version 2.0, the GNU Lesser\n General Public License, Version 2.1, the GNU Affero General Public\n License, Version 3.0, or any later versions of those licenses.\n\n1.13. “Source Code Form”\n\n means the form of the work preferred for making modifications.\n\n1.14. “You” (or “Your”)\n\n means an individual or a legal entity exercising rights under this\n License. For legal entities, “You” includes any entity that controls, is\n controlled by, or is under common control with You. For purposes of this\n definition, “control” means (a) the power, direct or indirect, to cause\n the direction or management of such entity, whether by contract or\n otherwise, or (b) ownership of more than fifty percent (50%) of the\n outstanding shares or beneficial ownership of such entity.\n\n\n2. License Grants and Conditions\n\n2.1. Grants\n\n Each Contributor hereby grants You a world-wide, royalty-free,\n non-exclusive license:\n\n a. under intellectual property rights (other than patent or trademark)\n Licensable by such Contributor to use, reproduce, make available,\n modify, display, perform, distribute, and otherwise exploit its\n Contributions, either on an unmodified basis, with Modifications, or as\n part of a Larger Work; and\n\n b. under Patent Claims of such Contributor to make, use, sell, offer for\n sale, have made, import, and otherwise transfer either its Contributions\n or its Contributor Version.\n\n2.2. Effective Date\n\n The licenses granted in Section 2.1 with respect to any Contribution become\n effective for each Contribution on the date the Contributor first distributes\n such Contribution.\n\n2.3. Limitations on Grant Scope\n\n The licenses granted in this Section 2 are the only rights granted under this\n License. No additional rights or licenses will be implied from the distribution\n or licensing of Covered Software under this License. Notwithstanding Section\n 2.1(b) above, no patent license is granted by a Contributor:\n\n a. for any code that a Contributor has removed from Covered Software; or\n\n b. for infringements caused by: (i) Your and any other third party’s\n modifications of Covered Software, or (ii) the combination of its\n Contributions with other software (except as part of its Contributor\n Version); or\n\n c. under Patent Claims infringed by Covered Software in the absence of its\n Contributions.\n\n This License does not grant any rights in the trademarks, service marks, or\n logos of any Contributor (except as may be necessary to comply with the\n notice requirements in Section 3.4).\n\n2.4. Subsequent Licenses\n\n No Contributor makes additional grants as a result of Your choice to\n distribute the Covered Software under a subsequent version of this License\n (see Section 10.2) or under the terms of a Secondary License (if permitted\n under the terms of Section 3.3).\n\n2.5. Representation\n\n Each Contributor represents that the Contributor believes its Contributions\n are its original creation(s) or it has sufficient rights to grant the\n rights to its Contributions conveyed by this License.\n\n2.6. Fair Use\n\n This License is not intended to limit any rights You have under applicable\n copyright doctrines of fair use, fair dealing, or other equivalents.\n\n2.7. Conditions\n\n Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in\n Section 2.1.\n\n\n3. Responsibilities\n\n3.1. Distribution of Source Form\n\n All distribution of Covered Software in Source Code Form, including any\n Modifications that You create or to which You contribute, must be under the\n terms of this License. You must inform recipients that the Source Code Form\n of the Covered Software is governed by the terms of this License, and how\n they can obtain a copy of this License. You may not attempt to alter or\n restrict the recipients’ rights in the Source Code Form.\n\n3.2. Distribution of Executable Form\n\n If You distribute Covered Software in Executable Form then:\n\n a. such Covered Software must also be made available in Source Code Form,\n as described in Section 3.1, and You must inform recipients of the\n Executable Form how they can obtain a copy of such Source Code Form by\n reasonable means in a timely manner, at a charge no more than the cost\n of distribution to the recipient; and\n\n b. You may distribute such Executable Form under the terms of this License,\n or sublicense it under different terms, provided that the license for\n the Executable Form does not attempt to limit or alter the recipients’\n rights in the Source Code Form under this License.\n\n3.3. Distribution of a Larger Work\n\n You may create and distribute a Larger Work under terms of Your choice,\n provided that You also comply with the requirements of this License for the\n Covered Software. If the Larger Work is a combination of Covered Software\n with a work governed by one or more Secondary Licenses, and the Covered\n Software is not Incompatible With Secondary Licenses, this License permits\n You to additionally distribute such Covered Software under the terms of\n such Secondary License(s), so that the recipient of the Larger Work may, at\n their option, further distribute the Covered Software under the terms of\n either this License or such Secondary License(s).\n\n3.4. Notices\n\n You may not remove or alter the substance of any license notices (including\n copyright notices, patent notices, disclaimers of warranty, or limitations\n of liability) contained within the Source Code Form of the Covered\n Software, except that You may alter any license notices to the extent\n required to remedy known factual inaccuracies.\n\n3.5. Application of Additional Terms\n\n You may choose to offer, and to charge a fee for, warranty, support,\n indemnity or liability obligations to one or more recipients of Covered\n Software. However, You may do so only on Your own behalf, and not on behalf\n of any Contributor. You must make it absolutely clear that any such\n warranty, support, indemnity, or liability obligation is offered by You\n alone, and You hereby agree to indemnify every Contributor for any\n liability incurred by such Contributor as a result of warranty, support,\n indemnity or liability terms You offer. You may include additional\n disclaimers of warranty and limitations of liability specific to any\n jurisdiction.\n\n4. Inability to Comply Due to Statute or Regulation\n\n If it is impossible for You to comply with any of the terms of this License\n with respect to some or all of the Covered Software due to statute, judicial\n order, or regulation then You must: (a) comply with the terms of this License\n to the maximum extent possible; and (b) describe the limitations and the code\n they affect. Such description must be placed in a text file included with all\n distributions of the Covered Software under this License. Except to the\n extent prohibited by statute or regulation, such description must be\n sufficiently detailed for a recipient of ordinary skill to be able to\n understand it.\n\n5. Termination\n\n5.1. The rights granted under this License will terminate automatically if You\n fail to comply with any of its terms. However, if You become compliant,\n then the rights granted under this License from a particular Contributor\n are reinstated (a) provisionally, unless and until such Contributor\n explicitly and finally terminates Your grants, and (b) on an ongoing basis,\n if such Contributor fails to notify You of the non-compliance by some\n reasonable means prior to 60 days after You have come back into compliance.\n Moreover, Your grants from a particular Contributor are reinstated on an\n ongoing basis if such Contributor notifies You of the non-compliance by\n some reasonable means, this is the first time You have received notice of\n non-compliance with this License from such Contributor, and You become\n compliant prior to 30 days after Your receipt of the notice.\n\n5.2. If You initiate litigation against any entity by asserting a patent\n infringement claim (excluding declaratory judgment actions, counter-claims,\n and cross-claims) alleging that a Contributor Version directly or\n indirectly infringes any patent, then the rights granted to You by any and\n all Contributors for the Covered Software under Section 2.1 of this License\n shall terminate.\n\n5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user\n license agreements (excluding distributors and resellers) which have been\n validly granted by You or Your distributors under this License prior to\n termination shall survive termination.\n\n6. Disclaimer of Warranty\n\n Covered Software is provided under this License on an “as is” basis, without\n warranty of any kind, either expressed, implied, or statutory, including,\n without limitation, warranties that the Covered Software is free of defects,\n merchantable, fit for a particular purpose or non-infringing. The entire\n risk as to the quality and performance of the Covered Software is with You.\n Should any Covered Software prove defective in any respect, You (not any\n Contributor) assume the cost of any necessary servicing, repair, or\n correction. This disclaimer of warranty constitutes an essential part of this\n License. No use of any Covered Software is authorized under this License\n except under this disclaimer.\n\n7. Limitation of Liability\n\n Under no circumstances and under no legal theory, whether tort (including\n negligence), contract, or otherwise, shall any Contributor, or anyone who\n distributes Covered Software as permitted above, be liable to You for any\n direct, indirect, special, incidental, or consequential damages of any\n character including, without limitation, damages for lost profits, loss of\n goodwill, work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses, even if such party shall have been\n informed of the possibility of such damages. This limitation of liability\n shall not apply to liability for death or personal injury resulting from such\n party’s negligence to the extent applicable law prohibits such limitation.\n Some jurisdictions do not allow the exclusion or limitation of incidental or\n consequential damages, so this exclusion and limitation may not apply to You.\n\n8. Litigation\n\n Any litigation relating to this License may be brought only in the courts of\n a jurisdiction where the defendant maintains its principal place of business\n and such litigation shall be governed by laws of that jurisdiction, without\n reference to its conflict-of-law provisions. Nothing in this Section shall\n prevent a party’s ability to bring cross-claims or counter-claims.\n\n9. Miscellaneous\n\n This License represents the complete agreement concerning the subject matter\n hereof. If any provision of this License is held to be unenforceable, such\n provision shall be reformed only to the extent necessary to make it\n enforceable. Any law or regulation which provides that the language of a\n contract shall be construed against the drafter shall not be used to construe\n this License against a Contributor.\n\n\n10. Versions of the License\n\n10.1. New Versions\n\n Mozilla Foundation is the license steward. Except as provided in Section\n 10.3, no one other than the license steward has the right to modify or\n publish new versions of this License. Each version will be given a\n distinguishing version number.\n\n10.2. Effect of New Versions\n\n You may distribute the Covered Software under the terms of the version of\n the License under which You originally received the Covered Software, or\n under the terms of any subsequent version published by the license\n steward.\n\n10.3. Modified Versions\n\n If you create software not governed by this License, and you want to\n create a new license for such software, you may create and use a modified\n version of this License if you rename the license and remove any\n references to the name of the license steward (except to note that such\n modified license differs from this License).\n\n10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses\n If You choose to distribute Source Code Form that is Incompatible With\n Secondary Licenses under the terms of this version of the License, the\n notice described in Exhibit B of this License must be attached.\n\nExhibit A - Source Code Form License Notice\n\n This Source Code Form is subject to the\n terms of the Mozilla Public License, v.\n 2.0. If a copy of the MPL was not\n distributed with this file, You can\n obtain one at\n http://mozilla.org/MPL/2.0/.\n\nIf it is not possible or desirable to put the notice in a particular file, then\nYou may include the notice in a location (such as a LICENSE file in a relevant\ndirectory) where a recipient would be likely to look for such a notice.\n\nYou may add additional accurate notices of copyright ownership.\n\nExhibit B - “Incompatible With Secondary Licenses” Notice\n\n This Source Code Form is “Incompatible\n With Secondary Licenses”, as defined by\n the Mozilla Public License, v. 2.0.\n\n" + "licenseText": "Copyright IBM Corp. 2014, 2025\n\nMozilla Public License, version 2.0\n\n1. Definitions\n\n1.1. “Contributor”\n\n means each individual or legal entity that creates, contributes to the\n creation of, or owns Covered Software.\n\n1.2. “Contributor Version”\n\n means the combination of the Contributions of others (if any) used by a\n Contributor and that particular Contributor’s Contribution.\n\n1.3. “Contribution”\n\n means Covered Software of a particular Contributor.\n\n1.4. “Covered Software”\n\n means Source Code Form to which the initial Contributor has attached the\n notice in Exhibit A, the Executable Form of such Source Code Form, and\n Modifications of such Source Code Form, in each case including portions\n thereof.\n\n1.5. “Incompatible With Secondary Licenses”\n means\n\n a. that the initial Contributor has attached the notice described in\n Exhibit B to the Covered Software; or\n\n b. that the Covered Software was made available under the terms of version\n 1.1 or earlier of the License, but not also under the terms of a\n Secondary License.\n\n1.6. “Executable Form”\n\n means any form of the work other than Source Code Form.\n\n1.7. “Larger Work”\n\n means a work that combines Covered Software with other material, in a separate\n file or files, that is not Covered Software.\n\n1.8. “License”\n\n means this document.\n\n1.9. “Licensable”\n\n means having the right to grant, to the maximum extent possible, whether at the\n time of the initial grant or subsequently, any and all of the rights conveyed by\n this License.\n\n1.10. “Modifications”\n\n means any of the following:\n\n a. any file in Source Code Form that results from an addition to, deletion\n from, or modification of the contents of Covered Software; or\n\n b. any new file in Source Code Form that contains any Covered Software.\n\n1.11. “Patent Claims” of a Contributor\n\n means any patent claim(s), including without limitation, method, process,\n and apparatus claims, in any patent Licensable by such Contributor that\n would be infringed, but for the grant of the License, by the making,\n using, selling, offering for sale, having made, import, or transfer of\n either its Contributions or its Contributor Version.\n\n1.12. “Secondary License”\n\n means either the GNU General Public License, Version 2.0, the GNU Lesser\n General Public License, Version 2.1, the GNU Affero General Public\n License, Version 3.0, or any later versions of those licenses.\n\n1.13. “Source Code Form”\n\n means the form of the work preferred for making modifications.\n\n1.14. “You” (or “Your”)\n\n means an individual or a legal entity exercising rights under this\n License. For legal entities, “You” includes any entity that controls, is\n controlled by, or is under common control with You. For purposes of this\n definition, “control” means (a) the power, direct or indirect, to cause\n the direction or management of such entity, whether by contract or\n otherwise, or (b) ownership of more than fifty percent (50%) of the\n outstanding shares or beneficial ownership of such entity.\n\n\n2. License Grants and Conditions\n\n2.1. Grants\n\n Each Contributor hereby grants You a world-wide, royalty-free,\n non-exclusive license:\n\n a. under intellectual property rights (other than patent or trademark)\n Licensable by such Contributor to use, reproduce, make available,\n modify, display, perform, distribute, and otherwise exploit its\n Contributions, either on an unmodified basis, with Modifications, or as\n part of a Larger Work; and\n\n b. under Patent Claims of such Contributor to make, use, sell, offer for\n sale, have made, import, and otherwise transfer either its Contributions\n or its Contributor Version.\n\n2.2. Effective Date\n\n The licenses granted in Section 2.1 with respect to any Contribution become\n effective for each Contribution on the date the Contributor first distributes\n such Contribution.\n\n2.3. Limitations on Grant Scope\n\n The licenses granted in this Section 2 are the only rights granted under this\n License. No additional rights or licenses will be implied from the distribution\n or licensing of Covered Software under this License. Notwithstanding Section\n 2.1(b) above, no patent license is granted by a Contributor:\n\n a. for any code that a Contributor has removed from Covered Software; or\n\n b. for infringements caused by: (i) Your and any other third party’s\n modifications of Covered Software, or (ii) the combination of its\n Contributions with other software (except as part of its Contributor\n Version); or\n\n c. under Patent Claims infringed by Covered Software in the absence of its\n Contributions.\n\n This License does not grant any rights in the trademarks, service marks, or\n logos of any Contributor (except as may be necessary to comply with the\n notice requirements in Section 3.4).\n\n2.4. Subsequent Licenses\n\n No Contributor makes additional grants as a result of Your choice to\n distribute the Covered Software under a subsequent version of this License\n (see Section 10.2) or under the terms of a Secondary License (if permitted\n under the terms of Section 3.3).\n\n2.5. Representation\n\n Each Contributor represents that the Contributor believes its Contributions\n are its original creation(s) or it has sufficient rights to grant the\n rights to its Contributions conveyed by this License.\n\n2.6. Fair Use\n\n This License is not intended to limit any rights You have under applicable\n copyright doctrines of fair use, fair dealing, or other equivalents.\n\n2.7. Conditions\n\n Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in\n Section 2.1.\n\n\n3. Responsibilities\n\n3.1. Distribution of Source Form\n\n All distribution of Covered Software in Source Code Form, including any\n Modifications that You create or to which You contribute, must be under the\n terms of this License. You must inform recipients that the Source Code Form\n of the Covered Software is governed by the terms of this License, and how\n they can obtain a copy of this License. You may not attempt to alter or\n restrict the recipients’ rights in the Source Code Form.\n\n3.2. Distribution of Executable Form\n\n If You distribute Covered Software in Executable Form then:\n\n a. such Covered Software must also be made available in Source Code Form,\n as described in Section 3.1, and You must inform recipients of the\n Executable Form how they can obtain a copy of such Source Code Form by\n reasonable means in a timely manner, at a charge no more than the cost\n of distribution to the recipient; and\n\n b. You may distribute such Executable Form under the terms of this License,\n or sublicense it under different terms, provided that the license for\n the Executable Form does not attempt to limit or alter the recipients’\n rights in the Source Code Form under this License.\n\n3.3. Distribution of a Larger Work\n\n You may create and distribute a Larger Work under terms of Your choice,\n provided that You also comply with the requirements of this License for the\n Covered Software. If the Larger Work is a combination of Covered Software\n with a work governed by one or more Secondary Licenses, and the Covered\n Software is not Incompatible With Secondary Licenses, this License permits\n You to additionally distribute such Covered Software under the terms of\n such Secondary License(s), so that the recipient of the Larger Work may, at\n their option, further distribute the Covered Software under the terms of\n either this License or such Secondary License(s).\n\n3.4. Notices\n\n You may not remove or alter the substance of any license notices (including\n copyright notices, patent notices, disclaimers of warranty, or limitations\n of liability) contained within the Source Code Form of the Covered\n Software, except that You may alter any license notices to the extent\n required to remedy known factual inaccuracies.\n\n3.5. Application of Additional Terms\n\n You may choose to offer, and to charge a fee for, warranty, support,\n indemnity or liability obligations to one or more recipients of Covered\n Software. However, You may do so only on Your own behalf, and not on behalf\n of any Contributor. You must make it absolutely clear that any such\n warranty, support, indemnity, or liability obligation is offered by You\n alone, and You hereby agree to indemnify every Contributor for any\n liability incurred by such Contributor as a result of warranty, support,\n indemnity or liability terms You offer. You may include additional\n disclaimers of warranty and limitations of liability specific to any\n jurisdiction.\n\n4. Inability to Comply Due to Statute or Regulation\n\n If it is impossible for You to comply with any of the terms of this License\n with respect to some or all of the Covered Software due to statute, judicial\n order, or regulation then You must: (a) comply with the terms of this License\n to the maximum extent possible; and (b) describe the limitations and the code\n they affect. Such description must be placed in a text file included with all\n distributions of the Covered Software under this License. Except to the\n extent prohibited by statute or regulation, such description must be\n sufficiently detailed for a recipient of ordinary skill to be able to\n understand it.\n\n5. Termination\n\n5.1. The rights granted under this License will terminate automatically if You\n fail to comply with any of its terms. However, if You become compliant,\n then the rights granted under this License from a particular Contributor\n are reinstated (a) provisionally, unless and until such Contributor\n explicitly and finally terminates Your grants, and (b) on an ongoing basis,\n if such Contributor fails to notify You of the non-compliance by some\n reasonable means prior to 60 days after You have come back into compliance.\n Moreover, Your grants from a particular Contributor are reinstated on an\n ongoing basis if such Contributor notifies You of the non-compliance by\n some reasonable means, this is the first time You have received notice of\n non-compliance with this License from such Contributor, and You become\n compliant prior to 30 days after Your receipt of the notice.\n\n5.2. If You initiate litigation against any entity by asserting a patent\n infringement claim (excluding declaratory judgment actions, counter-claims,\n and cross-claims) alleging that a Contributor Version directly or\n indirectly infringes any patent, then the rights granted to You by any and\n all Contributors for the Covered Software under Section 2.1 of this License\n shall terminate.\n\n5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user\n license agreements (excluding distributors and resellers) which have been\n validly granted by You or Your distributors under this License prior to\n termination shall survive termination.\n\n6. Disclaimer of Warranty\n\n Covered Software is provided under this License on an “as is” basis, without\n warranty of any kind, either expressed, implied, or statutory, including,\n without limitation, warranties that the Covered Software is free of defects,\n merchantable, fit for a particular purpose or non-infringing. The entire\n risk as to the quality and performance of the Covered Software is with You.\n Should any Covered Software prove defective in any respect, You (not any\n Contributor) assume the cost of any necessary servicing, repair, or\n correction. This disclaimer of warranty constitutes an essential part of this\n License. No use of any Covered Software is authorized under this License\n except under this disclaimer.\n\n7. Limitation of Liability\n\n Under no circumstances and under no legal theory, whether tort (including\n negligence), contract, or otherwise, shall any Contributor, or anyone who\n distributes Covered Software as permitted above, be liable to You for any\n direct, indirect, special, incidental, or consequential damages of any\n character including, without limitation, damages for lost profits, loss of\n goodwill, work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses, even if such party shall have been\n informed of the possibility of such damages. This limitation of liability\n shall not apply to liability for death or personal injury resulting from such\n party’s negligence to the extent applicable law prohibits such limitation.\n Some jurisdictions do not allow the exclusion or limitation of incidental or\n consequential damages, so this exclusion and limitation may not apply to You.\n\n8. Litigation\n\n Any litigation relating to this License may be brought only in the courts of\n a jurisdiction where the defendant maintains its principal place of business\n and such litigation shall be governed by laws of that jurisdiction, without\n reference to its conflict-of-law provisions. Nothing in this Section shall\n prevent a party’s ability to bring cross-claims or counter-claims.\n\n9. Miscellaneous\n\n This License represents the complete agreement concerning the subject matter\n hereof. If any provision of this License is held to be unenforceable, such\n provision shall be reformed only to the extent necessary to make it\n enforceable. Any law or regulation which provides that the language of a\n contract shall be construed against the drafter shall not be used to construe\n this License against a Contributor.\n\n\n10. Versions of the License\n\n10.1. New Versions\n\n Mozilla Foundation is the license steward. Except as provided in Section\n 10.3, no one other than the license steward has the right to modify or\n publish new versions of this License. Each version will be given a\n distinguishing version number.\n\n10.2. Effect of New Versions\n\n You may distribute the Covered Software under the terms of the version of\n the License under which You originally received the Covered Software, or\n under the terms of any subsequent version published by the license\n steward.\n\n10.3. Modified Versions\n\n If you create software not governed by this License, and you want to\n create a new license for such software, you may create and use a modified\n version of this License if you rename the license and remove any\n references to the name of the license steward (except to note that such\n modified license differs from this License).\n\n10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses\n If You choose to distribute Source Code Form that is Incompatible With\n Secondary Licenses under the terms of this version of the License, the\n notice described in Exhibit B of this License must be attached.\n\nExhibit A - Source Code Form License Notice\n\n This Source Code Form is subject to the\n terms of the Mozilla Public License, v.\n 2.0. If a copy of the MPL was not\n distributed with this file, You can\n obtain one at\n http://mozilla.org/MPL/2.0/.\n\nIf it is not possible or desirable to put the notice in a particular file, then\nYou may include the notice in a location (such as a LICENSE file in a relevant\ndirectory) where a recipient would be likely to look for such a notice.\n\nYou may add additional accurate notices of copyright ownership.\n\nExhibit B - “Incompatible With Secondary Licenses” Notice\n\n This Source Code Form is “Incompatible\n With Secondary Licenses”, as defined by\n the Mozilla Public License, v. 2.0.\n\n" }, { "name": "github.com/hashicorp/golang-lru/v2", @@ -799,6 +779,11 @@ "path": "github.com/klauspost/cpuid/v2/LICENSE", "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2015 Klaus Post\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n" }, + { + "name": "github.com/klauspost/crc32", + "path": "github.com/klauspost/crc32/LICENSE", + "licenseText": "Copyright (c) 2012 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" + }, { "name": "github.com/klauspost/pgzip", "path": "github.com/klauspost/pgzip/LICENSE", @@ -806,8 +791,8 @@ }, { "name": "github.com/lib/pq", - "path": "github.com/lib/pq/LICENSE.md", - "licenseText": "Copyright (c) 2011-2013, 'pq' Contributors\nPortions Copyright (C) 2011 Blake Mizerany\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" + "path": "github.com/lib/pq/LICENSE", + "licenseText": "MIT License\n\nCopyright (c) 2011-2013, 'pq' Contributors. Portions Copyright (c) 2011 Blake Mizerany\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, { "name": "github.com/libdns/libdns", @@ -1084,6 +1069,11 @@ "path": "github.com/sergi/go-diff/diffmatchpatch/LICENSE", "licenseText": "Copyright (c) 2012-2016 The go-diff Authors. All rights reserved.\n\nPermission is hereby granted, free of charge, to any person obtaining a\ncopy of this software and associated documentation files (the \"Software\"),\nto deal in the Software without restriction, including without limitation\nthe rights to use, copy, modify, merge, publish, distribute, sublicense,\nand/or sell copies of the Software, and to permit persons to whom the\nSoftware is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included\nin all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\nOR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n\n" }, + { + "name": "github.com/shopspring/decimal", + "path": "github.com/shopspring/decimal/LICENSE", + "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2015 Spring, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\n- Based on https://github.com/oguzbilgic/fpd, which has the following license:\n\"\"\"\nThe MIT License (MIT)\n\nCopyright (c) 2013 Oguz Bilgic\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\"\"\"\n" + }, { "name": "github.com/sirupsen/logrus", "path": "github.com/sirupsen/logrus/LICENSE", @@ -1096,8 +1086,13 @@ }, { "name": "github.com/sorairolake/lzip-go", - "path": "github.com/sorairolake/lzip-go/LICENSE", - "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n\n---\n\nMIT License\n\nCopyright (c) 2024 Shun Sakai\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + "path": "github.com/sorairolake/lzip-go/LICENSE-APACHE", + "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" + }, + { + "name": "github.com/spf13/afero", + "path": "github.com/spf13/afero/LICENSE.txt", + "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n" }, { "name": "github.com/ssor/bom", @@ -1217,7 +1212,7 @@ { "name": "go.uber.org/zap", "path": "go.uber.org/zap/LICENSE", - "licenseText": "Copyright (c) 2016-2017 Uber Technologies, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n" + "licenseText": "Copyright (c) 2016-2024 Uber Technologies, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n" }, { "name": "go.uber.org/zap/exp/zapslog", @@ -1225,8 +1220,18 @@ "licenseText": "Copyright (c) 2016-2024 Uber Technologies, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n" }, { - "name": "go4.org", - "path": "go4.org/LICENSE", + "name": "go.yaml.in/yaml/v2", + "path": "go.yaml.in/yaml/v2/LICENSE", + "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"{}\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright {yyyy} {name of copyright owner}\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" + }, + { + "name": "go.yaml.in/yaml/v3", + "path": "go.yaml.in/yaml/v3/LICENSE", + "licenseText": "\nThis project is covered by two different licenses: MIT and Apache.\n\n#### MIT License ####\n\nThe following files were ported to Go from C files of libyaml, and thus\nare still covered by their original MIT license, with the additional\ncopyright staring in 2011 when the project was ported over:\n\n apic.go emitterc.go parserc.go readerc.go scannerc.go\n writerc.go yamlh.go yamlprivateh.go\n\nCopyright (c) 2006-2010 Kirill Simonov\nCopyright (c) 2006-2011 Kirill Simonov\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies\nof the Software, and to permit persons to whom the Software is furnished to do\nso, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n### Apache License ###\n\nAll the remaining project files are covered by the Apache license:\n\nCopyright (c) 2011-2019 Canonical Ltd\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" + }, + { + "name": "go4.org/readerutil", + "path": "go4.org/readerutil/LICENSE", "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"{}\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright {yyyy} {name of copyright owner}\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n\n" }, { diff --git a/tools/misspellings.csv b/assets/misspellings.csv similarity index 100% rename from tools/misspellings.csv rename to assets/misspellings.csv diff --git a/build.go b/build.go deleted file mode 100644 index e81ba54690..0000000000 --- a/build.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build vendor - -package main - -// Libraries that are included to vendor utilities used during Makefile build. -// These libraries will not be included in a normal compilation. - -import ( - // for vet - _ "code.gitea.io/gitea-vet" -) diff --git a/build/generate-emoji.go b/build/generate-emoji.go index 446ab5f440..cbc1be2139 100644 --- a/build/generate-emoji.go +++ b/build/generate-emoji.go @@ -24,8 +24,8 @@ import ( ) const ( - gemojiURL = "https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json" - maxUnicodeVersion = 15 + gemojiURL = "https://raw.githubusercontent.com/rhysd/gemoji/537ff2d7e0496e9964824f7f73ec7ece88c9765a/db/emoji.json" + maxUnicodeVersion = 16 ) var flagOut = flag.String("o", "modules/emoji/emoji_data.go", "out") @@ -149,8 +149,8 @@ func generate() ([]byte, error) { } // write a JSON file to use with tribute (write before adding skin tones since we can't support them there yet) - file, _ := json.Marshal(data) - _ = os.WriteFile("assets/emoji.json", file, 0o644) + file, _ := json.MarshalIndent(data, "", " ") + _ = os.WriteFile("assets/emoji.json", append(file, '\n'), 0o644) // Add skin tones to emoji that support it var ( diff --git a/build/update-locales.sh b/build/update-locales.sh index 6f9ee334be..5316746f30 100755 --- a/build/update-locales.sh +++ b/build/update-locales.sh @@ -1,52 +1,22 @@ #!/bin/sh # this script runs in alpine image which only has `sh` shell - -set +e -if sed --version 2>/dev/null | grep -q GNU; then - SED_INPLACE="sed -i" -else - SED_INPLACE="sed -i ''" -fi -set -e - -if [ ! -f ./options/locale/locale_en-US.ini ]; then +if [ ! -f ./options/locale/locale_en-US.json ]; then echo "please run this script in the root directory of the project" exit 1 fi -mv ./options/locale/locale_en-US.ini ./options/ - -# the "ini" library for locale has many quirks, its behavior is different from Crowdin. -# see i18n_test.go for more details - -# this script helps to unquote the Crowdin outputs for the quirky ini library -# * find all `key="...\"..."` lines -# * remove the leading quote -# * remove the trailing quote -# * unescape the quotes -# * eg: key="...\"..." => key=..."... -$SED_INPLACE -r -e '/^[-.A-Za-z0-9_]+[ ]*=[ ]*".*"$/ { - s/^([-.A-Za-z0-9_]+)[ ]*=[ ]*"/\1=/ - s/"$// - s/\\"/"/g - }' ./options/locale/*.ini - -# * if the escaped line is incomplete like `key="...` or `key=..."`, quote it with backticks -# * eg: key="... => key=`"...` -# * eg: key=..." => key=`..."` -$SED_INPLACE -r -e 's/^([-.A-Za-z0-9_]+)[ ]*=[ ]*(".*[^"])$/\1=`\2`/' ./options/locale/*.ini -$SED_INPLACE -r -e 's/^([-.A-Za-z0-9_]+)[ ]*=[ ]*([^"].*")$/\1=`\2`/' ./options/locale/*.ini +mv ./options/locale/locale_en-US.json ./options/ # Remove translation under 25% of en_us -baselines=$(wc -l "./options/locale_en-US.ini" | cut -d" " -f1) +baselines=$(cat "./options/locale_en-US.json" | wc -l) baselines=$((baselines / 4)) -for filename in ./options/locale/*.ini; do - lines=$(wc -l "$filename" | cut -d" " -f1) - if [ $lines -lt $baselines ]; then +for filename in ./options/locale/*.json; do + lines=$(cat "$filename" | wc -l) + if [ "$lines" -lt "$baselines" ]; then echo "Removing $filename: $lines/$baselines" rm "$filename" fi done -mv ./options/locale_en-US.ini ./options/locale/ +mv ./options/locale_en-US.json ./options/locale/ diff --git a/cmd/admin.go b/cmd/admin.go index a01274b90e..dbd48e5727 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -134,7 +134,7 @@ func runRepoSyncReleases(ctx context.Context, _ *cli.Command) error { } log.Trace(" currentNumReleases is %d, running SyncReleasesWithTags", oldnum) - if err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil { + if _, err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil { log.Warn(" SyncReleasesWithTags: %v", err) gitRepo.Close() continue diff --git a/cmd/admin_auth_ldap.go b/cmd/admin_auth_ldap.go index 069ad6600c..c9be5abb37 100644 --- a/cmd/admin_auth_ldap.go +++ b/cmd/admin_auth_ldap.go @@ -94,6 +94,10 @@ func commonLdapCLIFlags() []cli.Flag { Name: "public-ssh-key-attribute", Usage: "The attribute of the user’s LDAP record containing the user’s public ssh key.", }, + &cli.BoolFlag{ + Name: "ssh-keys-are-verified", + Usage: "Set to true to automatically flag SSH keys in LDAP as verified.", + }, &cli.BoolFlag{ Name: "skip-local-2fa", Usage: "Set to true to skip local 2fa for users authenticated by this source", @@ -294,6 +298,9 @@ func parseLdapConfig(c *cli.Command, config *ldap.Source) error { if c.IsSet("public-ssh-key-attribute") { config.AttributeSSHPublicKey = c.String("public-ssh-key-attribute") } + if c.IsSet("ssh-keys-are-verified") { + config.SSHKeysAreVerified = c.Bool("ssh-keys-are-verified") + } if c.IsSet("avatar-attribute") { config.AttributeAvatar = c.String("avatar-attribute") } diff --git a/cmd/admin_auth_ldap_test.go b/cmd/admin_auth_ldap_test.go index 2da7ebc573..0e3e465e81 100644 --- a/cmd/admin_auth_ldap_test.go +++ b/cmd/admin_auth_ldap_test.go @@ -233,7 +233,7 @@ func TestAddLdapBindDn(t *testing.T) { }, getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) { assert.FailNow(t, "getAuthSourceByID called", "case %d: should not call getAuthSourceByID", n) - return nil, nil + return nil, nil //nolint:nilnil // mock function covering improper behavior }, } @@ -463,7 +463,7 @@ func TestAddLdapSimpleAuth(t *testing.T) { }, getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) { assert.FailNow(t, "getAuthSourceById called", "case %d: should not call getAuthSourceByID", n) - return nil, nil + return nil, nil //nolint:nilnil // mock function covering improper behavior }, } diff --git a/cmd/cmd.go b/cmd/cmd.go index 5b96bcbf9a..25e90a1695 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -121,6 +121,12 @@ func globalBool(c *cli.Command, name string) bool { // Any log appears in git stdout pipe will break the git protocol, eg: client can't push and hangs forever. func PrepareConsoleLoggerLevel(defaultLevel log.Level) func(context.Context, *cli.Command) (context.Context, error) { return func(ctx context.Context, c *cli.Command) (context.Context, error) { + if setting.InstallLock { + // During config loading, there might also be logs (for example: deprecation warnings). + // It must make sure that console logger is set up before config is loaded. + log.Error("Config is loaded before console logger is setup, it will cause bugs. Please fix it.") + return nil, errors.New("console logger must be setup before config is loaded") + } level := defaultLevel if globalBool(c, "quiet") { level = log.FATAL diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000000..5303b0e1e0 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,156 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + + "code.gitea.io/gitea/modules/setting" + + "github.com/urfave/cli/v3" +) + +func cmdConfig() *cli.Command { + subcmdConfigEditIni := &cli.Command{ + Name: "edit-ini", + Usage: "Load an existing INI file, apply environment variables, keep specified keys, and output to a new INI file.", + Description: ` +Help users to edit the Gitea configuration INI file. + +# Keep Specified Keys + +If you need to re-create the configuration file with only a subset of keys, +you can provide an INI template file for the kept keys and use the "--config-keep-keys" flag. +For example, if a helm chart needs to reset the settings and only keep SECRET_KEY, +it can use a template file (only keys take effect, values are ignored): + + [security] + SECRET_KEY= + +$ ./gitea config edit-ini --config app-old.ini --config-keep-keys app-keys.ini --out app-new.ini + +# Map Environment Variables to INI Configuration + +Environment variables of the form "GITEA__section_name__KEY_NAME" +will be mapped to the ini section "[section_name]" and the key +"KEY_NAME" with the value as provided. + +Environment variables of the form "GITEA__section_name__KEY_NAME__FILE" +will be mapped to the ini section "[section_name]" and the key +"KEY_NAME" with the value loaded from the specified file. + +Environment variable keys can only contain characters "0-9A-Z_", +if a section or key name contains dot ".", it needs to be escaped as _0x2E_. +For example, to apply this config: + + [git.config] + foo.bar=val + +$ export GITEA__git_0x2E_config__foo_0x2E_bar=val + +# Put All Together + +$ ./gitea config edit-ini --config app.ini --config-keep-keys app-keys.ini --apply-env {--in-place|--out app-new.ini} +`, + Flags: []cli.Flag{ + // "--config" flag is provided by global flags, and this flag is also used by "environment-to-ini" script wrapper + // "--in-place" is also used by "environment-to-ini" script wrapper for its old behavior: always overwrite the existing config file + &cli.BoolFlag{ + Name: "in-place", + Usage: "Output to the same config file as input. This flag will be ignored if --out is set.", + }, + &cli.StringFlag{ + Name: "config-keep-keys", + Usage: "An INI template file containing keys for keeping. Only the keys defined in the INI template will be kept from old config. If not set, all keys will be kept.", + }, + &cli.BoolFlag{ + Name: "apply-env", + Usage: "Apply all GITEA__* variables from the environment to the config.", + }, + &cli.StringFlag{ + Name: "out", + Usage: "Destination config file to write to.", + }, + }, + Action: runConfigEditIni, + } + + return &cli.Command{ + Name: "config", + Usage: "Manage Gitea configuration", + Commands: []*cli.Command{ + subcmdConfigEditIni, + }, + } +} + +func runConfigEditIni(_ context.Context, c *cli.Command) error { + // the config system may change the environment variables, so get a copy first, to be used later + env := append([]string{}, os.Environ()...) + + // don't use the guessed setting.CustomConf, instead, require the user to provide --config explicitly + if !c.IsSet("config") { + return errors.New("flag is required but not set: --config") + } + configFileIn := c.String("config") + + cfgIn, err := setting.NewConfigProviderFromFile(configFileIn) + if err != nil { + return fmt.Errorf("failed to load config file %q: %v", configFileIn, err) + } + + // determine output config file: use "--out" flag or use "--in-place" flag to overwrite input file + inPlace := c.Bool("in-place") + configFileOut := c.String("out") + if configFileOut == "" { + if !inPlace { + return errors.New("either --in-place or --out must be specified") + } + configFileOut = configFileIn // in-place edit + } + + needWriteOut := configFileOut != configFileIn + + cfgOut := cfgIn + configKeepKeys := c.String("config-keep-keys") + if configKeepKeys != "" { + needWriteOut = true + cfgOut, err = setting.NewConfigProviderFromFile(configKeepKeys) + if err != nil { + return fmt.Errorf("failed to load config-keep-keys template file %q: %v", configKeepKeys, err) + } + + for _, secOut := range cfgOut.Sections() { + for _, keyOut := range secOut.Keys() { + secIn := cfgIn.Section(secOut.Name()) + keyIn := setting.ConfigSectionKey(secIn, keyOut.Name()) + if keyIn != nil { + keyOut.SetValue(keyIn.String()) + } else { + secOut.DeleteKey(keyOut.Name()) + } + } + if len(secOut.Keys()) == 0 { + cfgOut.DeleteSection(secOut.Name()) + } + } + } + + if c.Bool("apply-env") { + if setting.EnvironmentToConfig(cfgOut, env) { + needWriteOut = true + } + } + + if needWriteOut { + err = cfgOut.SaveTo(configFileOut) + if err != nil { + return err + } + } + return nil +} diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000000..d123daa617 --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,85 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConfigEdit(t *testing.T) { + tmpDir := t.TempDir() + configOld := tmpDir + "/app-old.ini" + configTemplate := tmpDir + "/app-template.ini" + _ = os.WriteFile(configOld, []byte(` +[sec] +k1=v1 +k2=v2 +`), os.ModePerm) + + _ = os.WriteFile(configTemplate, []byte(` +[sec] +k1=in-template + +[sec2] +k3=v3 +`), os.ModePerm) + + t.Setenv("GITEA__EnV__KeY", "val") + + t.Run("OutputToNewWithEnv", func(t *testing.T) { + configNew := tmpDir + "/app-new.ini" + err := NewMainApp(AppVersion{}).Run(t.Context(), []string{ + "./gitea", "--config", configOld, + "config", "edit-ini", + "--apply-env", + "--config-keep-keys", configTemplate, + "--out", configNew, + }) + require.NoError(t, err) + + // "k1" old value is kept because its key is in the template + // "k2" is removed because it isn't in the template + // "k3" isn't in new config because it isn't in the old config + // [env] is applied from environment variable + data, _ := os.ReadFile(configNew) + require.Equal(t, `[sec] +k1 = v1 + +[env] +KeY = val +`, string(data)) + }) + + t.Run("OutputToExisting(environment-to-ini)", func(t *testing.T) { + // the legacy "environment-to-ini" (now a wrapper script) behavior: + // if no "--out", then "--in-place" must be used to overwrite the existing "--config" file + err := NewMainApp(AppVersion{}).Run(t.Context(), []string{ + "./gitea", "config", "edit-ini", + "--apply-env", + "--config", configOld, + }) + require.ErrorContains(t, err, "either --in-place or --out must be specified") + + // simulate the "environment-to-ini" behavior with "--in-place" + err = NewMainApp(AppVersion{}).Run(t.Context(), []string{ + "./gitea", "config", "edit-ini", + "--in-place", + "--apply-env", + "--config", configOld, + }) + require.NoError(t, err) + + data, _ := os.ReadFile(configOld) + require.Equal(t, `[sec] +k1 = v1 +k2 = v2 + +[env] +KeY = val +`, string(data)) + }) +} diff --git a/cmd/hook.go b/cmd/hook.go index 1845ade625..6004f679ac 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -163,6 +163,14 @@ func (n *nilWriter) WriteString(s string) (int, error) { return len(s), nil } +func parseGitHookCommitRefLine(line string) (oldCommitID, newCommitID string, refFullName git.RefName, ok bool) { + fields := strings.Split(line, " ") + if len(fields) != 3 { + return "", "", "", false + } + return fields[0], fields[1], git.RefName(fields[2]), true +} + func runHookPreReceive(ctx context.Context, c *cli.Command) error { if isInternal, _ := strconv.ParseBool(os.Getenv(repo_module.EnvIsInternal)); isInternal { return nil @@ -228,14 +236,11 @@ Gitea or set your environment appropriately.`, "") continue } - fields := bytes.Fields(scanner.Bytes()) - if len(fields) != 3 { + oldCommitID, newCommitID, refFullName, ok := parseGitHookCommitRefLine(scanner.Text()) + if !ok { continue } - oldCommitID := string(fields[0]) - newCommitID := string(fields[1]) - refFullName := git.RefName(fields[2]) total++ lastline++ @@ -313,7 +318,7 @@ func runHookPostReceive(ctx context.Context, c *cli.Command) error { setup(ctx, c.Bool("debug")) // First of all run update-server-info no matter what - if _, _, err := gitcmd.NewCommand("update-server-info").RunStdString(ctx); err != nil { + if err := gitcmd.NewCommand("update-server-info").RunWithStderr(ctx); err != nil { return fmt.Errorf("failed to call 'git update-server-info': %w", err) } @@ -378,16 +383,13 @@ Gitea or set your environment appropriately.`, "") continue } - fields := bytes.Fields(scanner.Bytes()) - if len(fields) != 3 { + var ok bool + oldCommitIDs[count], newCommitIDs[count], refFullNames[count], ok = parseGitHookCommitRefLine(scanner.Text()) + if !ok { continue } fmt.Fprintf(out, ".") - oldCommitIDs[count] = string(fields[0]) - newCommitIDs[count] = string(fields[1]) - refFullNames[count] = git.RefName(fields[2]) - commitID, _ := git.NewIDFromString(newCommitIDs[count]) if refFullNames[count] == git.BranchPrefix+"master" && !commitID.IsZero() && count == total { masterPushed = true @@ -594,8 +596,8 @@ Gitea or set your environment appropriately.`, "") hookOptions.RefFullNames = make([]git.RefName, 0, hookBatchSize) for { - // note: pktLineTypeUnknow means pktLineTypeFlush and pktLineTypeData all allowed - rs, err = readPktLine(ctx, reader, pktLineTypeUnknow) + // note: pktLineTypeUnknown means pktLineTypeFlush and pktLineTypeData all allowed + rs, err = readPktLine(ctx, reader, pktLineTypeUnknown) if err != nil { return err } @@ -614,7 +616,7 @@ Gitea or set your environment appropriately.`, "") if hasPushOptions { for { - rs, err = readPktLine(ctx, reader, pktLineTypeUnknow) + rs, err = readPktLine(ctx, reader, pktLineTypeUnknown) if err != nil { return err } @@ -711,8 +713,8 @@ Gitea or set your environment appropriately.`, "") type pktLineType int64 const ( - // UnKnow type - pktLineTypeUnknow pktLineType = 0 + // Unknown type + pktLineTypeUnknown pktLineType = 0 // flush-pkt "0000" pktLineTypeFlush pktLineType = iota // data line diff --git a/cmd/hook_test.go b/cmd/hook_test.go index 86cd4834f2..fefc33c01c 100644 --- a/cmd/hook_test.go +++ b/cmd/hook_test.go @@ -39,3 +39,17 @@ func TestPktLine(t *testing.T) { assert.NoError(t, err) assert.Equal(t, []byte("0007a\nb"), w.Bytes()) } + +func TestParseGitHookCommitRefLine(t *testing.T) { + oldCommitID, newCommitID, refName, ok := parseGitHookCommitRefLine("a b c") + assert.True(t, ok) + assert.Equal(t, "a", oldCommitID) + assert.Equal(t, "b", newCommitID) + assert.Equal(t, "c", string(refName)) + + _, _, _, ok = parseGitHookCommitRefLine("a\tb\tc") + assert.False(t, ok) + + _, _, _, ok = parseGitHookCommitRefLine("a b") + assert.False(t, ok) +} diff --git a/cmd/keys.go b/cmd/keys.go index 5ca3b91e15..035d39bfb8 100644 --- a/cmd/keys.go +++ b/cmd/keys.go @@ -19,7 +19,7 @@ import ( var CmdKeys = &cli.Command{ Name: "keys", Usage: "(internal) Should only be called by SSH server", - Hidden: true, // internal commands shouldn't not be visible + Hidden: true, // internal commands shouldn't be visible Description: "Queries the Gitea database to get the authorized command for a given ssh key fingerprint", Before: PrepareConsoleLoggerLevel(log.FATAL), Action: runKeys, diff --git a/cmd/main.go b/cmd/main.go index 3fdaf48ed9..2ee00382d7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -50,11 +50,15 @@ DEFAULT CONFIGURATION: func prepareSubcommandWithGlobalFlags(originCmd *cli.Command) { originBefore := originCmd.Before - originCmd.Before = func(ctx context.Context, cmd *cli.Command) (context.Context, error) { - prepareWorkPathAndCustomConf(cmd) + originCmd.Before = func(ctxOrig context.Context, cmd *cli.Command) (ctx context.Context, err error) { + ctx = ctxOrig if originBefore != nil { - return originBefore(ctx, cmd) + ctx, err = originBefore(ctx, cmd) + if err != nil { + return ctx, err + } } + prepareWorkPathAndCustomConf(cmd) return ctx, nil } } @@ -128,6 +132,7 @@ func NewMainApp(appVer AppVersion) *cli.Command { // these sub-commands do not need the config file, and they do not depend on any path or environment variable. subCmdStandalone := []*cli.Command{ + cmdConfig(), cmdCert(), CmdGenerate, CmdDocs, @@ -144,7 +149,7 @@ func NewMainApp(appVer AppVersion) *cli.Command { app.Commands = append(app.Commands, subCmdWithConfig...) app.Commands = append(app.Commands, subCmdStandalone...) - setting.InitGiteaEnvVars() + setting.UnsetUnnecessaryEnvVars() return app } diff --git a/cmd/main_test.go b/cmd/main_test.go index d49ebfd4df..b1f6bb3ba9 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" "github.com/urfave/cli/v3" @@ -28,11 +29,11 @@ func makePathOutput(workPath, customPath, customConf string) string { return fmt.Sprintf("WorkPath=%s\nCustomPath=%s\nCustomConf=%s", workPath, customPath, customConf) } -func newTestApp(testCmdAction cli.ActionFunc) *cli.Command { +func newTestApp(testCmd cli.Command) *cli.Command { app := NewMainApp(AppVersion{}) - testCmd := &cli.Command{Name: "test-cmd", Action: testCmdAction} - prepareSubcommandWithGlobalFlags(testCmd) - app.Commands = append(app.Commands, testCmd) + testCmd.Name = util.IfZero(testCmd.Name, "test-cmd") + prepareSubcommandWithGlobalFlags(&testCmd) + app.Commands = append(app.Commands, &testCmd) app.DefaultCommand = testCmd.Name return app } @@ -156,9 +157,12 @@ func TestCliCmd(t *testing.T) { for _, c := range cases { t.Run(c.cmd, func(t *testing.T) { - app := newTestApp(func(ctx context.Context, cmd *cli.Command) error { - _, _ = fmt.Fprint(cmd.Root().Writer, makePathOutput(setting.AppWorkPath, setting.CustomPath, setting.CustomConf)) - return nil + defer test.MockVariableValue(&setting.InstallLock, false)() + app := newTestApp(cli.Command{ + Action: func(ctx context.Context, cmd *cli.Command) error { + _, _ = fmt.Fprint(cmd.Root().Writer, makePathOutput(setting.AppWorkPath, setting.CustomPath, setting.CustomConf)) + return nil + }, }) for k, v := range c.env { t.Setenv(k, v) @@ -167,37 +171,63 @@ func TestCliCmd(t *testing.T) { r, err := runTestApp(app, args...) assert.NoError(t, err, c.cmd) assert.NotEmpty(t, c.exp, c.cmd) - assert.Contains(t, r.Stdout, c.exp, c.cmd) + if !assert.Contains(t, r.Stdout, c.exp, c.cmd) { + t.Log("Full output:\n" + r.Stdout) + t.Log("Expected:\n" + c.exp) + } }) } } func TestCliCmdError(t *testing.T) { - app := newTestApp(func(ctx context.Context, cmd *cli.Command) error { return errors.New("normal error") }) + app := newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return errors.New("normal error") }}) r, err := runTestApp(app, "./gitea", "test-cmd") assert.Error(t, err) assert.Equal(t, 1, r.ExitCode) assert.Empty(t, r.Stdout) assert.Equal(t, "Command error: normal error\n", r.Stderr) - app = newTestApp(func(ctx context.Context, cmd *cli.Command) error { return cli.Exit("exit error", 2) }) + app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return cli.Exit("exit error", 2) }}) r, err = runTestApp(app, "./gitea", "test-cmd") assert.Error(t, err) assert.Equal(t, 2, r.ExitCode) assert.Empty(t, r.Stdout) assert.Equal(t, "exit error\n", r.Stderr) - app = newTestApp(func(ctx context.Context, cmd *cli.Command) error { return nil }) + app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return nil }}) r, err = runTestApp(app, "./gitea", "test-cmd", "--no-such") assert.Error(t, err) assert.Equal(t, 1, r.ExitCode) assert.Empty(t, r.Stdout) assert.Equal(t, "Incorrect Usage: flag provided but not defined: -no-such\n\n", r.Stderr) - app = newTestApp(func(ctx context.Context, cmd *cli.Command) error { return nil }) + app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return nil }}) r, err = runTestApp(app, "./gitea", "test-cmd") assert.NoError(t, err) assert.Equal(t, -1, r.ExitCode) // the cli.OsExiter is not called assert.Empty(t, r.Stdout) assert.Empty(t, r.Stderr) } + +func TestCliCmdBefore(t *testing.T) { + ctxNew := context.WithValue(context.Background(), any("key"), "value") + configValues := map[string]string{} + setting.CustomConf = "/tmp/any.ini" + var actionCtx context.Context + app := newTestApp(cli.Command{ + Before: func(context.Context, *cli.Command) (context.Context, error) { + configValues["before"] = setting.CustomConf + return ctxNew, nil + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + configValues["action"] = setting.CustomConf + actionCtx = ctx + return nil + }, + }) + _, err := runTestApp(app, "./gitea", "--config", "/dev/null", "test-cmd") + assert.NoError(t, err) + assert.Equal(t, ctxNew, actionCtx) + assert.Equal(t, "/tmp/any.ini", configValues["before"], "BeforeFunc must be called before preparing config") + assert.Equal(t, "/dev/null", configValues["action"]) +} diff --git a/cmd/migrate_storage.go b/cmd/migrate_storage.go index 2c63e15f50..a6bf9fa4b5 100644 --- a/cmd/migrate_storage.go +++ b/cmd/migrate_storage.go @@ -36,7 +36,7 @@ var CmdMigrateStorage = &cli.Command{ Name: "type", Aliases: []string{"t"}, Value: "", - Usage: "Type of stored files to copy. Allowed types: 'attachments', 'lfs', 'avatars', 'repo-avatars', 'repo-archivers', 'packages', 'actions-log', 'actions-artifacts", + Usage: "Type of stored files to copy. Allowed types: 'attachments', 'lfs', 'avatars', 'repo-avatars', 'repo-archivers', 'packages', 'actions-log', 'actions-artifacts'", }, &cli.StringFlag{ Name: "storage", diff --git a/cmd/serv.go b/cmd/serv.go index 76d8c81544..4110fda0d5 100644 --- a/cmd/serv.go +++ b/cmd/serv.go @@ -13,13 +13,12 @@ import ( "path/filepath" "strconv" "strings" - "time" "unicode" asymkey_model "code.gitea.io/gitea/models/asymkey" git_model "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/models/perm" - "code.gitea.io/gitea/models/repo" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/json" @@ -32,7 +31,6 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/services/lfs" - "github.com/golang-jwt/jwt/v5" "github.com/kballard/go-shellquote" "github.com/urfave/cli/v3" ) @@ -133,27 +131,6 @@ func getAccessMode(verb, lfsVerb string) perm.AccessMode { return perm.AccessModeNone } -func getLFSAuthToken(ctx context.Context, lfsVerb string, results *private.ServCommandResults) (string, error) { - now := time.Now() - claims := lfs.Claims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)), - NotBefore: jwt.NewNumericDate(now), - }, - RepoID: results.RepoID, - Op: lfsVerb, - UserID: results.UserID, - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - // Sign and get the complete encoded token as a string using the secret - tokenString, err := token.SignedString(setting.LFS.JWTSecretBytes) - if err != nil { - return "", fail(ctx, "Failed to sign JWT Token", "Failed to sign JWT token: %v", err) - } - return "Bearer " + tokenString, nil -} - func runServ(ctx context.Context, c *cli.Command) error { // FIXME: This needs to internationalised setup(ctx, c.Bool("debug")) @@ -230,7 +207,7 @@ func runServ(ctx context.Context, c *cli.Command) error { username := repoPathFields[0] reponame := strings.TrimSuffix(repoPathFields[1], ".git") // “the-repo-name" or "the-repo-name.wiki" - if !repo.IsValidSSHAccessRepoName(reponame) { + if !repo_model.IsValidSSHAccessRepoName(reponame) { return fail(ctx, "Invalid repo name", "Invalid repo name: %s", reponame) } @@ -276,14 +253,16 @@ func runServ(ctx context.Context, c *cli.Command) error { return fail(ctx, extra.UserMsg, "ServCommand failed: %s", extra.Error) } - // LowerCase and trim the repoPath as that's how they are stored. - // This should be done after splitting the repoPath into username and reponame - // so that username and reponame are not affected. - repoPath = strings.ToLower(results.OwnerName + "/" + results.RepoName + ".git") + // because the original repoPath maybe redirected, we need to use the returned actual repository information + if results.IsWiki { + repoPath = repo_model.RelativeWikiPath(results.OwnerName, results.RepoName) + } else { + repoPath = repo_model.RelativePath(results.OwnerName, results.RepoName) + } // LFS SSH protocol if verb == git.CmdVerbLfsTransfer { - token, err := getLFSAuthToken(ctx, lfsVerb, results) + token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, RepoID: results.RepoID}) if err != nil { return err } @@ -294,7 +273,7 @@ func runServ(ctx context.Context, c *cli.Command) error { if verb == git.CmdVerbLfsAuthenticate { url := fmt.Sprintf("%s%s/%s.git/info/lfs", setting.AppURL, url.PathEscape(results.OwnerName), url.PathEscape(results.RepoName)) - token, err := getLFSAuthToken(ctx, lfsVerb, results) + token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, RepoID: results.RepoID}) if err != nil { return err } diff --git a/cmd/web.go b/cmd/web.go index 4723ddbbdd..61cfb87130 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -8,14 +8,13 @@ import ( "fmt" "net" "net/http" + "net/http/pprof" "os" "path/filepath" "strconv" "strings" "time" - _ "net/http/pprof" // Used for debugging if enabled and a web server is running - "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/gtprof" @@ -23,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers" "code.gitea.io/gitea/routers/install" @@ -156,7 +156,6 @@ func serveInstall(cmd *cli.Command) error { case <-graceful.GetManager().IsShutdown(): <-graceful.GetManager().Done() log.Info("PID: %d Gitea Web Finished", os.Getpid()) - log.GetManager().Close() return err default: } @@ -231,27 +230,26 @@ func serveInstalled(c *cli.Command) error { err := listen(webRoutes, true) <-graceful.GetManager().Done() log.Info("PID: %d Gitea Web Finished", os.Getpid()) - log.GetManager().Close() return err } func servePprof() { - // FIXME: it shouldn't use the global DefaultServeMux, and it should use a proper context - http.DefaultServeMux.Handle("/debug/fgprof", fgprof.Handler()) + mux := http.NewServeMux() + mux.HandleFunc("/debug/pprof/", pprof.Index) + mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("/debug/pprof/profile", pprof.Profile) + mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("/debug/pprof/trace", pprof.Trace) + mux.Handle("/debug/fgprof", fgprof.Handler()) + // FIXME: it should use a proper context _, _, finished := process.GetManager().AddTypedContext(context.TODO(), "Web: PProf Server", process.SystemProcessType, true) // The pprof server is for debug purpose only, it shouldn't be exposed on public network. At the moment, it's not worth introducing a configurable option for it. log.Info("Starting pprof server on localhost:6060") - log.Info("Stopped pprof server: %v", http.ListenAndServe("localhost:6060", nil)) + log.Info("Stopped pprof server: %v", http.ListenAndServe("localhost:6060", mux)) finished() } func runWeb(ctx context.Context, cmd *cli.Command) error { - defer func() { - if panicked := recover(); panicked != nil { - log.Fatal("PANIC: %v\n%s", panicked, log.Stack(2)) - } - }() - if subCmdName, valid := isValidDefaultSubCommand(cmd); !valid { return fmt.Errorf("unknown command: %s", subCmdName) } @@ -271,6 +269,10 @@ func runWeb(ctx context.Context, cmd *cli.Command) error { createPIDFile(cmd.String("pid")) } + // init the HTML renderer and load templates, if error happens, it will report the error immediately and exit with error log + // in dev mode, it won't exit, but watch the template files for changes + _ = templates.PageRenderer() + if !setting.InstallLock { if err := serveInstall(cmd); err != nil { return err diff --git a/contrib/environment-to-ini/README b/contrib/environment-to-ini/README deleted file mode 100644 index f1d3f2ae83..0000000000 --- a/contrib/environment-to-ini/README +++ /dev/null @@ -1,47 +0,0 @@ -Environment To Ini -================== - -Multiple docker users have requested that the Gitea docker is changed -to permit arbitrary configuration via environment variables. - -Gitea needs to use an ini file for configuration because the running -environment that starts the docker may not be the same as that used -by the hooks. An ini file also gives a good default and means that -users do not have to completely provide a full environment. - -With those caveats above, this command provides a generic way of -converting suitably structured environment variables into any ini -value. - -To use the command is very simple just run it and the default gitea -app.ini will be rewritten to take account of the variables provided, -however there are various options to give slightly different -behavior and these can be interrogated with the `-h` option. - -The environment variables should be of the form: - - GITEA__SECTION_NAME__KEY_NAME - -Note, SECTION_NAME in the notation above is case-insensitive. - -Environment variables are usually restricted to a reduced character -set "0-9A-Z_" - in order to allow the setting of sections with -characters outside of that set, they should be escaped as following: -"_0X2E_" for "." and "_0X2D_" for "-". The entire section and key names -can be escaped as a UTF8 byte string if necessary. E.g. to configure: - - """ - ... - [log.console] - COLORIZE=false - STDERR=true - ... - """ - -You would set the environment variables: "GITEA__LOG_0x2E_CONSOLE__COLORIZE=false" -and "GITEA__LOG_0x2E_CONSOLE__STDERR=false". Other examples can be found -on the configuration cheat sheet. - -To build locally, run: - - go build contrib/environment-to-ini/environment-to-ini.go diff --git a/contrib/environment-to-ini/environment-to-ini.go b/contrib/environment-to-ini/environment-to-ini.go deleted file mode 100644 index 5eb576c6fe..0000000000 --- a/contrib/environment-to-ini/environment-to-ini.go +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package main - -import ( - "context" - "os" - - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/setting" - - "github.com/urfave/cli/v3" -) - -func main() { - app := cli.Command{} - app.Name = "environment-to-ini" - app.Usage = "Use provided environment to update configuration ini" - app.Description = `As a helper to allow docker users to update the gitea configuration - through the environment, this command allows environment variables to - be mapped to values in the ini. - - Environment variables of the form "GITEA__SECTION_NAME__KEY_NAME" - will be mapped to the ini section "[section_name]" and the key - "KEY_NAME" with the value as provided. - - Environment variables of the form "GITEA__SECTION_NAME__KEY_NAME__FILE" - will be mapped to the ini section "[section_name]" and the key - "KEY_NAME" with the value loaded from the specified file. - - Environment variables are usually restricted to a reduced character - set "0-9A-Z_" - in order to allow the setting of sections with - characters outside of that set, they should be escaped as following: - "_0X2E_" for ".". The entire section and key names can be escaped as - a UTF8 byte string if necessary. E.g. to configure: - - """ - ... - [log.console] - COLORIZE=false - STDERR=true - ... - """ - - You would set the environment variables: "GITEA__LOG_0x2E_CONSOLE__COLORIZE=false" - and "GITEA__LOG_0x2E_CONSOLE__STDERR=false". Other examples can be found - on the configuration cheat sheet.` - app.Flags = []cli.Flag{ - &cli.StringFlag{ - Name: "custom-path", - Aliases: []string{"C"}, - Value: setting.CustomPath, - Usage: "Custom path file path", - }, - &cli.StringFlag{ - Name: "config", - Aliases: []string{"c"}, - Value: setting.CustomConf, - Usage: "Custom configuration file path", - }, - &cli.StringFlag{ - Name: "work-path", - Aliases: []string{"w"}, - Value: setting.AppWorkPath, - Usage: "Set the gitea working path", - }, - &cli.StringFlag{ - Name: "out", - Aliases: []string{"o"}, - Value: "", - Usage: "Destination file to write to", - }, - } - app.Action = runEnvironmentToIni - err := app.Run(context.Background(), os.Args) - if err != nil { - log.Fatal("Failed to run app with %s: %v", os.Args, err) - } -} - -func runEnvironmentToIni(_ context.Context, c *cli.Command) error { - // the config system may change the environment variables, so get a copy first, to be used later - env := append([]string{}, os.Environ()...) - setting.InitWorkPathAndCfgProvider(os.Getenv, setting.ArgWorkPathAndCustomConf{ - WorkPath: c.String("work-path"), - CustomPath: c.String("custom-path"), - CustomConf: c.String("config"), - }) - - cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf) - if err != nil { - log.Fatal("Failed to load custom conf '%s': %v", setting.CustomConf, err) - } - - changed := setting.EnvironmentToConfig(cfg, env) - - // try to save the config file - destination := c.String("out") - if len(destination) == 0 { - destination = setting.CustomConf - } - if destination != setting.CustomConf || changed { - log.Info("Settings saved to: %q", destination) - err = cfg.SaveTo(destination) - if err != nil { - return err - } - } - - return nil -} diff --git a/crowdin.yml b/crowdin.yml index 35a38d768c..d14d484cf8 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -4,9 +4,9 @@ base_path: "." base_url: "https://api.crowdin.com" preserve_hierarchy: true files: - - source: "/options/locale/locale_en-US.ini" - translation: "/options/locale/locale_%locale%.ini" - type: "ini" + - source: "/options/locale/locale_en-US.json" + translation: "/options/locale/locale_%locale%.json" + type: "json" skip_untranslated_strings: true export_only_approved: true update_option: "update_as_unapproved" diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index aa2fcee765..084c66aab0 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -503,9 +503,6 @@ INTERNAL_TOKEN = ;; Password Hash algorithm, either "argon2", "pbkdf2", "scrypt" or "bcrypt" ;PASSWORD_HASH_ALGO = pbkdf2 ;; -;; Set false to allow JavaScript to read CSRF cookie -;CSRF_COOKIE_HTTP_ONLY = true -;; ;; Validate against https://haveibeenpwned.com/Passwords to see if a password has been exposed ;PASSWORD_CHECK_PWN = false ;; @@ -567,6 +564,11 @@ ENABLED = true ;; Alternative location to specify OAuth2 authentication secret. You cannot specify both this and JWT_SECRET, and must pick one ;JWT_SECRET_URI = file:/etc/gitea/oauth2_jwt_secret ;; +;; The "issuer" claim identifies the principal that issued the JWT. +;; Gitea 1.25 makes it default to "ROOT_URL without the last slash" to follow the standard. +;; If you have old logins from before 1.25, you may want to set it to the old (non-standard) value "ROOT_URL with the last slash". +;JWT_CLAIM_ISSUER = +;; ;; Lifetime of an OAuth2 access token in seconds ;ACCESS_TOKEN_EXPIRATION_TIME = 3600 ;; @@ -728,15 +730,15 @@ LEVEL = Info ;DISABLE_CORE_PROTECT_NTFS=false ;; Disable the usage of using partial clones for git. ;DISABLE_PARTIAL_CLONE = false +;; Set the similarity threshold passed to git commands via `--find-renames=`. +;; Default is 50%, the same as git. Must be a integer percentage between 0% and 100%. +;DIFF_RENAME_SIMILARITY_THRESHOLD = 50% ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Git Operation timeout in seconds ;[git.timeout] -;DEFAULT = 360 ;MIGRATE = 600 ;MIRROR = 300 -;CLONE = 300 -;PULL = 300 ;GC = 60 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1327,9 +1329,12 @@ LEVEL = Info ;; Leave it empty to allow users to select any theme from "{CustomPath}/public/assets/css/theme-*.css" ;THEMES = ;; -;; The icons for file list (basic/material), this is a temporary option which will be replaced by a user setting in the future. +;; The icon theme for files (basic/material) ;FILE_ICON_THEME = material ;; +;; The icon theme for folders (basic/material) +;FOLDER_ICON_THEME = basic +;; ;; All available reactions users can choose on issues/prs and comments. ;; Values can be emoji alias (:smile:) or a unicode emoji. ;; For custom reactions, add a tightly cropped square image to public/assets/img/emoji/reaction_name.png @@ -1343,6 +1348,10 @@ LEVEL = Info ;; Dont mistake it for Reactions. ;CUSTOM_EMOJIS = gitea, codeberg, gitlab, git, github, gogs ;; +;; Comma separated list of enabled emojis, for example: smile, thumbsup, thumbsdown +;; Leave it empty to enable all emojis. +;ENABLED_EMOJIS = +;; ;; Whether the full name of the users should be shown where possible. If the full name isn't set, the username will be used. ;DEFAULT_SHOW_FULL_NAME = false ;; @@ -2325,7 +2334,7 @@ LEVEL = Info ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Resynchronize pre-receive, update and post-receive hooks of all repositories. +;; Resynchronize git hooks of all repositories (pre-receive, update, post-receive, proc-receive, ...) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;[cron.resync_all_hooks] ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -2479,8 +2488,9 @@ LEVEL = Info ;[highlight.mapping] ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Extension mapping to highlight class -;; e.g. .toml=ini +;; Extension mapping to highlight class, for example: +;; .toml = ini +;; .my-js = JavaScript ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -2536,7 +2546,19 @@ LEVEL = Info ;; * sanitized: Sanitize the content and render it inside current page, default to only allow a few HTML tags and attributes. Customized sanitizer rules can be defined in [markup.sanitizer.*] . ;; * no-sanitizer: Disable the sanitizer and render the content inside current page. It's **insecure** and may lead to XSS attack if the content contains malicious code. ;; * iframe: Render the content in a separate standalone page and embed it into current page by iframe. The iframe is in sandbox mode with same-origin disabled, and the JS code are safely isolated from parent page. -;RENDER_CONTENT_MODE=sanitized +;RENDER_CONTENT_MODE = sanitized +;; The sandbox applied to the iframe and Content-Security-Policy header when RENDER_CONTENT_MODE is `iframe`. +;; It defaults to a safe set of "allow-*" restrictions (space separated). +;; You can also set it by your requirements or use "disabled" to disable the sandbox completely. +;; When set it, make sure there is no security risk: +;; * PDF-only content: generally safe to use "disabled", and it needs to be "disabled" because PDF only renders with no sandbox. +;; * HTML content with JS: if the "RENDER_COMMAND" can guarantee there is no XSS, then it is safe, otherwise, you need to fine tune the "allow-*" restrictions. +;RENDER_CONTENT_SANDBOX = +;; Whether post-process the rendered HTML content, including: +;; resolve relative links and image sources, recognizing issue/commit references, escaping invisible characters, +;; mentioning users, rendering permlink code blocks, replacing emoji shorthands, etc. +;; By default, this is true when RENDER_CONTENT_MODE is `sanitized`, otherwise false. +;NEED_POST_PROCESS = false ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/docker/root/usr/local/bin/environment-to-ini b/docker/root/usr/local/bin/environment-to-ini new file mode 100644 index 0000000000..bb0c540685 --- /dev/null +++ b/docker/root/usr/local/bin/environment-to-ini @@ -0,0 +1,2 @@ +#!/bin/bash +exec /app/gitea/gitea config edit-ini --in-place --apply-env "$@" diff --git a/docker/rootless/usr/local/bin/environment-to-ini b/docker/rootless/usr/local/bin/environment-to-ini new file mode 100644 index 0000000000..bb0c540685 --- /dev/null +++ b/docker/rootless/usr/local/bin/environment-to-ini @@ -0,0 +1,2 @@ +#!/bin/bash +exec /app/gitea/gitea config edit-ini --in-place --apply-env "$@" diff --git a/eslint.config.ts b/eslint.config.ts index 678a49647c..5815702c89 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -3,7 +3,6 @@ import comments from '@eslint-community/eslint-plugin-eslint-comments'; import github from 'eslint-plugin-github'; import globals from 'globals'; import importPlugin from 'eslint-plugin-import-x'; -import noUseExtendNative from 'eslint-plugin-no-use-extend-native'; import playwright from 'eslint-plugin-playwright'; import regexp from 'eslint-plugin-regexp'; import sonarjs from 'eslint-plugin-sonarjs'; @@ -16,10 +15,22 @@ import vue from 'eslint-plugin-vue'; import vueScopedCss from 'eslint-plugin-vue-scoped-css'; import wc from 'eslint-plugin-wc'; import {defineConfig, globalIgnores} from 'eslint/config'; +import type {ESLint} from 'eslint'; const jsExts = ['js', 'mjs', 'cjs'] as const; const tsExts = ['ts', 'mts', 'cts'] as const; -const restrictedSyntax = ['WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression']; + +const restrictedGlobals = [ + {name: 'localStorage', message: 'Use `modules/user-settings.ts` instead.'}, + {name: 'fetch', message: 'Use `modules/fetch.ts` instead.'}, +]; + +const restrictedProperties = [ + {object: 'window', property: 'localStorage', message: 'Use `modules/user-settings.ts` instead.'}, + {object: 'globalThis', property: 'localStorage', message: 'Use `modules/user-settings.ts` instead.'}, + {object: 'window', property: 'fetch', message: 'Use `modules/fetch.ts` instead.'}, + {object: 'globalThis', property: 'fetch', message: 'Use `modules/fetch.ts` instead.'}, +]; export default defineConfig([ globalIgnores([ @@ -33,10 +44,6 @@ export default defineConfig([ languageOptions: { ecmaVersion: 'latest', sourceType: 'module', - globals: { - ...globals.browser, - ...globals.node, - }, parser: typescriptParser, parserOptions: { sourceType: 'module', @@ -49,24 +56,18 @@ export default defineConfig([ }, linterOptions: { reportUnusedDisableDirectives: 2, + reportUnusedInlineConfigs: 2, }, plugins: { '@eslint-community/eslint-comments': comments, - // @ts-expect-error '@stylistic': stylistic, '@typescript-eslint': typescriptPlugin.plugin, 'array-func': arrayFunc, - // @ts-expect-error -- https://github.com/un-ts/eslint-plugin-import-x/issues/203 - 'import-x': importPlugin, - 'no-use-extend-native': noUseExtendNative, - // @ts-expect-error + 'import-x': importPlugin as unknown as ESLint.Plugin, // https://github.com/un-ts/eslint-plugin-import-x/issues/203 regexp, - // @ts-expect-error sonarjs, - // @ts-expect-error unicorn, github, - // @ts-expect-error wc, }, settings: { @@ -75,7 +76,7 @@ export default defineConfig([ 'import-x/resolver': {'typescript': true}, }, rules: { - '@eslint-community/eslint-comments/disable-enable-pair': [2], + '@eslint-community/eslint-comments/disable-enable-pair': [0], '@eslint-community/eslint-comments/no-aggregating-enable': [2], '@eslint-community/eslint-comments/no-duplicate-disable': [2], '@eslint-community/eslint-comments/no-restricted-disable': [0], @@ -155,11 +156,11 @@ export default defineConfig([ '@typescript-eslint/adjacent-overload-signatures': [0], '@typescript-eslint/array-type': [0], '@typescript-eslint/await-thenable': [2], - '@typescript-eslint/ban-ts-comment': [2, {'ts-expect-error': false, 'ts-ignore': true, 'ts-nocheck': false, 'ts-check': false}], + '@typescript-eslint/ban-ts-comment': [2, {'ts-expect-error': true, 'ts-ignore': true, 'ts-nocheck': false, 'ts-check': false}], '@typescript-eslint/ban-tslint-comment': [0], '@typescript-eslint/class-literal-property-style': [0], '@typescript-eslint/class-methods-use-this': [0], - '@typescript-eslint/consistent-generic-constructors': [0], + '@typescript-eslint/consistent-generic-constructors': [2, 'constructor'], '@typescript-eslint/consistent-indexed-object-style': [0], '@typescript-eslint/consistent-return': [0], '@typescript-eslint/consistent-type-assertions': [2, {assertionStyle: 'as', objectLiteralTypeAssertions: 'allow'}], @@ -221,7 +222,7 @@ export default defineConfig([ '@typescript-eslint/no-unnecessary-condition': [0], '@typescript-eslint/no-unnecessary-qualifier': [0], '@typescript-eslint/no-unnecessary-template-expression': [0], - '@typescript-eslint/no-unnecessary-type-arguments': [0], + '@typescript-eslint/no-unnecessary-type-arguments': [2], '@typescript-eslint/no-unnecessary-type-assertion': [2], '@typescript-eslint/no-unnecessary-type-constraint': [2], '@typescript-eslint/no-unnecessary-type-conversion': [2], @@ -234,10 +235,12 @@ export default defineConfig([ '@typescript-eslint/no-unsafe-member-access': [0], '@typescript-eslint/no-unsafe-return': [0], '@typescript-eslint/no-unsafe-unary-minus': [2], - '@typescript-eslint/no-unused-expressions': [0], + '@typescript-eslint/no-unused-expressions': [2], + '@typescript-eslint/no-unused-private-class-members': [2], '@typescript-eslint/no-unused-vars': [2, {vars: 'all', args: 'all', caughtErrors: 'all', ignoreRestSiblings: false, argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_'}], '@typescript-eslint/no-use-before-define': [2, {functions: false, classes: true, variables: true, allowNamedExports: true, typedefs: false, enums: false, ignoreTypeReferences: true}], '@typescript-eslint/no-useless-constructor': [0], + '@typescript-eslint/no-useless-default-assignment': [2], '@typescript-eslint/no-useless-empty-export': [0], '@typescript-eslint/no-wrapper-object-types': [2], '@typescript-eslint/non-nullable-type-assertion-style': [0], @@ -268,6 +271,7 @@ export default defineConfig([ '@typescript-eslint/restrict-template-expressions': [0], '@typescript-eslint/return-await': [0], '@typescript-eslint/strict-boolean-expressions': [0], + '@typescript-eslint/strict-void-return': [0], '@typescript-eslint/switch-exhaustiveness-check': [0], '@typescript-eslint/triple-slash-reference': [2], '@typescript-eslint/typedef': [0], @@ -338,7 +342,7 @@ export default defineConfig([ 'import-x/first': [2], 'import-x/group-exports': [0], 'import-x/max-dependencies': [0], - 'import-x/named': [2], + 'import-x/named': [0], 'import-x/namespace': [0], 'import-x/newline-after-import': [0], 'import-x/no-absolute-path': [0], @@ -366,7 +370,7 @@ export default defineConfig([ 'import-x/no-self-import': [2], 'import-x/no-unassigned-import': [0], 'import-x/no-unresolved': [2, {commonjs: true, ignore: ['\\?.+$']}], - // 'import-x/no-unused-modules': [2, {unusedExports: true}], // not compatible with eslint 9 + 'import-x/no-unused-modules': [0], // incompatible with eslint 9 'import-x/no-useless-path-segments': [2, {commonjs: true}], 'import-x/no-webpack-loader-syntax': [2], 'import-x/order': [0], @@ -562,9 +566,10 @@ export default defineConfig([ 'no-redeclare': [0], // must be disabled for typescript overloads 'no-regex-spaces': [2], 'no-restricted-exports': [0], - 'no-restricted-globals': [2, 'addEventListener', 'blur', 'close', 'closed', 'confirm', 'defaultStatus', 'defaultstatus', 'error', 'event', 'external', 'find', 'focus', 'frameElement', 'frames', 'history', 'innerHeight', 'innerWidth', 'isFinite', 'isNaN', 'length', 'locationbar', 'menubar', 'moveBy', 'moveTo', 'name', 'onblur', 'onerror', 'onfocus', 'onload', 'onresize', 'onunload', 'open', 'opener', 'opera', 'outerHeight', 'outerWidth', 'pageXOffset', 'pageYOffset', 'parent', 'print', 'removeEventListener', 'resizeBy', 'resizeTo', 'screen', 'screenLeft', 'screenTop', 'screenX', 'screenY', 'scroll', 'scrollbars', 'scrollBy', 'scrollTo', 'scrollX', 'scrollY', 'status', 'statusbar', 'stop', 'toolbar', 'top'], + 'no-restricted-globals': [2, ...restrictedGlobals], + 'no-restricted-properties': [2, ...restrictedProperties], 'no-restricted-imports': [0], - 'no-restricted-syntax': [2, ...restrictedSyntax, {selector: 'CallExpression[callee.name="fetch"]', message: 'use modules/fetch.ts instead'}], + 'no-restricted-syntax': [2, 'WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression'], 'no-return-assign': [0], 'no-script-url': [2], 'no-self-assign': [2, {props: true}], @@ -589,12 +594,12 @@ export default defineConfig([ 'no-unreachable': [2], 'no-unsafe-finally': [2], 'no-unsafe-negation': [2], - 'no-unused-expressions': [2], + 'no-unused-expressions': [0], // handled by @typescript-eslint/no-unused-expressions 'no-unused-labels': [2], - 'no-unused-private-class-members': [2], + 'no-unused-private-class-members': [0], // handled by @typescript-eslint/no-unused-private-class-members 'no-unused-vars': [0], // handled by @typescript-eslint/no-unused-vars 'no-use-before-define': [0], // handled by @typescript-eslint/no-use-before-define - 'no-use-extend-native/no-use-extend-native': [2], + 'no-useless-assignment': [2], 'no-useless-backreference': [2], 'no-useless-call': [2], 'no-useless-catch': [2], @@ -777,6 +782,7 @@ export default defineConfig([ 'unicorn/no-empty-file': [2], 'unicorn/no-for-loop': [0], 'unicorn/no-hex-escape': [0], + 'unicorn/no-immediate-mutation': [0], 'unicorn/no-instanceof-array': [0], 'unicorn/no-invalid-fetch-options': [2], 'unicorn/no-invalid-remove-event-listener': [2], @@ -802,6 +808,7 @@ export default defineConfig([ 'unicorn/no-unreadable-array-destructuring': [0], 'unicorn/no-unreadable-iife': [2], 'unicorn/no-unused-properties': [2], + 'unicorn/no-useless-collection-argument': [2], 'unicorn/no-useless-fallback-in-spread': [2], 'unicorn/no-useless-length-check': [2], 'unicorn/no-useless-promise-resolve-reject': [2], @@ -813,8 +820,8 @@ export default defineConfig([ 'unicorn/numeric-separators-style': [0], 'unicorn/prefer-add-event-listener': [2], 'unicorn/prefer-array-find': [2], - 'unicorn/prefer-array-flat-map': [2], 'unicorn/prefer-array-flat': [2], + 'unicorn/prefer-array-flat-map': [2], 'unicorn/prefer-array-index-of': [2], 'unicorn/prefer-array-some': [2], 'unicorn/prefer-at': [0], @@ -849,6 +856,7 @@ export default defineConfig([ 'unicorn/prefer-query-selector': [2], 'unicorn/prefer-reflect-apply': [0], 'unicorn/prefer-regexp-test': [2], + 'unicorn/prefer-response-static-json': [2], 'unicorn/prefer-set-has': [0], 'unicorn/prefer-set-size': [2], 'unicorn/prefer-spread': [0], @@ -900,7 +908,6 @@ export default defineConfig([ 'yoda': [2, 'never'], }, }, - // @ts-expect-error { ...playwright.configs['flat/recommended'], files: ['tests/e2e/**'], @@ -916,27 +923,19 @@ export default defineConfig([ }, }, extends: [ - // @ts-expect-error vue.configs['flat/recommended'], - // @ts-expect-error - vueScopedCss.configs['flat/recommended'], + vueScopedCss.configs['flat/recommended'] as any, ], rules: { 'vue/attributes-order': [0], 'vue/html-closing-bracket-spacing': [2, {startTag: 'never', endTag: 'never', selfClosingTag: 'never'}], 'vue/max-attributes-per-line': [0], 'vue/singleline-html-element-content-newline': [0], - }, - }, - { - files: ['web_src/js/modules/fetch.ts', 'web_src/js/standalone/**/*'], - rules: { - 'no-restricted-syntax': [2, ...restrictedSyntax], + 'vue/require-typed-ref': [2], }, }, { files: ['**/*.test.ts', 'web_src/js/test/setup.ts'], - // @ts-expect-error - https://github.com/vitest-dev/eslint-plugin-vitest/issues/737 plugins: {vitest}, languageOptions: {globals: globals.vitest}, rules: { @@ -988,42 +987,23 @@ export default defineConfig([ 'vitest/require-to-throw-message': [0], 'vitest/require-top-level-describe': [0], 'vitest/valid-describe-callback': [2], - 'vitest/valid-expect': [2], + 'vitest/valid-expect': [2, {maxArgs: 2}], 'vitest/valid-title': [2], }, }, - { - files: ['web_src/js/types.ts'], - rules: { - 'import-x/no-unused-modules': [0], - }, - }, { files: ['**/*.d.ts'], rules: { - 'import-x/no-unused-modules': [0], '@typescript-eslint/consistent-type-definitions': [0], '@typescript-eslint/consistent-type-imports': [0], }, }, { - files: ['*.config.*'], - rules: { - 'import-x/no-unused-modules': [0], - }, - }, - { - files: ['web_src/**/*', 'docs/**/*'], - languageOptions: {globals: globals.browser}, + files: ['*', 'tools/**/*'], + languageOptions: {globals: globals.nodeBuiltin}, }, { files: ['web_src/**/*'], - languageOptions: { - globals: { - ...globals.browser, - __webpack_public_path__: true, - process: false, // https://github.com/webpack/webpack/issues/15833 - }, - }, + languageOptions: {globals: {...globals.browser, ...globals.webpack}}, }, ]); diff --git a/eslint.json.config.ts b/eslint.json.config.ts new file mode 100644 index 0000000000..45696fb79c --- /dev/null +++ b/eslint.json.config.ts @@ -0,0 +1,30 @@ +import {defineConfig, globalIgnores} from 'eslint/config'; +import json from '@eslint/json'; + +export default defineConfig([ + globalIgnores([ + '**/.venv', + '**/node_modules', + '**/public', + ]), + { + files: ['**/*.json'], + plugins: {json}, + language: 'json/json', + extends: ['json/recommended'], + }, + { + files: [ + 'tsconfig.json', + '.devcontainer/*.json', + '.vscode/*.json', + 'contrib/ide/vscode/*.json', + ], + plugins: {json}, + language: 'json/jsonc', + languageOptions: { + allowTrailingCommas: true, + }, + extends: ['json/recommended'], + }, +]); diff --git a/flake.lock b/flake.lock index 16a487ba13..4cbc85b87a 100644 --- a/flake.lock +++ b/flake.lock @@ -1,30 +1,12 @@ { "nodes": { - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1755186698, - "narHash": "sha256-wNO3+Ks2jZJ4nTHMuks+cxAiVBGNuEBXsT29Bz6HASo=", + "lastModified": 1760038930, + "narHash": "sha256-Oncbh0UmHjSlxO7ErQDM3KM0A5/Znfofj2BSzlHLeVw=", "owner": "nixos", "repo": "nixpkgs", - "rev": "fbcf476f790d8a217c3eab4e12033dc4a0f6d23c", + "rev": "0b4defa2584313f3b781240b29d61f6f9f7e0df3", "type": "github" }, "original": { @@ -36,24 +18,8 @@ }, "root": { "inputs": { - "flake-utils": "flake-utils", "nixpkgs": "nixpkgs" } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 588f608ffc..6fb3891963 100644 --- a/flake.nix +++ b/flake.nix @@ -1,73 +1,94 @@ { inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; - flake-utils.url = "github:numtide/flake-utils"; }; outputs = - { nixpkgs, flake-utils, ... }: - flake-utils.lib.eachDefaultSystem ( - system: - let - pkgs = nixpkgs.legacyPackages.${system}; - in - { - devShells.default = - with pkgs; + { nixpkgs, ... }: + let + supportedSystems = [ + "aarch64-darwin" + "aarch64-linux" + "x86_64-darwin" + "x86_64-linux" + ]; + + forEachSupportedSystem = + f: + nixpkgs.lib.genAttrs supportedSystems ( + system: let - # only bump toolchain versions here - go = go_1_25; - nodejs = nodejs_24; - python3 = python312; - pnpm = pnpm_10; - - # Platform-specific dependencies - linuxOnlyInputs = lib.optionals pkgs.stdenv.isLinux [ - glibc.static - ]; - - linuxOnlyEnv = lib.optionalAttrs pkgs.stdenv.isLinux { - CFLAGS = "-I${glibc.static.dev}/include"; - LDFLAGS = "-L ${glibc.static}/lib"; + pkgs = import nixpkgs { + inherit system; }; in - pkgs.mkShell ( - { - buildInputs = [ - # generic - git - git-lfs - gnumake - gnused - gnutar - gzip - zip + f { inherit pkgs; } + ); + in + { + devShells = forEachSupportedSystem ( + { pkgs, ... }: + { + default = + let + inherit (pkgs) lib; - # frontend - nodejs - pnpm - cairo - pixman - pkg-config + # only bump toolchain versions here + go = pkgs.go_1_25; + nodejs = pkgs.nodejs_24; + python3 = pkgs.python312; + pnpm = pkgs.pnpm_10; - # linting - python3 - uv + # Platform-specific dependencies + linuxOnlyInputs = lib.optionals pkgs.stdenv.isLinux [ + pkgs.glibc.static + ]; - # backend - go - gofumpt - sqlite - ] - ++ linuxOnlyInputs; + linuxOnlyEnv = lib.optionalAttrs pkgs.stdenv.isLinux { + CFLAGS = "-I${pkgs.glibc.static.dev}/include"; + LDFLAGS = "-L ${pkgs.glibc.static}/lib"; + }; + in + pkgs.mkShell { + packages = + with pkgs; + [ + # generic + git + git-lfs + gnumake + gnused + gnutar + gzip + zip - GO = "${go}/bin/go"; - GOROOT = "${go}/share/go"; + # frontend + nodejs + pnpm + cairo + pixman + pkg-config - TAGS = "sqlite sqlite_unlock_notify"; - STATIC = "true"; - } - // linuxOnlyEnv - ); - } - ); + # linting + python3 + uv + + # backend + go + gofumpt + sqlite + ] + ++ linuxOnlyInputs; + + env = { + GO = "${go}/bin/go"; + GOROOT = "${go}/share/go"; + + TAGS = "sqlite sqlite_unlock_notify"; + STATIC = "true"; + } + // linuxOnlyEnv; + }; + } + ); + }; } diff --git a/go.mod b/go.mod index 64a7dcc708..f784ac2581 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module code.gitea.io/gitea -go 1.25.1 +go 1.26.0 // rfc5280 said: "The serial number is an integer assigned by the CA to each certificate." // But some CAs use negative serial number, just relax the check. related: @@ -9,14 +9,13 @@ godebug x509negativeserial=1 require ( code.gitea.io/actions-proto-go v0.4.1 - code.gitea.io/gitea-vet v0.2.3 - code.gitea.io/sdk/gitea v0.22.0 + code.gitea.io/sdk/gitea v0.23.2 codeberg.org/gusted/mcaptcha v0.0.0-20220723083913-4f3072e1d570 - connectrpc.com/connect v1.18.1 + connectrpc.com/connect v1.19.1 gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed gitea.com/go-chi/cache v0.2.1 gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098 - gitea.com/go-chi/session v0.0.0-20250926004215-636cadd82e15 + gitea.com/go-chi/session v0.0.0-20251124165456-68e0254e989e gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 github.com/42wim/httpsig v1.2.3 @@ -25,24 +24,22 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2 github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 github.com/ProtonMail/go-crypto v1.3.0 - github.com/PuerkitoBio/goquery v1.10.3 + github.com/PuerkitoBio/goquery v1.11.0 github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.0 - github.com/alecthomas/chroma/v2 v2.20.0 - github.com/aws/aws-sdk-go-v2/credentials v1.18.10 - github.com/aws/aws-sdk-go-v2/service/codecommit v1.32.2 + github.com/alecthomas/chroma/v2 v2.23.1 + github.com/aws/aws-sdk-go-v2/credentials v1.19.7 + github.com/aws/aws-sdk-go-v2/service/codecommit v1.33.8 github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb - github.com/blevesearch/bleve/v2 v2.5.3 + github.com/blevesearch/bleve/v2 v2.5.7 github.com/bohde/codel v0.2.0 github.com/buildkite/terminal-to-html/v3 v3.16.8 - github.com/caddyserver/certmagic v0.24.0 - github.com/charmbracelet/git-lfs-transfer v0.2.0 + github.com/caddyserver/certmagic v0.25.1 + github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20251013092601-6327009efd21 github.com/chi-middleware/proxy v1.1.1 github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21 - github.com/djherbis/buffer v1.2.0 - github.com/djherbis/nio/v3 v3.0.1 github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 github.com/dustin/go-humanize v1.0.1 - github.com/editorconfig/editorconfig-core-go/v2 v2.6.3 + github.com/editorconfig/editorconfig-core-go/v2 v2.6.4 github.com/emersion/go-imap v1.2.1 github.com/emirpasic/gods v1.18.1 github.com/ethantkoenig/rupture v1.0.1 @@ -51,54 +48,53 @@ require ( github.com/gliderlabs/ssh v0.3.8 github.com/go-ap/activitypub v0.0.0-20250810115208-cb73b20a1742 github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 - github.com/go-chi/chi/v5 v5.2.3 + github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/go-co-op/gocron v1.37.0 - github.com/go-enry/go-enry/v2 v2.9.2 - github.com/go-git/go-billy/v5 v5.6.2 - github.com/go-git/go-git/v5 v5.16.2 - github.com/go-ldap/ldap/v3 v3.4.11 - github.com/go-redsync/redsync/v4 v4.13.0 + github.com/go-enry/go-enry/v2 v2.9.4 + github.com/go-git/go-billy/v5 v5.7.0 + github.com/go-git/go-git/v5 v5.16.4 + github.com/go-ldap/ldap/v3 v3.4.12 + github.com/go-redsync/redsync/v4 v4.15.0 github.com/go-sql-driver/mysql v1.9.3 github.com/go-webauthn/webauthn v0.13.4 github.com/goccy/go-json v0.10.5 github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85 - github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-github/v74 v74.0.0 github.com/google/licenseclassifier/v2 v2.0.0 - github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 + github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef github.com/google/uuid v1.6.0 github.com/gorilla/feeds v1.2.0 github.com/gorilla/sessions v1.4.0 - github.com/hashicorp/go-version v1.7.0 + github.com/hashicorp/go-version v1.8.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/huandu/xstrings v1.5.0 github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 github.com/jhillyerd/enmime v1.3.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/klauspost/compress v1.18.0 + github.com/klauspost/compress v1.18.3 github.com/klauspost/cpuid/v2 v2.3.0 - github.com/lib/pq v1.10.9 + github.com/lib/pq v1.11.1 github.com/markbates/goth v1.82.0 github.com/mattn/go-isatty v0.0.20 - github.com/mattn/go-sqlite3 v1.14.32 - github.com/meilisearch/meilisearch-go v0.33.2 - github.com/mholt/archives v0.1.3 + github.com/mattn/go-sqlite3 v1.14.33 + github.com/meilisearch/meilisearch-go v0.36.0 + github.com/mholt/archives v0.1.5 github.com/microcosm-cc/bluemonday v1.0.27 - github.com/microsoft/go-mssqldb v1.9.3 - github.com/minio/minio-go/v7 v7.0.95 + github.com/microsoft/go-mssqldb v1.9.6 + github.com/minio/minio-go/v7 v7.0.98 github.com/msteinert/pam v1.2.0 github.com/nektos/act v0.2.63 github.com/niklasfasching/go-org v1.9.1 github.com/olivere/elastic/v7 v7.0.32 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 - github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.5.0 - github.com/prometheus/client_golang v1.23.0 + github.com/prometheus/client_golang v1.23.2 github.com/quasoft/websspi v1.1.2 - github.com/redis/go-redis/v9 v9.12.1 + github.com/redis/go-redis/v9 v9.17.3 github.com/robfig/cron/v3 v3.0.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/sassoftware/go-rpmutils v0.4.0 @@ -109,32 +105,33 @@ require ( github.com/ulikunitz/xz v0.5.15 github.com/urfave/cli-docs/v3 v3.0.0-alpha6 github.com/urfave/cli/v3 v3.4.1 - github.com/wneessen/go-mail v0.7.1 + github.com/wneessen/go-mail v0.7.2 github.com/xeipuuv/gojsonschema v1.2.0 github.com/yohcop/openid-go v1.0.1 - github.com/yuin/goldmark v1.7.13 + github.com/yuin/goldmark v1.7.16 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc github.com/yuin/goldmark-meta v1.1.0 gitlab.com/gitlab-org/api/client-go v0.142.4 - golang.org/x/crypto v0.41.0 - golang.org/x/image v0.30.0 - golang.org/x/net v0.43.0 - golang.org/x/oauth2 v0.30.0 - golang.org/x/sync v0.17.0 - golang.org/x/sys v0.35.0 - golang.org/x/text v0.29.0 - google.golang.org/grpc v1.75.0 - google.golang.org/protobuf v1.36.8 - gopkg.in/ini.v1 v1.67.0 + golang.org/x/crypto v0.47.0 + golang.org/x/image v0.35.0 + golang.org/x/net v0.49.0 + golang.org/x/oauth2 v0.34.0 + golang.org/x/sync v0.19.0 + golang.org/x/sys v0.40.0 + golang.org/x/text v0.33.0 + google.golang.org/grpc v1.78.0 + google.golang.org/protobuf v1.36.11 + gopkg.in/ini.v1 v1.67.1 gopkg.in/yaml.v3 v3.0.1 mvdan.cc/xurls/v2 v2.6.0 strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251 xorm.io/builder v0.3.13 - xorm.io/xorm v1.3.10 + xorm.io/xorm v1.3.11 ) require ( - cloud.google.com/go/compute/metadata v0.8.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + code.gitea.io/gitea-vet v0.2.3 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect @@ -142,24 +139,24 @@ require ( github.com/DataDog/zstd v1.5.7 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect - github.com/STARRY-S/zip v0.2.1 // indirect + github.com/STARRY-S/zip v0.2.3 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect - github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect - github.com/aws/smithy-go v1.23.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.0 // indirect - github.com/blevesearch/bleve_index_api v1.2.9 // indirect + github.com/blevesearch/bleve_index_api v1.2.11 // indirect github.com/blevesearch/geo v0.2.4 // indirect - github.com/blevesearch/go-faiss v1.0.25 // indirect + github.com/blevesearch/go-faiss v1.0.26 // indirect github.com/blevesearch/go-porterstemmer v1.0.3 // indirect github.com/blevesearch/gtreap v0.1.1 // indirect github.com/blevesearch/mmap-go v1.0.4 // indirect - github.com/blevesearch/scorch_segment_api/v2 v2.3.11 // indirect + github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect github.com/blevesearch/segment v0.9.1 // indirect github.com/blevesearch/snowballstem v0.9.0 // indirect github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect @@ -169,14 +166,14 @@ require ( github.com/blevesearch/zapx/v13 v13.4.2 // indirect github.com/blevesearch/zapx/v14 v14.4.2 // indirect github.com/blevesearch/zapx/v15 v15.4.2 // indirect - github.com/blevesearch/zapx/v16 v16.2.4 // indirect + github.com/blevesearch/zapx/v16 v16.2.8 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/bodgit/plumbing v1.3.0 // indirect - github.com/bodgit/sevenzip v1.6.0 // indirect + github.com/bodgit/sevenzip v1.6.1 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/boombuler/barcode v1.1.0 // indirect github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect - github.com/caddyserver/zerossl v0.1.3 // indirect + github.com/caddyserver/zerossl v0.1.4 // indirect github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect @@ -200,11 +197,9 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-webauthn/x v0.1.24 // indirect - github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect @@ -221,6 +216,7 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/libdns/libdns v1.1.1 // indirect github.com/mailru/easyjson v0.9.0 // indirect @@ -228,19 +224,19 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect - github.com/mholt/acmez/v3 v3.1.2 // indirect - github.com/miekg/dns v1.1.68 // indirect + github.com/mholt/acmez/v3 v3.1.4 // indirect + github.com/miekg/dns v1.1.69 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect - github.com/minio/minlz v1.0.0 // indirect + github.com/minio/minlz v1.0.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 // indirect github.com/mschoch/smat v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nwaples/rardecode/v2 v2.1.0 // indirect + github.com/nwaples/rardecode/v2 v2.2.0 // indirect github.com/olekukonko/cat v0.0.0-20250817074551-3280053e4e00 // indirect github.com/olekukonko/errors v1.1.0 // indirect github.com/olekukonko/ll v0.1.0 // indirect @@ -249,19 +245,22 @@ require ( github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pjbgf/sha1cd v0.4.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/rhysd/actionlint v1.7.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/xid v1.6.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect - github.com/sorairolake/lzip-go v0.3.5 // indirect + github.com/sorairolake/lzip-go v0.3.8 // indirect + github.com/spf13/afero v1.15.0 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect - github.com/tinylib/msgp v1.4.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect github.com/unknwon/com v1.0.1 // indirect github.com/valyala/fastjson v1.6.4 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -274,14 +273,16 @@ require ( go.etcd.io/bbolt v1.4.3 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap v1.27.1 // indirect go.uber.org/zap/exp v0.3.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect - golang.org/x/mod v0.27.0 // indirect + golang.org/x/mod v0.31.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.36.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect + golang.org/x/tools v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) @@ -293,13 +294,8 @@ ignore ( replace github.com/jaytaylor/html2text => github.com/Necoro/html2text v0.0.0-20250804200300-7bf1ce1c7347 -replace github.com/hashicorp/go-version => github.com/6543/go-version v1.3.1 - replace github.com/nektos/act => gitea.com/gitea/act v0.261.7-0.20251003180512-ac6e4b751763 -// TODO: the only difference is in `PutObject`: the fork doesn't use `NewVerifyingReader(r, sha256.New(), oid, expectedSize)`, need to figure out why -replace github.com/charmbracelet/git-lfs-transfer => gitea.com/gitea/git-lfs-transfer v0.2.0 - replace git.sr.ht/~mariusor/go-xsd-duration => gitea.com/gitea/go-xsd-duration v0.0.0-20220703122237-02e73435a078 exclude github.com/gofrs/uuid v3.2.0+incompatible @@ -309,3 +305,5 @@ exclude github.com/gofrs/uuid v4.0.0+incompatible exclude github.com/goccy/go-json v0.4.11 exclude github.com/satori/go.uuid v1.2.0 + +tool code.gitea.io/gitea-vet diff --git a/go.sum b/go.sum index 3e9d75c3b8..b10e259c91 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,8 @@ cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6T cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= -cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= @@ -20,12 +20,12 @@ code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLr code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas= code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI= code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= -code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0= -code.gitea.io/sdk/gitea v0.22.0/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= +code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= +code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= codeberg.org/gusted/mcaptcha v0.0.0-20220723083913-4f3072e1d570 h1:TXbikPqa7YRtfU9vS6QJBg77pUvbEb6StRdZO8t1bEY= codeberg.org/gusted/mcaptcha v0.0.0-20220723083913-4f3072e1d570/go.mod h1:IIAjsijsd8q1isWX8MACefDEgTQslQ4stk2AeeTt3kM= -connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= -connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= @@ -33,8 +33,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= gitea.com/gitea/act v0.261.7-0.20251003180512-ac6e4b751763 h1:ohdxegvslDEllZmRNDqpKun6L4Oq81jNdEDtGgHEV2c= gitea.com/gitea/act v0.261.7-0.20251003180512-ac6e4b751763/go.mod h1:Pg5C9kQY1CEA3QjthjhlrqOC/QOT5NyWNjOjRHw23Ok= -gitea.com/gitea/git-lfs-transfer v0.2.0 h1:baHaNoBSRaeq/xKayEXwiDQtlIjps4Ac/Ll4KqLMB40= -gitea.com/gitea/git-lfs-transfer v0.2.0/go.mod h1:UrXUCm3xLQkq15fu7qlXHUMlrhdlXHoi13KH2Dfiits= gitea.com/gitea/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:BAFmdZpRW7zMQZQDClaCWobRj9uL1MR3MzpCVJvc5s4= gitea.com/gitea/go-xsd-duration v0.0.0-20220703122237-02e73435a078/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs= gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed h1:EZZBtilMLSZNWtHHcgq2mt6NSGhJSZBuduAlinMEmso= @@ -43,8 +41,8 @@ gitea.com/go-chi/cache v0.2.1 h1:bfAPkvXlbcZxPCpcmDVCWoHgiBSBmZN/QosnZvEC0+g= gitea.com/go-chi/cache v0.2.1/go.mod h1:Qic0HZ8hOHW62ETGbonpwz8WYypj9NieU9659wFUJ8Q= gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098 h1:p2ki+WK0cIeNQuqjR98IP2KZQKRzJJiV7aTeMAFwaWo= gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098/go.mod h1:LjzIOHlRemuUyO7WR12fmm18VZIlCAaOt9L3yKw40pk= -gitea.com/go-chi/session v0.0.0-20250926004215-636cadd82e15 h1:qFYmz05u/s9664o7+XEgrlHXSPQ4uHO8/ccZGUb1uxA= -gitea.com/go-chi/session v0.0.0-20250926004215-636cadd82e15/go.mod h1:0iEpFKnwO5dG0aF98O4eq6FMsAiXkNBaDIlUOlq4BtM= +gitea.com/go-chi/session v0.0.0-20251124165456-68e0254e989e h1:4bugwPyGMLvblEm3pZ8fZProSPVxE4l0UXF2Kv6IJoY= +gitea.com/go-chi/session v0.0.0-20251124165456-68e0254e989e/go.mod h1:KDvcfMUoXfATPHs2mbMoXFTXT45/FAFAS39waz9tPk0= gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 h1:+wWBi6Qfruqu7xJgjOIrKVQGiLUZdpKYCZewJ4clqhw= gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96/go.mod h1:VyMQP6ue6MKHM8UsOXfNfuMKD0oSAWZdXVcpHIN2yaY= gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 h1:IFT+hup2xejHqdhS7keYWioqfmxdnfblFDTGoOwcZ+o= @@ -55,8 +53,6 @@ github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/42wim/sshsig v0.0.0-20250502153856-5100632e8920 h1:mWAVGlovzUfREJBhm0GwJnDNu21yRrL9QH9NIzAU3rg= github.com/42wim/sshsig v0.0.0-20250502153856-5100632e8920/go.mod h1:zWxcT7BIWOe05xVJL0VMvO/PJ6RpoCux10heb77H6Q8= -github.com/6543/go-version v1.3.1 h1:HvOp+Telns7HWJ2Xo/05YXQSB2bE0WmVgbHqwMPZT4U= -github.com/6543/go-version v1.3.1/go.mod h1:oqFAHCwtLVUTLdhQmVZWYvaHXTdsbB4SY85at64SQEo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.0 h1:ci6Yd6nysBRLEodoziB6ah1+YOzZbZk+NYneoA6q+6E= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.0/go.mod h1:QyVsSSN64v5TGltphKLQ2sQxe4OBQg0J1eKRcVBnfgE= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= @@ -87,26 +83,26 @@ github.com/Necoro/html2text v0.0.0-20250804200300-7bf1ce1c7347 h1:3JhDl+JysaO8nh github.com/Necoro/html2text v0.0.0-20250804200300-7bf1ce1c7347/go.mod h1:2ErI0aycD43Ufr6CFK5lT/NrHGmoZuVbn1nlPThw69o= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= -github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= -github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= +github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= +github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo= github.com/RoaringBitmap/roaring v0.7.1/go.mod h1:jdT9ykXwHFNdJbEtxePexlFYH9LXucApeS0/+/g+p1I= github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0= github.com/RoaringBitmap/roaring/v2 v2.10.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= -github.com/STARRY-S/zip v0.2.1 h1:pWBd4tuSGm3wtpoqRZZ2EAwOmcHK6XFf7bU9qcJXyFg= -github.com/STARRY-S/zip v0.2.1/go.mod h1:xNvshLODWtC4EJ702g7cTYn13G53o1+X9BWnPFpcWV4= +github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= +github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.0 h1:tgjwQrDH5m6jIYB7kac5IQZmfUzQNseac/e3H4VoCNE= github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.0/go.mod h1:1HmmMEVsr+0R1QWahSeMJkjSkq6CYAZu1aIbYSpfJ4o= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= -github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= -github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= -github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= -github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -116,18 +112,18 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuW github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= -github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= -github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg= -github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE= -github.com/aws/aws-sdk-go-v2/service/codecommit v1.32.2 h1:qIySgaSYDLcInLpY0e7HPCi+AVeD/LTsl9EL1b692oA= -github.com/aws/aws-sdk-go-v2/service/codecommit v1.32.2/go.mod h1:SobWM1535Mn1WuThoIVLiLa/C1rRbxbbq5PZW2QFCIM= -github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= -github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/service/codecommit v1.33.8 h1:KxKGfYvkVOe/U/Z4yAd0ZySRJHavuL31VOC+fn7WEAs= +github.com/aws/aws-sdk-go-v2/service/codecommit v1.33.8/go.mod h1:cznnFD3BzYY+NB+4WoQ7SxdTACOsMqGCbQ5QaByPz4w= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -140,15 +136,15 @@ github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6 github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4= github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= github.com/blevesearch/bleve/v2 v2.0.5/go.mod h1:ZjWibgnbRX33c+vBRgla9QhPb4QOjD6fdVJ+R1Bk8LM= -github.com/blevesearch/bleve/v2 v2.5.3 h1:9l1xtKaETv64SZc1jc4Sy0N804laSa/LeMbYddq1YEM= -github.com/blevesearch/bleve/v2 v2.5.3/go.mod h1:Z/e8aWjiq8HeX+nW8qROSxiE0830yQA071dwR3yoMzw= +github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8= +github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA= github.com/blevesearch/bleve_index_api v1.0.0/go.mod h1:fiwKS0xLEm+gBRgv5mumf0dhgFr2mDgZah1pqv1c1M4= -github.com/blevesearch/bleve_index_api v1.2.9 h1:WqD3kvYwnlYLv8sTdH+AF7n/L4v969Cek68+wZnYj4Q= -github.com/blevesearch/bleve_index_api v1.2.9/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= +github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s= +github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk= github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8= -github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U= -github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= +github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI= +github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= @@ -157,8 +153,8 @@ github.com/blevesearch/mmap-go v1.0.2/go.mod h1:ol2qBqYaOUsGdm7aRMRrYGgPvnwLe6Y+ github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc= github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs= github.com/blevesearch/scorch_segment_api/v2 v2.0.1/go.mod h1:lq7yK2jQy1yQjtjTfU931aVqz7pYxEudHaDwOt1tXfU= -github.com/blevesearch/scorch_segment_api/v2 v2.3.11 h1:bYuEgsyGqgU/gy0/Vk6g1eCUqGBs2r+3bRCv+Cnq2kc= -github.com/blevesearch/scorch_segment_api/v2 v2.3.11/go.mod h1:aAWoeQ3DdoZ3Z5138jXVSd1T/klGwvg11z0pSxrJSEk= +github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY= +github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc= github.com/blevesearch/segment v0.9.0/go.mod h1:9PfHYUdQCgHktBgvtUOF4x+pc4/l8rdH0u5spnW85UQ= github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU= github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= @@ -186,15 +182,15 @@ github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXm github.com/blevesearch/zapx/v15 v15.2.0/go.mod h1:MmQceLpWfME4n1WrBFIwplhWmaQbQqLQARpaKUEOs/A= github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k= github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= -github.com/blevesearch/zapx/v16 v16.2.4 h1:tGgfvleXTAkwsD5mEzgM3zCS/7pgocTCnO1oyAUjlww= -github.com/blevesearch/zapx/v16 v16.2.4/go.mod h1:Rti/REtuuMmzwsI8/C/qIzRaEoSK/wiFYw5e5ctUKKs= +github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI= +github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14= github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b/go.mod h1:ac9efd0D1fsDb3EJvhqgXRbFx7bs2wqZ10HQPeU8U/Q= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= -github.com/bodgit/sevenzip v1.6.0 h1:a4R0Wu6/P1o1pP/3VV++aEOcyeBxeO/xE2Y9NSTrr6A= -github.com/bodgit/sevenzip v1.6.0/go.mod h1:zOBh9nJUof7tcrlqJFv1koWRrhz3LbDbUNngkuZxLMc= +github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= +github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/bohde/codel v0.2.0 h1:fzF7ibgKmCfQbOzQCblmQcwzDRmV7WO7VMLm/hDvD3E= @@ -210,15 +206,17 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/buildkite/terminal-to-html/v3 v3.16.8 h1:QN/daUob6cmK8GcdKnwn9+YTlPr1vNj+oeAIiJK6fPc= github.com/buildkite/terminal-to-html/v3 v3.16.8/go.mod h1:+k1KVKROZocrTLsEQ9PEf9A+8+X8uaVV5iO1ZIOwKYM= -github.com/caddyserver/certmagic v0.24.0 h1:EfXTWpxHAUKgDfOj6MHImJN8Jm4AMFfMT6ITuKhrDF0= -github.com/caddyserver/certmagic v0.24.0/go.mod h1:xPT7dC1DuHHnS2yuEQCEyks+b89sUkMENh8dJF+InLE= -github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= -github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/caddyserver/certmagic v0.25.1 h1:4sIKKbOt5pg6+sL7tEwymE1x2bj6CHr80da1CRRIPbY= +github.com/caddyserver/certmagic v0.25.1/go.mod h1:VhyvndxtVton/Fo/wKhRoC46Rbw1fmjvQ3GjHYSQTEY= +github.com/caddyserver/zerossl v0.1.4 h1:CVJOE3MZeFisCERZjkxIcsqIH4fnFdlYWnPYeFtBHRw= +github.com/caddyserver/zerossl v0.1.4/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20251013092601-6327009efd21 h1:2d64+4Jek9vjYwhY93AjbleiVH+AeWvPwPmDi1mfKFQ= +github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20251013092601-6327009efd21/go.mod h1:fNlYtCHWTRC8MofQERZkVUNUWaOvZeTBqHn/amSbKZI= github.com/chi-middleware/proxy v1.1.1 h1:4HaXUp8o2+bhHr1OhVy+VjN0+L7/07JDcn6v7YrTjrQ= github.com/chi-middleware/proxy v1.1.1/go.mod h1:jQwMEJct2tz9VmtCELxvnXoMfa+SOdikvbVJVHv/M+0= github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= @@ -260,11 +258,6 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21 h1:PdsjTl0Cg+ZJgOx/CFV5NNgO1ThTreqdgKYiDCMHJwA= github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21/go.mod h1:xJvkyD6Y2rZapGvPJLYo9dyx1s5dxBEDPa8T3YTuOk0= -github.com/djherbis/buffer v1.1.0/go.mod h1:VwN8VdFkMY0DCALdY8o00d3IZ6Amz/UNVMWcSaJT44o= -github.com/djherbis/buffer v1.2.0 h1:PH5Dd2ss0C7CRRhQCZ2u7MssF+No9ide8Ye71nPHcrQ= -github.com/djherbis/buffer v1.2.0/go.mod h1:fjnebbZjCUpPinBRD+TDwXSOeNQ7fPQWLfGQqiAiUyE= -github.com/djherbis/nio/v3 v3.0.1 h1:6wxhnuppteMa6RHA4L81Dq7ThkZH8SwnDzXDYy95vB4= -github.com/djherbis/nio/v3 v3.0.1/go.mod h1:Ng4h80pbZFMla1yKzm61cF0tqqilXZYrogmWgZxOcmg= github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= @@ -276,8 +269,8 @@ github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdf github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dvyukov/go-fuzz v0.0.0-20210429054444-fca39067bc72/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= -github.com/editorconfig/editorconfig-core-go/v2 v2.6.3 h1:XVUp6qW3BIkmM3/1EkrHpa6bL56APOynfXcZEmIgOhs= -github.com/editorconfig/editorconfig-core-go/v2 v2.6.3/go.mod h1:ThHVc+hqbUsmE1wmK/MASpQEhCleWu1JDJDNhUOMy0c= +github.com/editorconfig/editorconfig-core-go/v2 v2.6.4 h1:CHwUbBVVyKWRX9kt5A/OtwhYUJB32DrFp9xzmjR6cac= +github.com/editorconfig/editorconfig-core-go/v2 v2.6.4/go.mod h1:JWRVKHdVW+dkv6F8p+xGCa6a+TyMrqsFbFkSs/aQkrQ= github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= @@ -321,40 +314,40 @@ github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5La github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi/v5 v5.0.1/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= -github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0= github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY= -github.com/go-enry/go-enry/v2 v2.9.2 h1:giOQAtCgBX08kosrX818DCQJTCNtKwoPBGu0qb6nKTY= -github.com/go-enry/go-enry/v2 v2.9.2/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= +github.com/go-enry/go-enry/v2 v2.9.4 h1:DS4l06/NgMzYjsJ2J52wORo6UsfFDjDCwfAn7w3gG44= +github.com/go-enry/go-enry/v2 v2.9.4/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo= github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4= github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e h1:oRq/fiirun5HqlEWMLIcDmLpIELlG4iGbd0s8iqgPi8= github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= -github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM= +github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM= -github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= +github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= -github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= -github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= +github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= +github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= github.com/go-redis/redis/v7 v7.4.1 h1:PASvf36gyUpr2zdOUS/9Zqc80GbM+9BDyiJSJDDOrTI= github.com/go-redis/redis/v7 v7.4.1/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= -github.com/go-redsync/redsync/v4 v4.13.0 h1:49X6GJfnbLGaIpBBREM/zA4uIMDXKAh1NDkvQ1EkZKA= -github.com/go-redsync/redsync/v4 v4.13.0/go.mod h1:HMW4Q224GZQz6x1Xc7040Yfgacukdzu7ifTDAKiyErQ= +github.com/go-redsync/redsync/v4 v4.15.0 h1:KH/XymuxSV7vyKs6z1Cxxj+N+N18JlPxgXeP6x4JY54= +github.com/go-redsync/redsync/v4 v4.15.0/go.mod h1:qNp+lLs3vkfZbtA/aM/OjlZHfEr5YTAYhRktFPKHC7s= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= @@ -373,10 +366,8 @@ github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7w github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85 h1:UjoPNDAQ5JPCjlxoJd6K8ALZqSDDhk2ymieAZOVaDg0= github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85/go.mod h1:fR6z1Ie6rtF7kl/vBYMfgD5/G5B1blui7z426/sj2DU= -github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= -github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= @@ -407,8 +398,8 @@ github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8l github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws= -github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= +github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8= +github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= @@ -441,8 +432,8 @@ github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OI github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= -github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= -github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno= +github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -481,6 +472,8 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -525,12 +518,14 @@ github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PW github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kljensen/snowball v0.6.0/go.mod h1:27N7E8fVU5H68RlUmnWwZCfxgt4POBJfENGMvNRhldw= @@ -546,8 +541,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI= +github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= @@ -566,30 +561,30 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= -github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/meilisearch/meilisearch-go v0.33.2 h1:YgsQSLYhAkRN2ias6I1KNRTjdYCN5w2uHbLUQ+xgrws= -github.com/meilisearch/meilisearch-go v0.33.2/go.mod h1:6eOPcQ+OAuwXvnONlfSgfgvr7TIAWM/6OdhcVHg8cF0= -github.com/mholt/acmez/v3 v3.1.2 h1:auob8J/0FhmdClQicvJvuDavgd5ezwLBfKuYmynhYzc= -github.com/mholt/acmez/v3 v3.1.2/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= -github.com/mholt/archives v0.1.3 h1:aEAaOtNra78G+TvV5ohmXrJOAzf++dIlYeDW3N9q458= -github.com/mholt/archives v0.1.3/go.mod h1:LUCGp++/IbV/I0Xq4SzcIR6uwgeh2yjnQWamjRQfLTU= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/meilisearch/meilisearch-go v0.36.0 h1:N1etykTektXt5KPcSbhBO0d5Xx5NaKj4pJWEM7WA5dI= +github.com/meilisearch/meilisearch-go v0.36.0/go.mod h1:HBfHzKMxcSbTOvqdfuRA/yf6Vk9IivcwKocWRuW7W78= +github.com/mholt/acmez/v3 v3.1.4 h1:DyzZe/RnAzT3rpZj/2Ii5xZpiEvvYk3cQEN/RmqxwFQ= +github.com/mholt/acmez/v3 v3.1.4/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= +github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= +github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/microsoft/go-mssqldb v1.9.3 h1:hy4p+LDC8LIGvI3JATnLVmBOLMJbmn5X400mr5j0lPs= -github.com/microsoft/go-mssqldb v1.9.3/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA= -github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= -github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw= +github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4= +github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= +github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU= -github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo= -github.com/minio/minlz v1.0.0 h1:Kj7aJZ1//LlTP1DM8Jm7lNKvvJS2m74gyyXXn3+uJWQ= -github.com/minio/minlz v1.0.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= +github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0= +github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= +github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= +github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -608,10 +603,12 @@ github.com/msteinert/pam v1.2.0 h1:mYfjlvN2KYs2Pb9G6nb/1f/nPfAttT/Jee5Sq9r3bGE= github.com/msteinert/pam v1.2.0/go.mod h1:d2n0DCUK8rGecChV3JzvmsDjOY4R7AYbsNxAT+ftQl0= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0= github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48= -github.com/nwaples/rardecode/v2 v2.1.0 h1:JQl9ZoBPDy+nIZGb1mx8+anfHp/LV3NE2MjMiv0ct/U= -github.com/nwaples/rardecode/v2 v2.1.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= +github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= @@ -658,24 +655,26 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= -github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= -github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/quasoft/websspi v1.1.2 h1:/mA4w0LxWlE3novvsoEL6BBA1WnjJATbjkh1kFrTidw= github.com/quasoft/websspi v1.1.2/go.mod h1:HmVdl939dQ0WIXZhyik+ARdI03M6bQzaSEKcgpFmewk= github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= -github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= -github.com/redis/rueidis v1.0.19 h1:s65oWtotzlIFN8eMPhyYwxlwLR1lUdhza2KtWprKYSo= -github.com/redis/rueidis v1.0.19/go.mod h1:8B+r5wdnjwK3lTFml5VtxjzGOQAC+5UmujoD12pDrEo= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4= +github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/redis/rueidis v1.0.69 h1:WlUefRhuDekji5LsD387ys3UCJtSFeBVf0e5yI0B8b4= +github.com/redis/rueidis v1.0.69/go.mod h1:Lkhr2QTgcoYBhxARU7kJRO8SyVlgUuEkcJO1Y8MCluA= +github.com/redis/rueidis/rueidiscompat v1.0.69 h1:IWVYY9lXdjNO3do2VpJT7aDFi8zbCUuQxZB6E2Grahs= +github.com/redis/rueidis/rueidiscompat v1.0.69/go.mod h1:iC4Y8DoN0Uth0Uezg9e2trvNRC7QAgGeuP2OPLb5ccI= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rhysd/actionlint v1.7.7 h1:0KgkoNTrYY7vmOCs9BW2AHxLvvpoY9nEUzgBHiPUr0k= github.com/rhysd/actionlint v1.7.7/go.mod h1:AE6I6vJEkNaIfWqC2GNE5spIJNhxf8NCtLEKU4NnUXg= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -703,6 +702,8 @@ github.com/serenize/snaker v0.0.0-20171204205717-a683aaf2d516/go.mod h1:Yow6lPLS github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= @@ -714,9 +715,11 @@ github.com/smartystreets/assertions v1.1.1/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYl github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337 h1:WN9BUFbdyOsSH/XohnWpXOlq9NBD5sGAB2FciQMUEe8= github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/sorairolake/lzip-go v0.3.5 h1:ms5Xri9o1JBIWvOFAorYtUNik6HI3HgBTkISiqu0Cwg= -github.com/sorairolake/lzip-go v0.3.5/go.mod h1:N0KYq5iWrMXI0ZEXKXaS9hCyOjZUQdBDEIbXfoUwbdk= +github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= +github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= @@ -729,6 +732,7 @@ github.com/steveyen/gtreap v0.1.0/go.mod h1:kl/5J7XbrOmlIbYIXdRHDDE5QxHqpk0cmkT7 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -748,8 +752,8 @@ github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKN github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= -github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8= -github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tstranex/u2f v1.0.0 h1:HhJkSzDDlVSVIVt7pDJwCHQj67k7A5EeBgPmeD+pVsQ= github.com/tstranex/u2f v1.0.0/go.mod h1:eahSLaqAS0zsIEv80+vXT7WanXs7MQQDg3j3wGBSayo= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= @@ -765,8 +769,8 @@ github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZ github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= -github.com/wneessen/go-mail v0.7.1 h1:rvy63sp14N06/kdGqCYwW8Na5gDCXjTQM1E7So4PuKk= -github.com/wneessen/go-mail v0.7.1/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= +github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8= +github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= @@ -789,8 +793,8 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= -github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= @@ -817,10 +821,14 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -837,8 +845,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -851,8 +859,8 @@ golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11 golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= -golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= +golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I= +golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -875,8 +883,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -905,15 +913,15 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -929,8 +937,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -972,8 +980,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -984,8 +992,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -999,8 +1007,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= @@ -1036,8 +1044,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1068,8 +1076,8 @@ google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvx google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1077,16 +1085,16 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= -google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -1094,8 +1102,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= +gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= @@ -1122,20 +1130,20 @@ modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= -modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0= -modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk= -modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE= modernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A= -modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= -modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= -modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg= -modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= pgregory.net/rapid v0.4.2 h1:lsi9jhvZTYvzVpeG93WWgimPRmiJQfGFRNTEZh1dtY0= @@ -1147,5 +1155,5 @@ strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251 h1:mUcz5b3 strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251/go.mod h1:FJGmPh3vz9jSos1L/F91iAgnC/aejc0wIIrF2ZwJxdY= xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo= xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= -xorm.io/xorm v1.3.10 h1:yR83hTT4mKIPyA/lvWFTzS35xjLwkiYnwdw0Qupeh0o= -xorm.io/xorm v1.3.10/go.mod h1:Lo7hmsFF0F0GbDE7ubX5ZKa+eCf0eCuiJAUG3oI5cxQ= +xorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI= +xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q= diff --git a/main.go b/main.go index 2c25bac4e3..bc2121b1e7 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,7 @@ func main() { } app := cmd.NewMainApp(cmd.AppVersion{Version: Version, Extra: formatBuiltWith()}) _ = cmd.RunMainApp(app, os.Args...) // all errors should have been handled by the RunMainApp + // flush the queued logs before exiting, it is a MUST, otherwise there will be log loss log.GetManager().Close() } diff --git a/models/actions/main_test.go b/models/actions/main_test.go index 5d5089e3bb..4af483813a 100644 --- a/models/actions/main_test.go +++ b/models/actions/main_test.go @@ -13,6 +13,8 @@ func TestMain(m *testing.M) { unittest.MainTest(m, &unittest.TestOptions{ FixtureFiles: []string{ "action_runner_token.yml", + "action_run.yml", + "repository.yml", }, }) } diff --git a/models/actions/run.go b/models/actions/run.go index f1d85bbcd9..be332d6857 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -16,13 +16,13 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" - "github.com/nektos/act/pkg/jobparser" "xorm.io/builder" ) @@ -30,7 +30,7 @@ import ( type ActionRun struct { ID int64 Title string - RepoID int64 `xorm:"index unique(repo_index)"` + RepoID int64 `xorm:"unique(repo_index) index(repo_concurrency)"` Repo *repo_model.Repository `xorm:"-"` OwnerID int64 `xorm:"index"` WorkflowID string `xorm:"index"` // the name of workflow file @@ -49,6 +49,9 @@ type ActionRun struct { TriggerEvent string // the trigger event defined in the `on` configuration of the triggered workflow Status Status `xorm:"index"` Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed + RawConcurrency string // raw concurrency + ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` + ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` // Started and Stopped is used for recording last run time, if rerun happened, they will be reset to 0 Started timeutil.TimeStamp Stopped timeutil.TimeStamp @@ -190,9 +193,11 @@ func (run *ActionRun) IsSchedule() bool { return run.ScheduleID > 0 } -func updateRepoRunsNumbers(ctx context.Context, repo *repo_model.Repository) error { +// UpdateRepoRunsNumbers updates the number of runs and closed runs of a repository. +func UpdateRepoRunsNumbers(ctx context.Context, repo *repo_model.Repository) error { _, err := db.GetEngine(ctx).ID(repo.ID). NoAutoTime(). + Cols("num_action_runs", "num_closed_action_runs"). SetExpr("num_action_runs", builder.Select("count(*)").From("action_run"). Where(builder.Eq{"repo_id": repo.ID}), @@ -247,116 +252,62 @@ func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID strin return cancelledJobs, err } - // Iterate over each job and attempt to cancel it. - for _, job := range jobs { - // Skip jobs that are already in a terminal state (completed, cancelled, etc.). - status := job.Status - if status.IsDone() { - continue - } - - // If the job has no associated task (probably an error), set its status to 'Cancelled' and stop it. - if job.TaskID == 0 { - job.Status = StatusCancelled - job.Stopped = timeutil.TimeStampNow() - - // Update the job's status and stopped time in the database. - n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped") - if err != nil { - return cancelledJobs, err - } - - // If the update affected 0 rows, it means the job has changed in the meantime, so we need to try again. - if n == 0 { - return cancelledJobs, errors.New("job has changed, try again") - } - - cancelledJobs = append(cancelledJobs, job) - // Continue with the next job. - continue - } - - // If the job has an associated task, try to stop the task, effectively cancelling the job. - if err := StopTask(ctx, job.TaskID, StatusCancelled); err != nil { - return cancelledJobs, err - } - cancelledJobs = append(cancelledJobs, job) + cjs, err := CancelJobs(ctx, jobs) + if err != nil { + return cancelledJobs, err } + cancelledJobs = append(cancelledJobs, cjs...) } // Return nil to indicate successful cancellation of all running and waiting jobs. return cancelledJobs, nil } -// InsertRun inserts a run -// The title will be cut off at 255 characters if it's longer than 255 characters. -func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWorkflow) error { - return db.WithTx(ctx, func(ctx context.Context) error { - index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID) - if err != nil { - return err - } - run.Index = index - run.Title = util.EllipsisDisplayString(run.Title, 255) - - if err := db.Insert(ctx, run); err != nil { - return err +func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) { + cancelledJobs := make([]*ActionRunJob, 0, len(jobs)) + // Iterate over each job and attempt to cancel it. + for _, job := range jobs { + // Skip jobs that are already in a terminal state (completed, cancelled, etc.). + status := job.Status + if status.IsDone() { + continue } - if run.Repo == nil { - repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID) + // If the job has no associated task (probably an error), set its status to 'Cancelled' and stop it. + if job.TaskID == 0 { + job.Status = StatusCancelled + job.Stopped = timeutil.TimeStampNow() + + // Update the job's status and stopped time in the database. + n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped") if err != nil { - return err + return cancelledJobs, err } - run.Repo = repo + + // If the update affected 0 rows, it means the job has changed in the meantime + if n == 0 { + log.Error("Failed to cancel job %d because it has changed", job.ID) + continue + } + + cancelledJobs = append(cancelledJobs, job) + // Continue with the next job. + continue } - if err := updateRepoRunsNumbers(ctx, run.Repo); err != nil { - return err + // If the job has an associated task, try to stop the task, effectively cancelling the job. + if err := StopTask(ctx, job.TaskID, StatusCancelled); err != nil { + return cancelledJobs, err } + updatedJob, err := GetRunJobByID(ctx, job.ID) + if err != nil { + return cancelledJobs, fmt.Errorf("get job: %w", err) + } + cancelledJobs = append(cancelledJobs, updatedJob) + } - runJobs := make([]*ActionRunJob, 0, len(jobs)) - var hasWaiting bool - for _, v := range jobs { - id, job := v.Job() - needs := job.Needs() - if err := v.SetJob(id, job.EraseNeeds()); err != nil { - return err - } - payload, _ := v.Marshal() - status := StatusWaiting - if len(needs) > 0 || run.NeedApproval { - status = StatusBlocked - } else { - hasWaiting = true - } - job.Name = util.EllipsisDisplayString(job.Name, 255) - runJobs = append(runJobs, &ActionRunJob{ - RunID: run.ID, - RepoID: run.RepoID, - OwnerID: run.OwnerID, - CommitSHA: run.CommitSHA, - IsForkPullRequest: run.IsForkPullRequest, - Name: job.Name, - WorkflowPayload: payload, - JobID: id, - Needs: needs, - RunsOn: job.RunsOn(), - Status: status, - }) - } - if err := db.Insert(ctx, runJobs); err != nil { - return err - } - - // if there is a job in the waiting status, increase tasks version. - if hasWaiting { - if err := IncreaseTaskVersion(ctx, run.OwnerID, run.RepoID); err != nil { - return err - } - } - return nil - }) + // Return nil to indicate successful cancellation of all running and waiting jobs. + return cancelledJobs, nil } func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, error) { @@ -441,7 +392,7 @@ func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error { if err = run.LoadRepo(ctx); err != nil { return err } - if err := updateRepoRunsNumbers(ctx, run.Repo); err != nil { + if err := UpdateRepoRunsNumbers(ctx, run.Repo); err != nil { return err } } @@ -450,3 +401,59 @@ func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error { } type ActionRunIndex db.ResourceIndex + +func GetConcurrentRunsAndJobs(ctx context.Context, repoID int64, concurrencyGroup string, status []Status) ([]*ActionRun, []*ActionRunJob, error) { + runs, err := db.Find[ActionRun](ctx, &FindRunOptions{ + RepoID: repoID, + ConcurrencyGroup: concurrencyGroup, + Status: status, + }) + if err != nil { + return nil, nil, fmt.Errorf("find runs: %w", err) + } + + jobs, err := db.Find[ActionRunJob](ctx, &FindRunJobOptions{ + RepoID: repoID, + ConcurrencyGroup: concurrencyGroup, + Statuses: status, + }) + if err != nil { + return nil, nil, fmt.Errorf("find jobs: %w", err) + } + + return runs, jobs, nil +} + +func CancelPreviousJobsByRunConcurrency(ctx context.Context, actionRun *ActionRun) ([]*ActionRunJob, error) { + if actionRun.ConcurrencyGroup == "" { + return nil, nil + } + + var jobsToCancel []*ActionRunJob + + statusFindOption := []Status{StatusWaiting, StatusBlocked} + if actionRun.ConcurrencyCancel { + statusFindOption = append(statusFindOption, StatusRunning) + } + runs, jobs, err := GetConcurrentRunsAndJobs(ctx, actionRun.RepoID, actionRun.ConcurrencyGroup, statusFindOption) + if err != nil { + return nil, fmt.Errorf("find concurrent runs and jobs: %w", err) + } + jobsToCancel = append(jobsToCancel, jobs...) + + // cancel runs in the same concurrency group + for _, run := range runs { + if run.ID == actionRun.ID { + continue + } + jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{ + RunID: run.ID, + }) + if err != nil { + return nil, fmt.Errorf("find run %d jobs: %w", run.ID, err) + } + jobsToCancel = append(jobsToCancel, jobs...) + } + + return CancelJobs(ctx, jobsToCancel) +} diff --git a/models/actions/run_job.go b/models/actions/run_job.go index e7fa21270c..f72a7040e3 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" + "github.com/nektos/act/pkg/jobparser" "xorm.io/builder" ) @@ -22,23 +23,38 @@ type ActionRunJob struct { ID int64 RunID int64 `xorm:"index"` Run *ActionRun `xorm:"-"` - RepoID int64 `xorm:"index"` + RepoID int64 `xorm:"index(repo_concurrency)"` Repo *repo_model.Repository `xorm:"-"` OwnerID int64 `xorm:"index"` CommitSHA string `xorm:"index"` IsForkPullRequest bool Name string `xorm:"VARCHAR(255)"` Attempt int64 - WorkflowPayload []byte - JobID string `xorm:"VARCHAR(255)"` // job id in workflow, not job's id - Needs []string `xorm:"JSON TEXT"` - RunsOn []string `xorm:"JSON TEXT"` - TaskID int64 // the latest task of the job - Status Status `xorm:"index"` - Started timeutil.TimeStamp - Stopped timeutil.TimeStamp - Created timeutil.TimeStamp `xorm:"created"` - Updated timeutil.TimeStamp `xorm:"updated index"` + + // WorkflowPayload is act/jobparser.SingleWorkflow for act/jobparser.Parse + // it should contain exactly one job with global workflow fields for this model + WorkflowPayload []byte + + JobID string `xorm:"VARCHAR(255)"` // job id in workflow, not job's id + Needs []string `xorm:"JSON TEXT"` + RunsOn []string `xorm:"JSON TEXT"` + TaskID int64 // the latest task of the job + Status Status `xorm:"index"` + + RawConcurrency string // raw concurrency from job YAML's "concurrency" section + + // IsConcurrencyEvaluated is only valid/needed when this job's RawConcurrency is not empty. + // If RawConcurrency can't be evaluated (e.g. depend on other job's outputs or have errors), this field will be false. + // If RawConcurrency has been successfully evaluated, this field will be true, ConcurrencyGroup and ConcurrencyCancel are also set. + IsConcurrencyEvaluated bool + + ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` // evaluated concurrency.group + ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` // evaluated concurrency.cancel-in-progress + + Started timeutil.TimeStamp + Stopped timeutil.TimeStamp + Created timeutil.TimeStamp `xorm:"created"` + Updated timeutil.TimeStamp `xorm:"updated index"` } func init() { @@ -84,6 +100,24 @@ func (job *ActionRunJob) LoadAttributes(ctx context.Context) error { return job.Run.LoadAttributes(ctx) } +// ParseJob parses the job structure from the ActionRunJob.WorkflowPayload +func (job *ActionRunJob) ParseJob() (*jobparser.Job, error) { + // job.WorkflowPayload is a SingleWorkflow created from an ActionRun's workflow, which exactly contains this job's YAML definition. + // Ideally it shouldn't be called "Workflow", it is just a job with global workflow fields + trigger + parsedWorkflows, err := jobparser.Parse(job.WorkflowPayload) + if err != nil { + return nil, fmt.Errorf("job %d single workflow: unable to parse: %w", job.ID, err) + } else if len(parsedWorkflows) != 1 { + return nil, fmt.Errorf("job %d single workflow: not single workflow", job.ID) + } + _, workflowJob := parsedWorkflows[0].Job() + if workflowJob == nil { + // it shouldn't happen, and since the callers don't check nil, so return an error instead of nil + return nil, util.ErrorWrap(util.ErrNotExist, "job %d single workflow: payload doesn't contain a job", job.ID) + } + return workflowJob, nil +} + func GetRunJobByID(ctx context.Context, id int64) (*ActionRunJob, error) { var job ActionRunJob has, err := db.GetEngine(ctx).Where("id=?", id).Get(&job) @@ -125,7 +159,7 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col return affected, nil } - if affected != 0 && slices.Contains(cols, "status") && job.Status.IsWaiting() { + if slices.Contains(cols, "status") && job.Status.IsWaiting() { // if the status of job changes to waiting again, increase tasks version. if err := IncreaseTaskVersion(ctx, job.OwnerID, job.RepoID); err != nil { return 0, err @@ -197,3 +231,39 @@ func AggregateJobStatus(jobs []*ActionRunJob) Status { return StatusUnknown // it shouldn't happen } } + +func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob) (jobsToCancel []*ActionRunJob, _ error) { + if job.RawConcurrency == "" { + return nil, nil + } + if !job.IsConcurrencyEvaluated { + return nil, nil + } + if job.ConcurrencyGroup == "" { + return nil, nil + } + + statusFindOption := []Status{StatusWaiting, StatusBlocked} + if job.ConcurrencyCancel { + statusFindOption = append(statusFindOption, StatusRunning) + } + runs, jobs, err := GetConcurrentRunsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, statusFindOption) + if err != nil { + return nil, fmt.Errorf("find concurrent runs and jobs: %w", err) + } + jobs = slices.DeleteFunc(jobs, func(j *ActionRunJob) bool { return j.ID == job.ID }) + jobsToCancel = append(jobsToCancel, jobs...) + + // cancel runs in the same concurrency group + for _, run := range runs { + jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{ + RunID: run.ID, + }) + if err != nil { + return nil, fmt.Errorf("find run %d jobs: %w", run.ID, err) + } + jobsToCancel = append(jobsToCancel, jobs...) + } + + return CancelJobs(ctx, jobsToCancel) +} diff --git a/models/actions/run_job_list.go b/models/actions/run_job_list.go index 5f7bb62878..10f76d3641 100644 --- a/models/actions/run_job_list.go +++ b/models/actions/run_job_list.go @@ -69,12 +69,13 @@ func (jobs ActionJobList) LoadAttributes(ctx context.Context, withRepo bool) err type FindRunJobOptions struct { db.ListOptions - RunID int64 - RepoID int64 - OwnerID int64 - CommitSHA string - Statuses []Status - UpdatedBefore timeutil.TimeStamp + RunID int64 + RepoID int64 + OwnerID int64 + CommitSHA string + Statuses []Status + UpdatedBefore timeutil.TimeStamp + ConcurrencyGroup string } func (opts FindRunJobOptions) ToConds() builder.Cond { @@ -94,6 +95,12 @@ func (opts FindRunJobOptions) ToConds() builder.Cond { if opts.UpdatedBefore > 0 { cond = cond.And(builder.Lt{"`action_run_job`.updated": opts.UpdatedBefore}) } + if opts.ConcurrencyGroup != "" { + if opts.RepoID == 0 { + panic("Invalid FindRunJobOptions: repo_id is required") + } + cond = cond.And(builder.Eq{"`action_run_job`.concurrency_group": opts.ConcurrencyGroup}) + } return cond } diff --git a/models/actions/run_list.go b/models/actions/run_list.go index 12c55e538e..2628c4712f 100644 --- a/models/actions/run_list.go +++ b/models/actions/run_list.go @@ -64,15 +64,16 @@ func (runs RunList) LoadRepos(ctx context.Context) error { type FindRunOptions struct { db.ListOptions - RepoID int64 - OwnerID int64 - WorkflowID string - Ref string // the commit/tag/… that caused this workflow - TriggerUserID int64 - TriggerEvent webhook_module.HookEventType - Approved bool // not util.OptionalBool, it works only when it's true - Status []Status - CommitSHA string + RepoID int64 + OwnerID int64 + WorkflowID string + Ref string // the commit/tag/… that caused this workflow + TriggerUserID int64 + TriggerEvent webhook_module.HookEventType + Approved bool // not util.OptionalBool, it works only when it's true + Status []Status + ConcurrencyGroup string + CommitSHA string } func (opts FindRunOptions) ToConds() builder.Cond { @@ -101,6 +102,12 @@ func (opts FindRunOptions) ToConds() builder.Cond { if opts.CommitSHA != "" { cond = cond.And(builder.Eq{"`action_run`.commit_sha": opts.CommitSHA}) } + if len(opts.ConcurrencyGroup) > 0 { + if opts.RepoID == 0 { + panic("Invalid FindRunOptions: repo_id is required") + } + cond = cond.And(builder.Eq{"`action_run`.concurrency_group": opts.ConcurrencyGroup}) + } return cond } diff --git a/models/actions/run_test.go b/models/actions/run_test.go new file mode 100644 index 0000000000..bd2b92f4f6 --- /dev/null +++ b/models/actions/run_test.go @@ -0,0 +1,35 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" +) + +func TestUpdateRepoRunsNumbers(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + // update the number to a wrong one, the original is 3 + _, err := db.GetEngine(t.Context()).ID(4).Cols("num_closed_action_runs").Update(&repo_model.Repository{ + NumClosedActionRuns: 2, + }) + assert.NoError(t, err) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + assert.Equal(t, 4, repo.NumActionRuns) + assert.Equal(t, 2, repo.NumClosedActionRuns) + + // now update will correct them, only num_actionr_runs and num_closed_action_runs should be updated + err = UpdateRepoRunsNumbers(t.Context(), repo) + assert.NoError(t, err) + repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + assert.Equal(t, 5, repo.NumActionRuns) + assert.Equal(t, 3, repo.NumClosedActionRuns) +} diff --git a/models/actions/runner.go b/models/actions/runner.go index f313f9465e..738e74c9f8 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -14,6 +14,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/shared/types" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" @@ -173,6 +174,13 @@ func (r *ActionRunner) GenerateToken() (err error) { return err } +// CanMatchLabels checks whether the runner's labels can match a job's "runs-on" +// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idruns-on +func (r *ActionRunner) CanMatchLabels(jobRunsOn []string) bool { + runnerLabelSet := container.SetOf(r.AgentLabels...) + return runnerLabelSet.Contains(jobRunsOn...) // match all labels +} + func init() { db.RegisterModel(&ActionRunner{}) } diff --git a/models/actions/task.go b/models/actions/task.go index c1306a8418..8b4ecf28f7 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -13,7 +13,6 @@ import ( auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unit" - "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" @@ -21,7 +20,6 @@ import ( runnerv1 "code.gitea.io/actions-proto-go/runner/v1" lru "github.com/hashicorp/golang-lru/v2" - "github.com/nektos/act/pkg/jobparser" "google.golang.org/protobuf/types/known/timestamppb" "xorm.io/builder" ) @@ -246,7 +244,7 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask var job *ActionRunJob log.Trace("runner labels: %v", runner.AgentLabels) for _, v := range jobs { - if isSubset(runner.AgentLabels, v.RunsOn) { + if runner.CanMatchLabels(v.RunsOn) { job = v break } @@ -278,13 +276,10 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask return nil, false, err } - parsedWorkflows, err := jobparser.Parse(job.WorkflowPayload) + workflowJob, err := job.ParseJob() if err != nil { - return nil, false, fmt.Errorf("parse workflow of job %d: %w", job.ID, err) - } else if len(parsedWorkflows) != 1 { - return nil, false, fmt.Errorf("workflow of job %d: not single workflow", job.ID) + return nil, false, fmt.Errorf("load job %d: %w", job.ID, err) } - _, workflowJob := parsedWorkflows[0].Job() if _, err := e.Insert(task); err != nil { return nil, false, err @@ -479,20 +474,6 @@ func FindOldTasksToExpire(ctx context.Context, olderThan timeutil.TimeStamp, lim Find(&tasks) } -func isSubset(set, subset []string) bool { - m := make(container.Set[string], len(set)) - for _, v := range set { - m.Add(v) - } - - for _, v := range subset { - if !m.Contains(v) { - return false - } - } - return true -} - func convertTimestamp(timestamp *timestamppb.Timestamp) timeutil.TimeStamp { if timestamp.GetSeconds() == 0 && timestamp.GetNanos() == 0 { return timeutil.TimeStamp(0) diff --git a/models/activities/notification.go b/models/activities/notification.go index b482e6020a..8a830c5aa2 100644 --- a/models/activities/notification.go +++ b/models/activities/notification.go @@ -386,7 +386,7 @@ func SetNotificationStatus(ctx context.Context, notificationID int64, user *user notification.Status = status - _, err = db.GetEngine(ctx).ID(notificationID).Update(notification) + _, err = db.GetEngine(ctx).ID(notificationID).Cols("status").Update(notification) return notification, err } diff --git a/models/admin/task.go b/models/admin/task.go index 0541a8ec78..5d2b9bbff6 100644 --- a/models/admin/task.go +++ b/models/admin/task.go @@ -11,6 +11,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/migration" "code.gitea.io/gitea/modules/secret" "code.gitea.io/gitea/modules/setting" @@ -123,17 +124,17 @@ func (task *Task) MigrateConfig() (*migration.MigrateOptions, error) { // decrypt credentials if opts.CloneAddrEncrypted != "" { if opts.CloneAddr, err = secret.DecryptSecret(setting.SecretKey, opts.CloneAddrEncrypted); err != nil { - return nil, err + log.Error("Unable to decrypt CloneAddr, maybe SECRET_KEY is wrong: %v", err) } } if opts.AuthPasswordEncrypted != "" { if opts.AuthPassword, err = secret.DecryptSecret(setting.SecretKey, opts.AuthPasswordEncrypted); err != nil { - return nil, err + log.Error("Unable to decrypt AuthPassword, maybe SECRET_KEY is wrong: %v", err) } } if opts.AuthTokenEncrypted != "" { if opts.AuthToken, err = secret.DecryptSecret(setting.SecretKey, opts.AuthTokenEncrypted); err != nil { - return nil, err + log.Error("Unable to decrypt AuthToken, maybe SECRET_KEY is wrong: %v", err) } } diff --git a/models/asymkey/gpg_key_test.go b/models/asymkey/gpg_key_test.go index 4621337f11..e6656cb70d 100644 --- a/models/asymkey/gpg_key_test.go +++ b/models/asymkey/gpg_key_test.go @@ -11,7 +11,6 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/util" "github.com/ProtonMail/go-crypto/openpgp" "github.com/ProtonMail/go-crypto/openpgp/packet" @@ -398,7 +397,7 @@ epiDVQ== func TestTryGetKeyIDFromSignature(t *testing.T) { assert.Empty(t, TryGetKeyIDFromSignature(&packet.Signature{})) assert.Equal(t, "038D1A3EADDBEA9C", TryGetKeyIDFromSignature(&packet.Signature{ - IssuerKeyId: util.ToPointer(uint64(0x38D1A3EADDBEA9C)), + IssuerKeyId: new(uint64(0x38D1A3EADDBEA9C)), })) assert.Equal(t, "038D1A3EADDBEA9C", TryGetKeyIDFromSignature(&packet.Signature{ IssuerFingerprint: []uint8{0xb, 0x23, 0x24, 0xc7, 0xe6, 0xfe, 0x4f, 0x3a, 0x6, 0x26, 0xc1, 0x21, 0x3, 0x8d, 0x1a, 0x3e, 0xad, 0xdb, 0xea, 0x9c}, @@ -419,7 +418,7 @@ func TestParseGPGKey(t *testing.T) { // then revoke the key for _, id := range e.Identities { - id.Revocations = append(id.Revocations, &packet.Signature{RevocationReason: util.ToPointer(packet.KeyCompromised)}) + id.Revocations = append(id.Revocations, &packet.Signature{RevocationReason: new(packet.KeyCompromised)}) } k, err = parseGPGKey(t.Context(), 1, e, true) require.NoError(t, err) diff --git a/models/asymkey/gpg_key_verify.go b/models/asymkey/gpg_key_verify.go index 55c64973b4..5df0265c88 100644 --- a/models/asymkey/gpg_key_verify.go +++ b/models/asymkey/gpg_key_verify.go @@ -78,7 +78,7 @@ func VerifyGPGKey(ctx context.Context, ownerID int64, keyID, token, signature st } key.Verified = true - if _, err := db.GetEngine(ctx).ID(key.ID).SetExpr("verified", true).Update(new(GPGKey)); err != nil { + if _, err := db.GetEngine(ctx).ID(key.ID).Cols("verified").Update(key); err != nil { return "", err } diff --git a/models/asymkey/ssh_key.go b/models/asymkey/ssh_key.go index 87205f0651..98784b36bd 100644 --- a/models/asymkey/ssh_key.go +++ b/models/asymkey/ssh_key.go @@ -67,13 +67,6 @@ func (key *PublicKey) OmitEmail() string { return strings.Join(strings.Split(key.Content, " ")[:2], " ") } -// AuthorizedString returns formatted public key string for authorized_keys file. -// -// TODO: Consider dropping this function -func (key *PublicKey) AuthorizedString() string { - return AuthorizedStringForKey(key) -} - func addKey(ctx context.Context, key *PublicKey) (err error) { if len(key.Fingerprint) == 0 { key.Fingerprint, err = CalcFingerprint(key.Content) @@ -91,7 +84,7 @@ func addKey(ctx context.Context, key *PublicKey) (err error) { } // AddPublicKey adds new public key to database and authorized_keys file. -func AddPublicKey(ctx context.Context, ownerID int64, name, content string, authSourceID int64) (*PublicKey, error) { +func AddPublicKey(ctx context.Context, ownerID int64, name, content string, authSourceID int64, verified bool) (*PublicKey, error) { log.Trace(content) fingerprint, err := CalcFingerprint(content) @@ -122,6 +115,7 @@ func AddPublicKey(ctx context.Context, ownerID int64, name, content string, auth Mode: perm.AccessModeWrite, Type: KeyTypeUser, LoginSourceID: authSourceID, + Verified: verified, } if err = addKey(ctx, key); err != nil { return nil, fmt.Errorf("addKey: %w", err) @@ -305,7 +299,7 @@ func deleteKeysMarkedForDeletion(ctx context.Context, keys []string) (bool, erro } // AddPublicKeysBySource add a users public keys. Returns true if there are changes. -func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) bool { +func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string, verified bool) bool { var sshKeysNeedUpdate bool for _, sshKey := range sshPublicKeys { var err error @@ -324,7 +318,7 @@ func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.So marshalled = marshalled[:len(marshalled)-1] sshKeyName := fmt.Sprintf("%s-%s", s.Name, ssh.FingerprintSHA256(out)) - if _, err := AddPublicKey(ctx, usr.ID, sshKeyName, marshalled, s.ID); err != nil { + if _, err := AddPublicKey(ctx, usr.ID, sshKeyName, marshalled, s.ID, verified); err != nil { if IsErrKeyAlreadyExist(err) { log.Trace("AddPublicKeysBySource[%s]: Public SSH Key %s already exists for user", sshKeyName, usr.Name) } else { @@ -343,7 +337,7 @@ func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.So } // SynchronizePublicKeys updates a user's public keys. Returns true if there are changes. -func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) bool { +func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string, verified bool) bool { var sshKeysNeedUpdate bool log.Trace("synchronizePublicKeys[%s]: Handling Public SSH Key synchronization for user %s", s.Name, usr.Name) @@ -388,7 +382,7 @@ func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.So newKeys = append(newKeys, key) } } - if AddPublicKeysBySource(ctx, usr, s, newKeys) { + if AddPublicKeysBySource(ctx, usr, s, newKeys, verified) { sshKeysNeedUpdate = true } diff --git a/models/asymkey/ssh_key_authorized_keys.go b/models/asymkey/ssh_key_authorized_keys.go index 2e4cd62e5c..cb2943e31d 100644 --- a/models/asymkey/ssh_key_authorized_keys.go +++ b/models/asymkey/ssh_key_authorized_keys.go @@ -10,6 +10,7 @@ import ( "io" "os" "path/filepath" + "regexp" "strings" "sync" @@ -17,29 +18,13 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" + + "golang.org/x/crypto/ssh" ) -// _____ __ .__ .__ .___ -// / _ \ __ ___/ |_| |__ ___________|__|_______ ____ __| _/ -// / /_\ \| | \ __\ | \ / _ \_ __ \ \___ // __ \ / __ | -// / | \ | /| | | Y ( <_> ) | \/ |/ /\ ___// /_/ | -// \____|__ /____/ |__| |___| /\____/|__| |__/_____ \\___ >____ | -// \/ \/ \/ \/ \/ -// ____ __. -// | |/ _|____ ___.__. ______ -// | <_/ __ < | |/ ___/ -// | | \ ___/\___ |\___ \ -// |____|__ \___ > ____/____ > -// \/ \/\/ \/ -// -// This file contains functions for creating authorized_keys files -// -// There is a dependence on the database within RegeneratePublicKeys however most of these functions probably belong in a module - -const ( - tplCommentPrefix = `# gitea public key` - tplPublicKey = tplCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict %s` + "\n" -) +// AuthorizedStringCommentPrefix is a magic tag +// some functions like RegeneratePublicKeys needs this tag to skip the keys generated by Gitea, while keep other keys +const AuthorizedStringCommentPrefix = `# gitea public key` var sshOpLocker sync.Mutex @@ -50,17 +35,73 @@ func WithSSHOpLocker(f func() error) error { } // AuthorizedStringForKey creates the authorized keys string appropriate for the provided key -func AuthorizedStringForKey(key *PublicKey) string { +func AuthorizedStringForKey(key *PublicKey) (string, error) { sb := &strings.Builder{} - _ = setting.SSH.AuthorizedKeysCommandTemplateTemplate.Execute(sb, map[string]any{ + _, err := writeAuthorizedStringForKey(key, sb) + return sb.String(), err +} + +// WriteAuthorizedStringForValidKey writes the authorized key for the provided key. If the key is invalid, it does nothing. +func WriteAuthorizedStringForValidKey(key *PublicKey, w io.Writer) error { + validKey, err := writeAuthorizedStringForKey(key, w) + if !validKey { + log.Debug("WriteAuthorizedStringForValidKey: key %s is not valid: %v", key, err) + return nil + } + return err +} + +var globalVars = sync.OnceValue(func() (ret struct { + principalRegexp *regexp.Regexp +}, +) { + // principalRegexp expresses whether a principal is considered valid. + // This reverse engineers how sshd parses the authorized keys file, + // see e.g. https://github.com/openssh/openssh-portable/blob/32deb00b38b4ee2b3302f261ea1e68c04e020a08/auth2-pubkeyfile.c#L221-L256 + // Any newline or # comment will be stripped when parsing, so don't allow + // those. Also, if any space or tab is present in the principal, the part + // proceeding this would be parsed as an option, so just avoid any whitespace + // altogether. + ret.principalRegexp = regexp.MustCompile(`^[^\s#]+$`) + return ret +}) + +func writeAuthorizedStringForKey(key *PublicKey, w io.Writer) (keyValid bool, err error) { + const tpl = AuthorizedStringCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict %s` + "\n" + + var sshKey string + + if key.Type == KeyTypePrincipal { + // TODO: actually using PublicKey to store "principal" is an abuse + if !globalVars().principalRegexp.MatchString(key.Content) { + return false, fmt.Errorf("invalid principal key: %s", key.Content) + } + sshKey = fmt.Sprintf("%s # user-%d", key.Content, key.OwnerID) + } else { + pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content)) + if err != nil { + return false, err + } + + sshKeyMarshalled := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey))) + sshKey = fmt.Sprintf("%s user-%d", sshKeyMarshalled, key.OwnerID) + } + + // now the key is valid, the code below could only return template/IO related errors + sbCmd := &strings.Builder{} + err = setting.SSH.AuthorizedKeysCommandTemplateTemplate.Execute(sbCmd, map[string]any{ "AppPath": util.ShellEscape(setting.AppPath), "AppWorkPath": util.ShellEscape(setting.AppWorkPath), "CustomConf": util.ShellEscape(setting.CustomConf), "CustomPath": util.ShellEscape(setting.CustomPath), "Key": key, }) - - return fmt.Sprintf(tplPublicKey, util.ShellEscape(sb.String()), key.Content) + if err != nil { + return true, err + } + sshCommandEscaped := util.ShellEscape(sbCmd.String()) + _, err = fmt.Fprintf(w, tpl, sshCommandEscaped, sshKey) + return true, err } // appendAuthorizedKeysToFile appends new SSH keys' content to authorized_keys file. @@ -112,7 +153,7 @@ func appendAuthorizedKeysToFile(keys ...*PublicKey) error { if key.Type == KeyTypePrincipal { continue } - if _, err = f.WriteString(key.AuthorizedString()); err != nil { + if err = WriteAuthorizedStringForValidKey(key, f); err != nil { return err } } @@ -120,10 +161,9 @@ func appendAuthorizedKeysToFile(keys ...*PublicKey) error { } // RegeneratePublicKeys regenerates the authorized_keys file -func RegeneratePublicKeys(ctx context.Context, t io.StringWriter) error { +func RegeneratePublicKeys(ctx context.Context, t io.Writer) error { if err := db.GetEngine(ctx).Where("type != ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean any) (err error) { - _, err = t.WriteString((bean.(*PublicKey)).AuthorizedString()) - return err + return WriteAuthorizedStringForValidKey(bean.(*PublicKey), t) }); err != nil { return err } @@ -144,11 +184,11 @@ func RegeneratePublicKeys(ctx context.Context, t io.StringWriter) error { scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() - if strings.HasPrefix(line, tplCommentPrefix) { + if strings.HasPrefix(line, AuthorizedStringCommentPrefix) { scanner.Scan() continue } - _, err = t.WriteString(line + "\n") + _, err = io.WriteString(t, line+"\n") if err != nil { return err } diff --git a/models/asymkey/ssh_key_authorized_keys_test.go b/models/asymkey/ssh_key_authorized_keys_test.go new file mode 100644 index 0000000000..36ed57a653 --- /dev/null +++ b/models/asymkey/ssh_key_authorized_keys_test.go @@ -0,0 +1,90 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "strings" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestWriteAuthorizedStringForKey(t *testing.T) { + defer test.MockVariableValue(&setting.AppPath, "/tmp/gitea")() + defer test.MockVariableValue(&setting.CustomConf, "/tmp/app.ini")() + writeKey := func(t *testing.T, key *PublicKey) (bool, string, error) { + sb := &strings.Builder{} + valid, err := writeAuthorizedStringForKey(key, sb) + return valid, sb.String(), err + } + const validKeyContent = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf` + + testValid := func(t *testing.T, key *PublicKey, expected string) { + valid, content, err := writeKey(t, key) + assert.True(t, valid) + assert.Equal(t, expected, content) + assert.NoError(t, err) + } + + testInvalid := func(t *testing.T, key *PublicKey) { + valid, content, err := writeKey(t, key) + assert.False(t, valid) + assert.Empty(t, content) + assert.Error(t, err) + } + + t.Run("PublicKey", func(t *testing.T) { + testValid(t, &PublicKey{ + OwnerID: 123, + Content: validKeyContent + " any-comment", + Type: KeyTypeUser, + }, `# gitea public key +command="/tmp/gitea --config=/tmp/app.ini serv key-0",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf user-123 +`) + }) + + t.Run("PublicKeyWithNewLine", func(t *testing.T) { + testValid(t, &PublicKey{ + OwnerID: 123, + Content: validKeyContent + "\nany-more", // the new line should be ignored + Type: KeyTypeUser, + }, `# gitea public key +command="/tmp/gitea --config=/tmp/app.ini serv key-0",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf user-123 +`) + }) + + t.Run("PublicKeyInvalid", func(t *testing.T) { + testInvalid(t, &PublicKey{ + OwnerID: 123, + Content: validKeyContent + "any-more", + Type: KeyTypeUser, + }) + }) + + t.Run("Principal", func(t *testing.T) { + testValid(t, &PublicKey{ + OwnerID: 123, + Content: "any-content", + Type: KeyTypePrincipal, + }, `# gitea public key +command="/tmp/gitea --config=/tmp/app.ini serv key-0",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict any-content # user-123 +`) + }) + + t.Run("PrincipalInvalid", func(t *testing.T) { + testInvalid(t, &PublicKey{ + OwnerID: 123, + Content: "a b", + Type: KeyTypePrincipal, + }) + testInvalid(t, &PublicKey{ + OwnerID: 123, + Content: "a\nb", + Type: KeyTypePrincipal, + }) + }) +} diff --git a/models/auth/twofactor.go b/models/auth/twofactor.go index 200ce7c7c0..4263495650 100644 --- a/models/auth/twofactor.go +++ b/models/auth/twofactor.go @@ -111,11 +111,11 @@ func (t *TwoFactor) SetSecret(secretString string) error { func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) { decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret) if err != nil { - return false, err + return false, fmt.Errorf("ValidateTOTP invalid base64: %w", err) } secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret) if err != nil { - return false, err + return false, fmt.Errorf("ValidateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err) } secretStr := string(secretBytes) return totp.Validate(passcode, secretStr), nil diff --git a/models/db/common.go b/models/db/common.go index ea628bf2a0..b3c43f8b62 100644 --- a/models/db/common.go +++ b/models/db/common.go @@ -12,30 +12,30 @@ import ( "xorm.io/builder" ) -// BuildCaseInsensitiveLike returns a condition to check if the given value is like the given key case-insensitively. -// Handles especially SQLite correctly as UPPER there only transforms ASCII letters. +// BuildCaseInsensitiveLike returns a case-insensitive LIKE condition for the given key and value. +// Cast the search value and the database column value to the same case for case-insensitive matching. +// * SQLite: only cast ASCII chars because it doesn't handle complete Unicode case folding +// * Other databases: use database's string function, assuming that they are able to handle complete Unicode case folding correctly func BuildCaseInsensitiveLike(key, value string) builder.Cond { + // ToLowerASCII is about 7% faster than ToUpperASCII (according to Golang's benchmark) if setting.Database.Type.IsSQLite3() { - return builder.Like{"UPPER(" + key + ")", util.ToUpperASCII(value)} + return builder.Like{"LOWER(" + key + ")", util.ToLowerASCII(value)} } - return builder.Like{"UPPER(" + key + ")", strings.ToUpper(value)} + return builder.Like{"LOWER(" + key + ")", strings.ToLower(value)} } // BuildCaseInsensitiveIn returns a condition to check if the given value is in the given values case-insensitively. -// Handles especially SQLite correctly as UPPER there only transforms ASCII letters. +// See BuildCaseInsensitiveLike for more details func BuildCaseInsensitiveIn(key string, values []string) builder.Cond { - uppers := make([]string, 0, len(values)) + incaseValues := make([]string, len(values)) + caseCast := strings.ToLower if setting.Database.Type.IsSQLite3() { - for _, value := range values { - uppers = append(uppers, util.ToUpperASCII(value)) - } - } else { - for _, value := range values { - uppers = append(uppers, strings.ToUpper(value)) - } + caseCast = util.ToLowerASCII } - - return builder.In("UPPER("+key+")", uppers) + for i, value := range values { + incaseValues[i] = caseCast(value) + } + return builder.In("LOWER("+key+")", incaseValues) } // BuilderDialect returns the xorm.Builder dialect of the engine diff --git a/models/db/engine_init.go b/models/db/engine_init.go index f26189b805..ef5db3ff5e 100644 --- a/models/db/engine_init.go +++ b/models/db/engine_init.go @@ -57,7 +57,7 @@ func InitEngine(ctx context.Context) error { xe, err := newXORMEngine() if err != nil { if strings.Contains(err.Error(), "SQLite3 support") { - return fmt.Errorf(`sqlite3 requires: -tags sqlite,sqlite_unlock_notify%s%w`, "\n", err) + return fmt.Errorf("sqlite3 requires: -tags sqlite,sqlite_unlock_notify\n%w", err) } return fmt.Errorf("failed to connect to database: %w", err) } diff --git a/models/fixtures/action_run.yml b/models/fixtures/action_run.yml index 09dfa6cccb..44b131c961 100644 --- a/models/fixtures/action_run.yml +++ b/models/fixtures/action_run.yml @@ -139,3 +139,43 @@ updated: 1683636626 need_approval: 0 approved_by: 0 +- + id: 804 + title: "use a private action" + repo_id: 60 + owner_id: 40 + workflow_id: "run.yaml" + index: 189 + trigger_user_id: 40 + ref: "refs/heads/master" + commit_sha: "6e64b26de7ba966d01d90ecfaf5c7f14ef203e86" + event: "push" + trigger_event: "push" + is_fork_pull_request: 0 + status: 1 + started: 1683636528 + stopped: 1683636626 + created: 1683636108 + updated: 1683636626 + need_approval: 0 + approved_by: 0 +- + id: 805 + title: "update actions" + repo_id: 4 + owner_id: 1 + workflow_id: "artifact.yaml" + index: 191 + trigger_user_id: 1 + ref: "refs/heads/master" + commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0" + event: "push" + trigger_event: "push" + is_fork_pull_request: 0 + status: 5 + started: 1683636528 + stopped: 1683636626 + created: 1683636108 + updated: 1683636626 + need_approval: 0 + approved_by: 0 diff --git a/models/fixtures/action_run_job.yml b/models/fixtures/action_run_job.yml index 6c06d94aa4..c5aeb4931c 100644 --- a/models/fixtures/action_run_job.yml +++ b/models/fixtures/action_run_job.yml @@ -129,3 +129,31 @@ status: 5 started: 1683636528 stopped: 1683636626 +- + id: 205 + run_id: 804 + repo_id: 6 + owner_id: 10 + commit_sha: 6e64b26de7ba966d01d90ecfaf5c7f14ef203e86 + is_fork_pull_request: 0 + name: job_2 + attempt: 1 + job_id: job_2 + task_id: 48 + status: 1 + started: 1683636528 + stopped: 1683636626 +- + id: 206 + run_id: 805 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: 0 + name: job_2 + attempt: 1 + job_id: job_2 + task_id: 56 + status: 3 + started: 1683636528 + stopped: 1683636626 diff --git a/models/fixtures/action_task.yml b/models/fixtures/action_task.yml index c79fb07050..a28ddd0add 100644 --- a/models/fixtures/action_task.yml +++ b/models/fixtures/action_task.yml @@ -177,3 +177,42 @@ log_length: 0 log_size: 0 log_expired: 0 +- + id: 55 + job_id: 205 + attempt: 1 + runner_id: 1 + status: 6 # 6 is the status code for "running" + started: 1683636528 + stopped: 1683636626 + repo_id: 6 + owner_id: 10 + commit_sha: 6e64b26de7ba966d01d90ecfaf5c7f14ef203e86 + is_fork_pull_request: 0 + token_hash: b8d3962425466b6709b9ac51446f93260c54afe8e7b6d3686e34f991fb8a8953822b0deed86fe41a103f34bc48dbc478422b + token_salt: ERxJGHvg3I + token_last_eight: 182199eb + log_filename: collaborative-owner-test/1a/49.log + log_in_storage: 1 + log_length: 707 + log_size: 90179 + log_expired: 0 +- + id: 56 + attempt: 1 + runner_id: 1 + status: 3 # 3 is the status code for "cancelled" + started: 1683636528 + stopped: 1683636626 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: 0 + token_hash: 6d8ef48297195edcc8e22c70b3020eaa06c52976db67d39b4240c64a69a2cc1508825121b7b8394e48e00b1bf3718b2aaaab + token_salt: eeeeeeee + token_last_eight: eeeeeeee + log_filename: artifact-test2/2f/47.log + log_in_storage: 1 + log_length: 707 + log_size: 90179 + log_expired: 0 diff --git a/models/fixtures/branch.yml b/models/fixtures/branch.yml index 717230149b..a17999091e 100644 --- a/models/fixtures/branch.yml +++ b/models/fixtures/branch.yml @@ -225,3 +225,27 @@ is_deleted: false deleted_by_id: 0 deleted_unix: 0 + +- + id: 27 + repo_id: 1 + name: 'DefaultBranch' + commit_id: '90c1019714259b24fb81711d4416ac0f18667dfa' + commit_message: 'add license' + commit_time: 1709345946 + pusher_id: 1 + is_deleted: false + deleted_by_id: 0 + deleted_unix: 0 + +- + id: 28 + repo_id: 1 + name: 'sub-home-md-img-check' + commit_id: '4649299398e4d39a5c09eb4f534df6f1e1eb87cc' + commit_message: "Test how READMEs render images when found in a subfolder" + commit_time: 1678403550 + pusher_id: 1 + is_deleted: false + deleted_by_id: 0 + deleted_unix: 0 diff --git a/models/fixtures/repo_unit.yml b/models/fixtures/repo_unit.yml index f6b6252da1..4c3e37500f 100644 --- a/models/fixtures/repo_unit.yml +++ b/models/fixtures/repo_unit.yml @@ -733,3 +733,17 @@ type: 3 config: "{\"IgnoreWhitespaceConflicts\":false,\"AllowMerge\":true,\"AllowRebase\":true,\"AllowRebaseMerge\":true,\"AllowSquash\":true}" created_unix: 946684810 + +- + id: 111 + repo_id: 3 + type: 10 + config: "{}" + created_unix: 946684810 + +- + id: 112 + repo_id: 4 + type: 10 + config: "{}" + created_unix: 946684810 diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml index 552a78cbd2..dfa514db37 100644 --- a/models/fixtures/repository.yml +++ b/models/fixtures/repository.yml @@ -110,6 +110,8 @@ num_closed_milestones: 0 num_projects: 0 num_closed_projects: 1 + num_action_runs: 4 + num_closed_action_runs: 3 is_private: false is_empty: false is_archived: false diff --git a/models/git/branch.go b/models/git/branch.go index 54351649cc..e5b73fb3e7 100644 --- a/models/git/branch.go +++ b/models/git/branch.go @@ -368,7 +368,7 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, from, to str } // 1. update branch in database - if n, err := sess.Where("repo_id=? AND name=?", repo.ID, from).Update(&Branch{ + if n, err := sess.Where("repo_id=? AND name=?", repo.ID, from).Cols("name").Update(&Branch{ Name: to, }); err != nil { return err @@ -490,12 +490,25 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o opts.CommitAfterUnix = time.Now().Add(-time.Hour * 2).Unix() } - baseBranch, err := GetBranch(ctx, opts.BaseRepo.ID, opts.BaseRepo.DefaultBranch) + var ignoredCommitIDs []string + baseDefaultBranch, err := GetBranch(ctx, opts.BaseRepo.ID, opts.BaseRepo.DefaultBranch) if err != nil { - return nil, err + log.Warn("GetBranch:DefaultBranch: %v", err) + } else { + ignoredCommitIDs = append(ignoredCommitIDs, baseDefaultBranch.CommitID) } - // find all related branches, these branches may already created PRs, we will check later + baseDefaultTargetBranchName := opts.BaseRepo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig().DefaultTargetBranch + if baseDefaultTargetBranchName != "" && baseDefaultTargetBranchName != opts.BaseRepo.DefaultBranch { + baseDefaultTargetBranch, err := GetBranch(ctx, opts.BaseRepo.ID, baseDefaultTargetBranchName) + if err != nil { + log.Warn("GetBranch:DefaultTargetBranch: %v", err) + } else { + ignoredCommitIDs = append(ignoredCommitIDs, baseDefaultTargetBranch.CommitID) + } + } + + // find all related branches, these branches may already have PRs, we will check later var branches []*Branch if err := db.GetEngine(ctx). Where(builder.And( @@ -506,7 +519,7 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o builder.Gte{"commit_time": opts.CommitAfterUnix}, builder.In("repo_id", repoIDs), // newly created branch have no changes, so skip them - builder.Neq{"commit_id": baseBranch.CommitID}, + builder.NotIn("commit_id", ignoredCommitIDs), )). OrderBy(db.SearchOrderByRecentUpdated.String()). Find(&branches); err != nil { @@ -514,10 +527,8 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o } newBranches := make([]*RecentlyPushedNewBranch, 0, len(branches)) - if opts.MaxCount == 0 { - // by default we display 2 recently pushed new branch - opts.MaxCount = 2 - } + opts.MaxCount = util.IfZero(opts.MaxCount, 2) // by default, we display 2 recently pushed new branch + baseTargetBranchName := opts.BaseRepo.GetPullRequestTargetBranch(ctx) for _, branch := range branches { // whether the branch is protected protected, err := IsBranchProtected(ctx, branch.RepoID, branch.Name) @@ -555,7 +566,7 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o BranchDisplayName: branchDisplayName, BranchName: branch.Name, BranchLink: fmt.Sprintf("%s/src/branch/%s", branch.Repo.Link(), util.PathEscapeSegments(branch.Name)), - BranchCompareURL: branch.Repo.ComposeBranchCompareURL(opts.BaseRepo, branch.Name), + BranchCompareURL: branch.Repo.ComposeBranchCompareURL(opts.BaseRepo, baseTargetBranchName, branch.Name), CommitTime: branch.CommitTime, }) } diff --git a/models/git/branch_test.go b/models/git/branch_test.go index 5be435172b..7728d72f3e 100644 --- a/models/git/branch_test.go +++ b/models/git/branch_test.go @@ -114,7 +114,7 @@ func TestFindRenamedBranch(t *testing.T) { assert.True(t, exist) assert.Equal(t, "master", branch.To) - _, exist, err = git_model.FindRenamedBranch(t.Context(), 1, "unknow") + _, exist, err = git_model.FindRenamedBranch(t.Context(), 1, "unknown") assert.NoError(t, err) assert.False(t, exist) } diff --git a/models/git/commit_status.go b/models/git/commit_status.go index e255bca5d0..2ae5937a3d 100644 --- a/models/git/commit_status.go +++ b/models/git/commit_status.go @@ -30,17 +30,21 @@ import ( // CommitStatus holds a single Status of a single Commit type CommitStatus struct { - ID int64 `xorm:"pk autoincr"` - Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` - RepoID int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` - Repo *repo_model.Repository `xorm:"-"` - State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"` - SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"` - TargetURL string `xorm:"TEXT"` - Description string `xorm:"TEXT"` - ContextHash string `xorm:"VARCHAR(64) index"` - Context string `xorm:"TEXT"` - Creator *user_model.User `xorm:"-"` + ID int64 `xorm:"pk autoincr"` + Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` + RepoID int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` + Repo *repo_model.Repository `xorm:"-"` + State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"` + SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"` + + // TargetURL points to the commit status page reported by a CI system + // If Gitea Actions is used, it is a relative link like "{RepoLink}/actions/runs/{RunID}/jobs{JobID}" + TargetURL string `xorm:"TEXT"` + + Description string `xorm:"TEXT"` + ContextHash string `xorm:"VARCHAR(64) index"` + Context string `xorm:"TEXT"` + Creator *user_model.User `xorm:"-"` CreatorID int64 CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` @@ -211,21 +215,45 @@ func (status *CommitStatus) LocaleString(lang translation.Locale) string { // HideActionsURL set `TargetURL` to an empty string if the status comes from Gitea Actions func (status *CommitStatus) HideActionsURL(ctx context.Context) { + if _, ok := status.cutTargetURLGiteaActionsPrefix(ctx); ok { + status.TargetURL = "" + } +} + +func (status *CommitStatus) cutTargetURLGiteaActionsPrefix(ctx context.Context) (string, bool) { if status.RepoID == 0 { - return + return "", false } if status.Repo == nil { if err := status.loadRepository(ctx); err != nil { log.Error("loadRepository: %v", err) - return + return "", false } } prefix := status.Repo.Link() + "/actions" - if strings.HasPrefix(status.TargetURL, prefix) { - status.TargetURL = "" + return strings.CutPrefix(status.TargetURL, prefix) +} + +// ParseGiteaActionsTargetURL parses the commit status target URL as Gitea Actions link +func (status *CommitStatus) ParseGiteaActionsTargetURL(ctx context.Context) (runID, jobID int64, ok bool) { + s, ok := status.cutTargetURLGiteaActionsPrefix(ctx) + if !ok { + return 0, 0, false } + + parts := strings.Split(s, "/") // expect: /runs/{runID}/jobs/{jobID} + if len(parts) < 5 || parts[1] != "runs" || parts[3] != "jobs" { + return 0, 0, false + } + + runID, err1 := strconv.ParseInt(parts[2], 10, 64) + jobID, err2 := strconv.ParseInt(parts[4], 10, 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return runID, jobID, true } // CalcCommitStatus returns commit status state via some status, the commit statues should order by id desc diff --git a/models/git/lfs.go b/models/git/lfs.go index 8bba060ff9..1f3d086ac2 100644 --- a/models/git/lfs.go +++ b/models/git/lfs.go @@ -8,7 +8,6 @@ import ( "fmt" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -42,30 +41,6 @@ func (err ErrLFSLockNotExist) Unwrap() error { return util.ErrNotExist } -// ErrLFSUnauthorizedAction represents a "LFSUnauthorizedAction" kind of error. -type ErrLFSUnauthorizedAction struct { - RepoID int64 - UserName string - Mode perm.AccessMode -} - -// IsErrLFSUnauthorizedAction checks if an error is a ErrLFSUnauthorizedAction. -func IsErrLFSUnauthorizedAction(err error) bool { - _, ok := err.(ErrLFSUnauthorizedAction) - return ok -} - -func (err ErrLFSUnauthorizedAction) Error() string { - if err.Mode == perm.AccessModeWrite { - return fmt.Sprintf("User %s doesn't have write access for lfs lock [rid: %d]", err.UserName, err.RepoID) - } - return fmt.Sprintf("User %s doesn't have read access for lfs lock [rid: %d]", err.UserName, err.RepoID) -} - -func (err ErrLFSUnauthorizedAction) Unwrap() error { - return util.ErrPermissionDenied -} - // ErrLFSLockAlreadyExist represents a "LFSLockAlreadyExist" kind of error. type ErrLFSLockAlreadyExist struct { RepoID int64 @@ -93,12 +68,6 @@ type ErrLFSFileLocked struct { UserName string } -// IsErrLFSFileLocked checks if an error is a ErrLFSFileLocked. -func IsErrLFSFileLocked(err error) bool { - _, ok := err.(ErrLFSFileLocked) - return ok -} - func (err ErrLFSFileLocked) Error() string { return fmt.Sprintf("File is lfs locked [repo: %d, locked by: %s, path: %s]", err.RepoID, err.UserName, err.Path) } @@ -343,15 +312,12 @@ func IterateRepositoryIDsWithLFSMetaObjects(ctx context.Context, f func(ctx cont // IterateLFSMetaObjectsForRepoOptions provides options for IterateLFSMetaObjectsForRepo type IterateLFSMetaObjectsForRepoOptions struct { - OlderThan timeutil.TimeStamp - UpdatedLessRecentlyThan timeutil.TimeStamp - OrderByUpdated bool - LoopFunctionAlwaysUpdates bool + OlderThan timeutil.TimeStamp + UpdatedLessRecentlyThan timeutil.TimeStamp } // IterateLFSMetaObjectsForRepo provides a iterator for LFSMetaObjects per Repo func IterateLFSMetaObjectsForRepo(ctx context.Context, repoID int64, f func(context.Context, *LFSMetaObject, int64) error, opts *IterateLFSMetaObjectsForRepoOptions) error { - var start int batchSize := setting.Database.IterateBufferSize engine := db.GetEngine(ctx) type CountLFSMetaObject struct { @@ -359,7 +325,7 @@ func IterateLFSMetaObjectsForRepo(ctx context.Context, repoID int64, f func(cont LFSMetaObject `xorm:"extends"` } - id := int64(0) + lastID := int64(0) for { beans := make([]*CountLFSMetaObject, 0, batchSize) @@ -372,29 +338,23 @@ func IterateLFSMetaObjectsForRepo(ctx context.Context, repoID int64, f func(cont if !opts.UpdatedLessRecentlyThan.IsZero() { sess.And("`lfs_meta_object`.updated_unix < ?", opts.UpdatedLessRecentlyThan) } - sess.GroupBy("`lfs_meta_object`.id") - if opts.OrderByUpdated { - sess.OrderBy("`lfs_meta_object`.updated_unix ASC") - } else { - sess.And("`lfs_meta_object`.id > ?", id) - sess.OrderBy("`lfs_meta_object`.id ASC") - } - if err := sess.Limit(batchSize, start).Find(&beans); err != nil { + sess.GroupBy("`lfs_meta_object`.id"). + And("`lfs_meta_object`.id > ?", lastID). + OrderBy("`lfs_meta_object`.id ASC") + + if err := sess.Limit(batchSize).Find(&beans); err != nil { return err } if len(beans) == 0 { return nil } - if !opts.LoopFunctionAlwaysUpdates { - start += len(beans) - } for _, bean := range beans { if err := f(ctx, &bean.LFSMetaObject, bean.Count); err != nil { return err } } - id = beans[len(beans)-1].ID + lastID = beans[len(beans)-1].ID } } diff --git a/models/git/lfs_lock.go b/models/git/lfs_lock.go index c5f9a4e6de..aabed6b7fa 100644 --- a/models/git/lfs_lock.go +++ b/models/git/lfs_lock.go @@ -11,10 +11,7 @@ import ( "time" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/perm" - access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -71,10 +68,6 @@ func (l *LFSLock) LoadOwner(ctx context.Context) error { // CreateLFSLock creates a new lock. func CreateLFSLock(ctx context.Context, repo *repo_model.Repository, lock *LFSLock) (*LFSLock, error) { return db.WithTx2(ctx, func(ctx context.Context) (*LFSLock, error) { - if err := CheckLFSAccessForRepo(ctx, lock.OwnerID, repo, perm.AccessModeWrite); err != nil { - return nil, err - } - lock.Path = util.PathJoinRel(lock.Path) lock.RepoID = repo.ID @@ -108,10 +101,10 @@ func GetLFSLock(ctx context.Context, repo *repo_model.Repository, path string) ( return rel, nil } -// GetLFSLockByID returns release by given id. -func GetLFSLockByID(ctx context.Context, id int64) (*LFSLock, error) { +// GetLFSLockByIDAndRepo returns lfs lock by given id and repository id. +func GetLFSLockByIDAndRepo(ctx context.Context, id, repoID int64) (*LFSLock, error) { lock := new(LFSLock) - has, err := db.GetEngine(ctx).ID(id).Get(lock) + has, err := db.GetEngine(ctx).ID(id).And("repo_id = ?", repoID).Get(lock) if err != nil { return nil, err } else if !has { @@ -160,15 +153,11 @@ func CountLFSLockByRepoID(ctx context.Context, repoID int64) (int64, error) { // DeleteLFSLockByID deletes a lock by given ID. func DeleteLFSLockByID(ctx context.Context, id int64, repo *repo_model.Repository, u *user_model.User, force bool) (*LFSLock, error) { return db.WithTx2(ctx, func(ctx context.Context) (*LFSLock, error) { - lock, err := GetLFSLockByID(ctx, id) + lock, err := GetLFSLockByIDAndRepo(ctx, id, repo.ID) if err != nil { return nil, err } - if err := CheckLFSAccessForRepo(ctx, u.ID, repo, perm.AccessModeWrite); err != nil { - return nil, err - } - if !force && u.ID != lock.OwnerID { return nil, errors.New("user doesn't own lock and force flag is not set") } @@ -180,22 +169,3 @@ func DeleteLFSLockByID(ctx context.Context, id int64, repo *repo_model.Repositor return lock, nil }) } - -// CheckLFSAccessForRepo check needed access mode base on action -func CheckLFSAccessForRepo(ctx context.Context, ownerID int64, repo *repo_model.Repository, mode perm.AccessMode) error { - if ownerID == 0 { - return ErrLFSUnauthorizedAction{repo.ID, "undefined", mode} - } - u, err := user_model.GetUserByID(ctx, ownerID) - if err != nil { - return err - } - perm, err := access_model.GetUserRepoPermission(ctx, repo, u) - if err != nil { - return err - } - if !perm.CanAccess(mode, unit.TypeCode) { - return ErrLFSUnauthorizedAction{repo.ID, u.DisplayName(), mode} - } - return nil -} diff --git a/models/git/lfs_lock_test.go b/models/git/lfs_lock_test.go new file mode 100644 index 0000000000..c88e89be47 --- /dev/null +++ b/models/git/lfs_lock_test.go @@ -0,0 +1,82 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "fmt" + "testing" + "time" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createTestLock(t *testing.T, repo *repo_model.Repository, owner *user_model.User) *LFSLock { + t.Helper() + + path := fmt.Sprintf("%s-%d-%d", t.Name(), repo.ID, time.Now().UnixNano()) + lock, err := CreateLFSLock(t.Context(), repo, &LFSLock{ + OwnerID: owner.ID, + Path: path, + }) + require.NoError(t, err) + return lock +} + +func TestGetLFSLockByIDAndRepo(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + + lockRepo1 := createTestLock(t, repo1, user2) + lockRepo3 := createTestLock(t, repo3, user4) + + fetched, err := GetLFSLockByIDAndRepo(t.Context(), lockRepo1.ID, repo1.ID) + require.NoError(t, err) + assert.Equal(t, lockRepo1.ID, fetched.ID) + assert.Equal(t, repo1.ID, fetched.RepoID) + + _, err = GetLFSLockByIDAndRepo(t.Context(), lockRepo1.ID, repo3.ID) + assert.Error(t, err) + assert.True(t, IsErrLFSLockNotExist(err)) + + _, err = GetLFSLockByIDAndRepo(t.Context(), lockRepo3.ID, repo1.ID) + assert.Error(t, err) + assert.True(t, IsErrLFSLockNotExist(err)) +} + +func TestDeleteLFSLockByIDRequiresRepoMatch(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + + lockRepo1 := createTestLock(t, repo1, user2) + lockRepo3 := createTestLock(t, repo3, user4) + + _, err := DeleteLFSLockByID(t.Context(), lockRepo3.ID, repo1, user2, true) + assert.Error(t, err) + assert.True(t, IsErrLFSLockNotExist(err)) + + existing, err := GetLFSLockByIDAndRepo(t.Context(), lockRepo3.ID, repo3.ID) + require.NoError(t, err) + assert.Equal(t, lockRepo3.ID, existing.ID) + + deleted, err := DeleteLFSLockByID(t.Context(), lockRepo3.ID, repo3, user4, true) + require.NoError(t, err) + assert.Equal(t, lockRepo3.ID, deleted.ID) + + deleted, err = DeleteLFSLockByID(t.Context(), lockRepo1.ID, repo1, user2, false) + require.NoError(t, err) + assert.Equal(t, lockRepo1.ID, deleted.ID) +} diff --git a/models/git/lfs_test.go b/models/git/lfs_test.go new file mode 100644 index 0000000000..4c0242f439 --- /dev/null +++ b/models/git/lfs_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git_test + +import ( + "bytes" + "context" + "strconv" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/timeutil" + + "github.com/stretchr/testify/assert" +) + +func TestIterateLFSMetaObjectsForRepoUpdatesDoNotSkip(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + ctx := t.Context() + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, "user2", "repo1") + assert.NoError(t, err) + + defer test.MockVariableValue(&setting.Database.IterateBufferSize, 1)() + + created := make([]*git_model.LFSMetaObject, 0, 3) + for i := range 3 { + content := []byte("gitea-lfs-" + strconv.Itoa(i)) + pointer, err := lfs.GeneratePointer(bytes.NewReader(content)) + assert.NoError(t, err) + + meta, err := git_model.NewLFSMetaObject(ctx, repo.ID, pointer) + assert.NoError(t, err) + created = append(created, meta) + } + + iterated := make([]int64, 0, len(created)) + cutoff := time.Now().Add(24 * time.Hour) + iterErr := git_model.IterateLFSMetaObjectsForRepo(ctx, repo.ID, func(ctx context.Context, meta *git_model.LFSMetaObject, count int64) error { + iterated = append(iterated, meta.ID) + _, err := db.GetEngine(ctx).ID(meta.ID).Cols("updated_unix").Update(&git_model.LFSMetaObject{ + UpdatedUnix: timeutil.TimeStamp(time.Now().Unix()), + }) + return err + }, &git_model.IterateLFSMetaObjectsForRepoOptions{ + OlderThan: timeutil.TimeStamp(cutoff.Unix()), + UpdatedLessRecentlyThan: timeutil.TimeStamp(cutoff.Unix()), + }) + assert.NoError(t, iterErr) + + expected := []int64{created[0].ID, created[1].ID, created[2].ID} + assert.Equal(t, expected, iterated) +} diff --git a/models/git/protected_branch.go b/models/git/protected_branch.go index 511f7563cf..1085c14cae 100644 --- a/models/git/protected_branch.go +++ b/models/git/protected_branch.go @@ -5,7 +5,6 @@ package git import ( "context" - "errors" "fmt" "slices" "strings" @@ -25,7 +24,7 @@ import ( "xorm.io/builder" ) -var ErrBranchIsProtected = errors.New("branch is protected") +var ErrBranchIsProtected = util.ErrorWrap(util.ErrPermissionDenied, "branch is protected") // ProtectedBranch struct type ProtectedBranch struct { @@ -467,11 +466,13 @@ func updateApprovalWhitelist(ctx context.Context, repo *repo_model.Repository, c return currentWhitelist, nil } + prUserIDs, err := access_model.GetUserIDsWithUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests) + if err != nil { + return nil, err + } whitelist = make([]int64, 0, len(newWhitelist)) for _, userID := range newWhitelist { - if reader, err := access_model.IsRepoReader(ctx, repo, userID); err != nil { - return nil, err - } else if !reader { + if !prUserIDs.Contains(userID) { continue } whitelist = append(whitelist, userID) diff --git a/models/issues/comment.go b/models/issues/comment.go index 3a4049700d..f15618bf50 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -20,6 +20,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/references" @@ -233,11 +234,17 @@ func (r RoleInRepo) LocaleHelper(lang translation.Locale) string { return lang.TrString("repo.issues.role." + string(r) + "_helper") } +type SpecialDoerNameType string + +const SpecialDoerNameCodeOwners SpecialDoerNameType = "CODEOWNERS" + // CommentMetaData stores metadata for a comment, these data will not be changed once inserted into database type CommentMetaData struct { ProjectColumnID int64 `json:"project_column_id,omitempty"` ProjectColumnTitle string `json:"project_column_title,omitempty"` ProjectTitle string `json:"project_title,omitempty"` + + SpecialDoerName SpecialDoerNameType `json:"special_doer_name,omitempty"` // e.g. "CODEOWNERS" for CODEOWNERS-triggered review requests } // Comment represents a comment in commit and issue page. @@ -764,6 +771,37 @@ func (c *Comment) CodeCommentLink(ctx context.Context) string { return fmt.Sprintf("%s/files#%s", c.Issue.Link(), c.HashTag()) } +func (c *Comment) MetaSpecialDoerTr(locale translation.Locale) template.HTML { + if c.CommentMetaData == nil { + return "" + } + if c.CommentMetaData.SpecialDoerName == SpecialDoerNameCodeOwners { + return locale.Tr("repo.issues.review.codeowners_rules") + } + return htmlutil.HTMLFormat("%s", c.CommentMetaData.SpecialDoerName) +} + +func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdStr template.HTML) template.HTML { + if c.AssigneeID > 0 { + // it guarantees LoadAssigneeUserAndTeam has been called, and c.Assignee is Ghost user but not nil if the user doesn't exist + if c.RemovedAssignee { + if c.PosterID == c.AssigneeID { + return locale.Tr("repo.issues.review.remove_review_request_self", createdStr) + } + return locale.Tr("repo.issues.review.remove_review_request", c.Assignee.GetDisplayName(), createdStr) + } + return locale.Tr("repo.issues.review.add_review_request", c.Assignee.GetDisplayName(), createdStr) + } + teamName := "Ghost Team" + if c.AssigneeTeam != nil { + teamName = c.AssigneeTeam.Name + } + if c.RemovedAssignee { + return locale.Tr("repo.issues.review.remove_review_request", teamName, createdStr) + } + return locale.Tr("repo.issues.review.add_review_request", teamName, createdStr) +} + // CreateComment creates comment with context func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, err error) { return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) { @@ -780,6 +818,11 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, ProjectTitle: opts.ProjectTitle, } } + if opts.SpecialDoerName != "" { + commentMetaData = &CommentMetaData{ + SpecialDoerName: opts.SpecialDoerName, + } + } comment := &Comment{ Type: opts.Type, @@ -862,10 +905,7 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment if err = UpdateCommentAttachments(ctx, comment, opts.Attachments); err != nil { return err } - case CommentTypeReopen, CommentTypeClose: - if err = repo_model.UpdateRepoIssueNumbers(ctx, opts.Issue.RepoID, opts.Issue.IsPull, true); err != nil { - return err - } + // comment type reopen and close event have their own logic to update numbers but not here } // update the issue's updated_unix column return UpdateIssueCols(ctx, opts.Issue, "updated_unix") @@ -979,6 +1019,7 @@ type CreateCommentOptions struct { RefIsPull bool IsForcePush bool Invalidated bool + SpecialDoerName SpecialDoerNameType // e.g. "CODEOWNERS" for CODEOWNERS-triggered review requests } // GetCommentByID returns the comment by given ID. @@ -993,6 +1034,20 @@ func GetCommentByID(ctx context.Context, id int64) (*Comment, error) { return c, nil } +func GetCommentWithRepoID(ctx context.Context, repoID, commentID int64) (*Comment, error) { + c, err := GetCommentByID(ctx, commentID) + if err != nil { + return nil, err + } + if err := c.LoadIssue(ctx); err != nil { + return nil, err + } + if c.Issue.RepoID != repoID { + return nil, ErrCommentNotExist{commentID, 0} + } + return c, nil +} + // FindCommentsOptions describes the conditions to Find comments type FindCommentsOptions struct { db.ListOptions diff --git a/models/issues/comment_code.go b/models/issues/comment_code.go index 55e67a1243..8de52f815a 100644 --- a/models/issues/comment_code.go +++ b/models/issues/comment_code.go @@ -102,6 +102,7 @@ func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issu continue } comment.Review = re + comment.Issue = issue } comments[n] = comment n++ diff --git a/models/issues/issue.go b/models/issues/issue.go index 053b96dceb..f6f27588b3 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -682,7 +682,7 @@ func (issue *Issue) GetParticipantIDsByIssue(ctx context.Context) ([]int64, erro } // BlockedByDependencies finds all Dependencies an issue is blocked by -func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptions) (issueDeps []*DependencyInfo, err error) { +func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptions) (issueDeps []*DependencyInfo, total int64, err error) { sess := db.GetEngine(ctx). Table("issue"). Join("INNER", "repository", "repository.id = issue.repo_id"). @@ -693,13 +693,13 @@ func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptio if opts.Page > 0 { sess = db.SetSessionPagination(sess, &opts) } - err = sess.Find(&issueDeps) + total, err = sess.FindAndCount(&issueDeps) for _, depInfo := range issueDeps { depInfo.Issue.Repo = &depInfo.Repository } - return issueDeps, err + return issueDeps, total, err } // BlockingDependencies returns all blocking dependencies, aka all other issues a given issue blocks diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index 466e788d6c..049dcc7de8 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -476,7 +476,7 @@ func applySubscribedCondition(sess *xorm.Session, subscriberID int64) { ), builder.Eq{"issue.poster_id": subscriberID}, builder.In("issue.repo_id", builder. - Select("id"). + Select("repo_id"). From("watch"). Where(builder.And(builder.Eq{"user_id": subscriberID}, builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto))), diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 09fd492667..55a90f50a1 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -197,6 +197,12 @@ func TestIssues(t *testing.T) { }, []int64{2}, }, + { + issues_model.IssuesOptions{ + SubscriberID: 11, + }, + []int64{11, 5, 9, 8, 3, 2, 1}, + }, } { issues, err := issues_model.Issues(t.Context(), &test.Opts) assert.NoError(t, err) diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 553e99aece..0a320ffc56 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -146,8 +146,19 @@ func updateIssueNumbers(ctx context.Context, issue *Issue, doer *user_model.User } // update repository's issue closed number - if err := repo_model.UpdateRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, true); err != nil { - return nil, err + switch cmtType { + case CommentTypeClose, CommentTypeMergePull: + // only increase closed count + if err := IncrRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, false); err != nil { + return nil, err + } + case CommentTypeReopen: + // only decrease closed count + if err := DecrRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, false, true); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("invalid comment type: %d", cmtType) } return CreateComment(ctx, &CreateCommentOptions{ @@ -318,7 +329,6 @@ type NewIssueOptions struct { Issue *Issue LabelIDs []int64 Attachments []string // In UUID format. - IsPull bool } // NewIssueWithIndex creates issue with given index @@ -369,7 +379,8 @@ func NewIssueWithIndex(ctx context.Context, doer *user_model.User, opts NewIssue } } - if err := repo_model.UpdateRepoIssueNumbers(ctx, opts.Issue.RepoID, opts.IsPull, false); err != nil { + // Update repository issue total count + if err := IncrRepoIssueNumbers(ctx, opts.Repo.ID, opts.Issue.IsPull, true); err != nil { return err } @@ -439,6 +450,42 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *Issue, la }) } +// IncrRepoIssueNumbers increments repository issue numbers. +func IncrRepoIssueNumbers(ctx context.Context, repoID int64, isPull, totalOrClosed bool) error { + dbSession := db.GetEngine(ctx) + var colName string + if totalOrClosed { + colName = util.Iif(isPull, "num_pulls", "num_issues") + } else { + colName = util.Iif(isPull, "num_closed_pulls", "num_closed_issues") + } + _, err := dbSession.Incr(colName).ID(repoID). + NoAutoCondition().NoAutoTime(). + Update(new(repo_model.Repository)) + return err +} + +// DecrRepoIssueNumbers decrements repository issue numbers. +func DecrRepoIssueNumbers(ctx context.Context, repoID int64, isPull, includeTotal, includeClosed bool) error { + if !includeTotal && !includeClosed { + return fmt.Errorf("no numbers to decrease for repo id %d", repoID) + } + + dbSession := db.GetEngine(ctx) + if includeTotal { + colName := util.Iif(isPull, "num_pulls", "num_issues") + dbSession = dbSession.Decr(colName) + } + if includeClosed { + closedColName := util.Iif(isPull, "num_closed_pulls", "num_closed_issues") + dbSession = dbSession.Decr(closedColName) + } + _, err := dbSession.ID(repoID). + NoAutoCondition().NoAutoTime(). + Update(new(repo_model.Repository)) + return err +} + // UpdateIssueMentions updates issue-user relations for mentioned users. func UpdateIssueMentions(ctx context.Context, issueID int64, mentions []*user_model.User) error { if len(mentions) == 0 { diff --git a/models/issues/milestone.go b/models/issues/milestone.go index 373f39f4ff..1dd8630276 100644 --- a/models/issues/milestone.go +++ b/models/issues/milestone.go @@ -75,6 +75,8 @@ func init() { func (m *Milestone) BeforeUpdate() { if m.NumIssues > 0 { m.Completeness = m.NumClosedIssues * 100 / m.NumIssues + } else if m.IsClosed { + m.Completeness = 100 } else { m.Completeness = 0 } @@ -181,6 +183,7 @@ func updateMilestone(ctx context.Context, m *Milestone) error { func UpdateMilestoneCounters(ctx context.Context, id int64) error { e := db.GetEngine(ctx) _, err := e.ID(id). + Cols("num_issues", "num_closed_issues"). SetExpr("num_issues", builder.Select("count(*)").From("issue").Where( builder.Eq{"milestone_id": id}, )). @@ -194,8 +197,8 @@ func UpdateMilestoneCounters(ctx context.Context, id int64) error { if err != nil { return err } - _, err = e.Exec("UPDATE `milestone` SET completeness=100*num_closed_issues/(CASE WHEN num_issues > 0 THEN num_issues ELSE 1 END) WHERE id=?", - id, + _, err = e.Exec("UPDATE `milestone` SET completeness=(CASE WHEN is_closed = ? AND num_issues = 0 THEN 100 ELSE 100*num_closed_issues/(CASE WHEN num_issues > 0 THEN num_issues ELSE 1 END) END) WHERE id=?", + true, id, ) return err } @@ -239,6 +242,11 @@ func changeMilestoneStatus(ctx context.Context, m *Milestone, isClosed bool) err if count < 1 { return nil } + + if err := UpdateMilestoneCounters(ctx, m.ID); err != nil { + return err + } + return updateRepoMilestoneNum(ctx, m.RepoID) } diff --git a/models/issues/milestone_list.go b/models/issues/milestone_list.go index 955ab2356d..021b8beb9e 100644 --- a/models/issues/milestone_list.go +++ b/models/issues/milestone_list.go @@ -24,6 +24,18 @@ func (milestones MilestoneList) getMilestoneIDs() []int64 { return ids } +// SplitByOpenClosed splits the milestone list into open and closed milestones +func (milestones MilestoneList) SplitByOpenClosed() (open, closed MilestoneList) { + for _, m := range milestones { + if m.IsClosed { + closed = append(closed, m) + } else { + open = append(open, m) + } + } + return open, closed +} + // FindMilestoneOptions contain options to get milestones type FindMilestoneOptions struct { db.ListOptions diff --git a/models/issues/pull.go b/models/issues/pull.go index fb7dff3cc9..9f180f9ac9 100644 --- a/models/issues/pull.go +++ b/models/issues/pull.go @@ -467,13 +467,13 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *Iss issue.Index = idx issue.Title = util.EllipsisDisplayString(issue.Title, 255) + issue.IsPull = true if err = NewIssueWithIndex(ctx, issue.Poster, NewIssueOptions{ Repo: repo, Issue: issue, LabelIDs: labelIDs, Attachments: uuids, - IsPull: true, }); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) || IsErrNewIssueInsert(err) { return err @@ -658,17 +658,24 @@ func (pr *PullRequest) IsWorkInProgress(ctx context.Context) bool { // HasWorkInProgressPrefix determines if the given PR title has a Work In Progress prefix func HasWorkInProgressPrefix(title string) bool { - for _, prefix := range setting.Repository.PullRequest.WorkInProgressPrefixes { - if strings.HasPrefix(strings.ToUpper(title), strings.ToUpper(prefix)) { - return true - } - } - return false + _, ok := CutWorkInProgressPrefix(title) + return ok } -// IsFilesConflicted determines if the Pull Request has changes conflicting with the target branch. +func CutWorkInProgressPrefix(title string) (origTitle string, ok bool) { + for _, prefix := range setting.Repository.PullRequest.WorkInProgressPrefixes { + prefixLen := len(prefix) + if prefixLen <= len(title) && util.AsciiEqualFold(title[:prefixLen], prefix) { + return title[len(prefix):], true + } + } + return title, false +} + +// IsFilesConflicted determines if the Pull Request has changes conflicting with the target branch. +// Sometimes a conflict may not list any files func (pr *PullRequest) IsFilesConflicted() bool { - return len(pr.ConflictedFiles) > 0 + return pr.Status == PullRequestStatusConflict } // GetWorkInProgressPrefix returns the prefix used to mark the pull request as a work in progress. diff --git a/models/issues/pull_list.go b/models/issues/pull_list.go index 84f9f6166d..19d727ecbd 100644 --- a/models/issues/pull_list.go +++ b/models/issues/pull_list.go @@ -14,6 +14,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "xorm.io/builder" @@ -324,12 +325,26 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error) // HasMergedPullRequestInRepo returns whether the user(poster) has merged pull-request in the repo func HasMergedPullRequestInRepo(ctx context.Context, repoID, posterID int64) (bool, error) { - return db.GetEngine(ctx). + return HasMergedPullRequestInRepoBefore(ctx, repoID, posterID, 0, 0) +} + +// HasMergedPullRequestInRepoBefore returns whether the user has a merged PR before a timestamp (0 = no limit) +func HasMergedPullRequestInRepoBefore(ctx context.Context, repoID, posterID int64, beforeUnix timeutil.TimeStamp, excludePullID int64) (bool, error) { + sess := db.GetEngine(ctx). Join("INNER", "pull_request", "pull_request.issue_id = issue.id"). Where("repo_id=?", repoID). And("poster_id=?", posterID). And("is_pull=?", true). - And("pull_request.has_merged=?", true). + And("pull_request.has_merged=?", true) + + if beforeUnix > 0 { + sess.And("pull_request.merged_unix < ?", beforeUnix) + } + if excludePullID > 0 { + sess.And("pull_request.id != ?", excludePullID) + } + + return sess. Select("issue.id"). Limit(1). Get(new(Issue)) diff --git a/models/issues/pull_test.go b/models/issues/pull_test.go index 7089af253b..25b27cbe9c 100644 --- a/models/issues/pull_test.go +++ b/models/issues/pull_test.go @@ -130,7 +130,7 @@ func TestLoadRequestedReviewers(t *testing.T) { user1, err := user_model.GetUserByID(t.Context(), 1) assert.NoError(t, err) - comment, err := issues_model.AddReviewRequest(t.Context(), issue, user1, &user_model.User{}) + comment, err := issues_model.AddReviewRequest(t.Context(), issue, user1, &user_model.User{}, false) assert.NoError(t, err) assert.NotNil(t, comment) diff --git a/models/issues/review.go b/models/issues/review.go index b758fa5ffa..d8caa4d13a 100644 --- a/models/issues/review.go +++ b/models/issues/review.go @@ -643,7 +643,7 @@ func InsertReviews(ctx context.Context, reviews []*Review) error { } // AddReviewRequest add a review request from one reviewer -func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_model.User) (*Comment, error) { +func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_model.User, isCodeOwners bool) (*Comment, error) { return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) { sess := db.GetEngine(ctx) @@ -702,6 +702,7 @@ func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_mo RemovedAssignee: false, // Use RemovedAssignee as !isRequest AssigneeID: reviewer.ID, // Use AssigneeID as reviewer ID ReviewID: review.ID, + SpecialDoerName: util.Iif(isCodeOwners, SpecialDoerNameCodeOwners, ""), }) if err != nil { return nil, err @@ -767,7 +768,7 @@ func restoreLatestOfficialReview(ctx context.Context, issueID, reviewerID int64) } // AddTeamReviewRequest add a review request from one team -func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organization.Team, doer *user_model.User) (*Comment, error) { +func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organization.Team, doer *user_model.User, isCodeOwners bool) (*Comment, error) { return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) { review, err := GetTeamReviewerByIssueIDAndTeamID(ctx, issue.ID, reviewer.ID) if err != nil && !IsErrReviewNotExist(err) { @@ -812,6 +813,7 @@ func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organizat RemovedAssignee: false, // Use RemovedAssignee as !isRequest AssigneeTeamID: reviewer.ID, // Use AssigneeTeamID as reviewer team ID ReviewID: review.ID, + SpecialDoerName: util.Iif(isCodeOwners, SpecialDoerNameCodeOwners, ""), }) if err != nil { return nil, fmt.Errorf("CreateComment(): %w", err) diff --git a/models/issues/review_list.go b/models/issues/review_list.go index bbb8c489fa..86b1a2e76e 100644 --- a/models/issues/review_list.go +++ b/models/issues/review_list.go @@ -173,7 +173,7 @@ func GetReviewsByIssueID(ctx context.Context, issueID int64) (latestReviews, mig reviewersMap := make(map[int64][]*Review) // key is reviewer id originalReviewersMap := make(map[int64][]*Review) // key is original author id reviewTeamsMap := make(map[int64][]*Review) // key is reviewer team id - countedReivewTypes := []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest} + countedReivewTypes := []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest, ReviewTypeComment} for _, review := range reviews { if review.ReviewerTeamID == 0 && slices.Contains(countedReivewTypes, review.Type) && !review.Dismissed { if review.OriginalAuthorID != 0 { diff --git a/models/issues/review_test.go b/models/issues/review_test.go index 7b8537cc7d..092d88d174 100644 --- a/models/issues/review_test.go +++ b/models/issues/review_test.go @@ -122,6 +122,7 @@ func TestGetReviewersByIssueID(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) @@ -129,6 +130,12 @@ func TestGetReviewersByIssueID(t *testing.T) { expectedReviews := []*issues_model.Review{} expectedReviews = append(expectedReviews, + &issues_model.Review{ + ID: 5, + Reviewer: user1, + Type: issues_model.ReviewTypeComment, + UpdatedUnix: 946684810, + }, &issues_model.Review{ ID: 7, Reviewer: org3, @@ -167,8 +174,9 @@ func TestGetReviewersByIssueID(t *testing.T) { for _, review := range allReviews { assert.NoError(t, review.LoadReviewer(t.Context())) } - if assert.Len(t, allReviews, 5) { + if assert.Len(t, allReviews, 6) { for i, review := range allReviews { + assert.Equal(t, expectedReviews[i].ID, review.ID) assert.Equal(t, expectedReviews[i].Reviewer, review.Reviewer) assert.Equal(t, expectedReviews[i].Type, review.Type) assert.Equal(t, expectedReviews[i].UpdatedUnix, review.UpdatedUnix) @@ -313,14 +321,28 @@ func TestAddReviewRequest(t *testing.T) { pull.HasMerged = false assert.NoError(t, pull.UpdateCols(t.Context(), "has_merged")) issue.IsClosed = true - _, err = issues_model.AddReviewRequest(t.Context(), issue, reviewer, &user_model.User{}) + _, err = issues_model.AddReviewRequest(t.Context(), issue, reviewer, &user_model.User{}, false) assert.Error(t, err) assert.True(t, issues_model.IsErrReviewRequestOnClosedPR(err)) pull.HasMerged = true assert.NoError(t, pull.UpdateCols(t.Context(), "has_merged")) issue.IsClosed = false - _, err = issues_model.AddReviewRequest(t.Context(), issue, reviewer, &user_model.User{}) + _, err = issues_model.AddReviewRequest(t.Context(), issue, reviewer, &user_model.User{}, false) assert.Error(t, err) assert.True(t, issues_model.IsErrReviewRequestOnClosedPR(err)) + + // Test CODEOWNERS review request stores metadata correctly + pull2 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}) + assert.NoError(t, pull2.LoadIssue(t.Context())) + issue2 := pull2.Issue + assert.NoError(t, issue2.LoadRepo(t.Context())) + reviewer2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 7}) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + comment, err := issues_model.AddReviewRequest(t.Context(), issue2, reviewer2, doer, true) + assert.NoError(t, err) + assert.NotNil(t, comment) + assert.NotNil(t, comment.CommentMetaData) + assert.Equal(t, issues_model.SpecialDoerNameCodeOwners, comment.CommentMetaData.SpecialDoerName) } diff --git a/models/issues/stopwatch.go b/models/issues/stopwatch.go index 761b8f91a0..f119951b09 100644 --- a/models/issues/stopwatch.go +++ b/models/issues/stopwatch.go @@ -12,6 +12,8 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" ) // Stopwatch represents a stopwatch for time tracking. @@ -232,3 +234,14 @@ func CancelStopwatch(ctx context.Context, user *user_model.User, issue *Issue) ( }) return ok, err } + +// RemoveStopwatchesByRepoID removes all stopwatches for a user in a specific repository +// this function should be called before removing all the issues of the repository +func RemoveStopwatchesByRepoID(ctx context.Context, userID, repoID int64) error { + _, err := db.GetEngine(ctx). + Where("`stopwatch`.user_id = ?", userID). + And(builder.In("`stopwatch`.issue_id", + builder.Select("id").From("issue").Where(builder.Eq{"repo_id": repoID}))). + Delete(new(Stopwatch)) + return err +} diff --git a/models/migrations/base/db.go b/models/migrations/base/db.go index 479a46379c..3b8f0e00a0 100644 --- a/models/migrations/base/db.go +++ b/models/migrations/base/db.go @@ -5,21 +5,14 @@ package base import ( "context" - "database/sql" "errors" "fmt" - "os" - "path" "reflect" "regexp" "strings" - "time" - "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "xorm.io/xorm" "xorm.io/xorm/schemas" @@ -515,114 +508,3 @@ func ModifyColumn(x *xorm.Engine, tableName string, col *schemas.Column) error { } return nil } - -func removeAllWithRetry(dir string) error { - var err error - for range 20 { - err = os.RemoveAll(dir) - if err == nil { - break - } - time.Sleep(100 * time.Millisecond) - } - return err -} - -func newXORMEngine() (*xorm.Engine, error) { - if err := db.InitEngine(context.Background()); err != nil { - return nil, err - } - x := unittest.GetXORMEngine() - return x, nil -} - -func deleteDB() error { - switch { - case setting.Database.Type.IsSQLite3(): - if err := util.Remove(setting.Database.Path); err != nil { - return err - } - return os.MkdirAll(path.Dir(setting.Database.Path), os.ModePerm) - - case setting.Database.Type.IsMySQL(): - db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/", - setting.Database.User, setting.Database.Passwd, setting.Database.Host)) - if err != nil { - return err - } - defer db.Close() - - if _, err = db.Exec("DROP DATABASE IF EXISTS " + setting.Database.Name); err != nil { - return err - } - - if _, err = db.Exec("CREATE DATABASE IF NOT EXISTS " + setting.Database.Name); err != nil { - return err - } - return nil - case setting.Database.Type.IsPostgreSQL(): - db, err := sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/?sslmode=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.SSLMode)) - if err != nil { - return err - } - defer db.Close() - - if _, err = db.Exec("DROP DATABASE IF EXISTS " + setting.Database.Name); err != nil { - return err - } - - if _, err = db.Exec("CREATE DATABASE " + setting.Database.Name); err != nil { - return err - } - db.Close() - - // Check if we need to setup a specific schema - if len(setting.Database.Schema) != 0 { - db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode)) - if err != nil { - return err - } - defer db.Close() - - schrows, err := db.Query(fmt.Sprintf("SELECT 1 FROM information_schema.schemata WHERE schema_name = '%s'", setting.Database.Schema)) - if err != nil { - return err - } - defer schrows.Close() - - if !schrows.Next() { - // Create and setup a DB schema - _, err = db.Exec("CREATE SCHEMA " + setting.Database.Schema) - if err != nil { - return err - } - } - - // Make the user's default search path the created schema; this will affect new connections - _, err = db.Exec(fmt.Sprintf(`ALTER USER "%s" SET search_path = %s`, setting.Database.User, setting.Database.Schema)) - if err != nil { - return err - } - return nil - } - case setting.Database.Type.IsMSSQL(): - host, port := setting.ParseMSSQLHostPort(setting.Database.Host) - db, err := sql.Open("mssql", fmt.Sprintf("server=%s; port=%s; database=%s; user id=%s; password=%s;", - host, port, "master", setting.Database.User, setting.Database.Passwd)) - if err != nil { - return err - } - defer db.Close() - - if _, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS [%s]", setting.Database.Name)); err != nil { - return err - } - if _, err = db.Exec(fmt.Sprintf("CREATE DATABASE [%s]", setting.Database.Name)); err != nil { - return err - } - } - - return nil -} diff --git a/models/migrations/base/db_test.go b/models/migrations/base/db_test.go index 80bf00b22a..00635ca72e 100644 --- a/models/migrations/base/db_test.go +++ b/models/migrations/base/db_test.go @@ -11,6 +11,10 @@ import ( "xorm.io/xorm/names" ) +func TestMain(m *testing.M) { + MainTest(m) +} + func Test_DropTableColumns(t *testing.T) { x, deferable := PrepareTestEnv(t, 0) if x == nil || t.Failed() { diff --git a/models/migrations/base/main_test.go b/models/migrations/base/main_test.go deleted file mode 100644 index c1c789150f..0000000000 --- a/models/migrations/base/main_test.go +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package base - -import ( - "testing" -) - -func TestMain(m *testing.M) { - MainTest(m) -} diff --git a/models/migrations/base/tests.go b/models/migrations/base/tests.go index 3b52a5e7c7..36afd35dd4 100644 --- a/models/migrations/base/tests.go +++ b/models/migrations/base/tests.go @@ -4,25 +4,140 @@ package base import ( + "database/sql" "fmt" "os" + "path" "path/filepath" - "runtime" "testing" + "time" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/tempdir" - "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/testlogger" + "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/require" "xorm.io/xorm" + "xorm.io/xorm/schemas" ) // FIXME: this file shouldn't be in a normal package, it should only be compiled for tests +func removeAllWithRetry(dir string) error { + var err error + for range 20 { + err = os.RemoveAll(dir) + if err == nil { + break + } + time.Sleep(100 * time.Millisecond) + } + return err +} + +func newXORMEngine(t *testing.T) (*xorm.Engine, error) { + if err := db.InitEngine(t.Context()); err != nil { + return nil, err + } + x := unittest.GetXORMEngine() + return x, nil +} + +func deleteDB() error { + switch { + case setting.Database.Type.IsSQLite3(): + if err := util.Remove(setting.Database.Path); err != nil { + return err + } + return os.MkdirAll(path.Dir(setting.Database.Path), os.ModePerm) + + case setting.Database.Type.IsMySQL(): + db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/", + setting.Database.User, setting.Database.Passwd, setting.Database.Host)) + if err != nil { + return err + } + defer db.Close() + + if _, err = db.Exec("DROP DATABASE IF EXISTS " + setting.Database.Name); err != nil { + return err + } + + if _, err = db.Exec("CREATE DATABASE IF NOT EXISTS " + setting.Database.Name); err != nil { + return err + } + return nil + case setting.Database.Type.IsPostgreSQL(): + db, err := sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/?sslmode=%s", + setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.SSLMode)) + if err != nil { + return err + } + defer db.Close() + + if _, err = db.Exec("DROP DATABASE IF EXISTS " + setting.Database.Name); err != nil { + return err + } + + if _, err = db.Exec("CREATE DATABASE " + setting.Database.Name); err != nil { + return err + } + db.Close() + + // Check if we need to setup a specific schema + if len(setting.Database.Schema) != 0 { + db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", + setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode)) + if err != nil { + return err + } + defer db.Close() + + schrows, err := db.Query(fmt.Sprintf("SELECT 1 FROM information_schema.schemata WHERE schema_name = '%s'", setting.Database.Schema)) + if err != nil { + return err + } + defer schrows.Close() + + if !schrows.Next() { + // Create and setup a DB schema + _, err = db.Exec("CREATE SCHEMA " + setting.Database.Schema) + if err != nil { + return err + } + } + + // Make the user's default search path the created schema; this will affect new connections + _, err = db.Exec(fmt.Sprintf(`ALTER USER "%s" SET search_path = %s`, setting.Database.User, setting.Database.Schema)) + if err != nil { + return err + } + return nil + } + case setting.Database.Type.IsMSSQL(): + host, port := setting.ParseMSSQLHostPort(setting.Database.Host) + db, err := sql.Open("mssql", fmt.Sprintf("server=%s; port=%s; database=%s; user id=%s; password=%s;", + host, port, "master", setting.Database.User, setting.Database.Passwd)) + if err != nil { + return err + } + defer db.Close() + + if _, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS [%s]", setting.Database.Name)); err != nil { + return err + } + if _, err = db.Exec(fmt.Sprintf("CREATE DATABASE [%s]", setting.Database.Name)); err != nil { + return err + } + } + + return nil +} + // PrepareTestEnv prepares the test environment and reset the database. The skip parameter should usually be 0. // Provide models to be sync'd with the database - in particular any models you expect fixtures to be loaded from. // @@ -39,7 +154,7 @@ func PrepareTestEnv(t *testing.T, skip int, syncModels ...any) (*xorm.Engine, fu return nil, deferFn } - x, err := newXORMEngine() + x, err := newXORMEngine(t) require.NoError(t, err) if x != nil { oldDefer := deferFn @@ -88,30 +203,19 @@ func PrepareTestEnv(t *testing.T, skip int, syncModels ...any) (*xorm.Engine, fu return x, deferFn } +func LoadTableSchemasMap(t *testing.T, x *xorm.Engine) map[string]*schemas.Table { + tables, err := x.DBMetas() + require.NoError(t, err) + tableMap := make(map[string]*schemas.Table) + for _, table := range tables { + tableMap[table.Name] = table + } + return tableMap +} + func MainTest(m *testing.M) { testlogger.Init() - - giteaRoot := test.SetupGiteaRoot() - giteaBinary := "gitea" - if runtime.GOOS == "windows" { - giteaBinary += ".exe" - } - setting.AppPath = filepath.Join(giteaRoot, giteaBinary) - if _, err := os.Stat(setting.AppPath); err != nil { - testlogger.Fatalf("Could not find gitea binary at %s\n", setting.AppPath) - } - - giteaConf := os.Getenv("GITEA_CONF") - if giteaConf == "" { - giteaConf = filepath.Join(filepath.Dir(setting.AppPath), "tests/sqlite.ini") - _, _ = fmt.Fprintf(os.Stderr, "Environment variable $GITEA_CONF not set - defaulting to %s\n", giteaConf) - } - - if !filepath.IsAbs(giteaConf) { - setting.CustomConf = filepath.Join(giteaRoot, giteaConf) - } else { - setting.CustomConf = giteaConf - } + setting.SetupGiteaTestEnv() tmpDataPath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("data") if err != nil { @@ -119,7 +223,6 @@ func MainTest(m *testing.M) { } defer cleanup() - setting.CustomPath = filepath.Join(setting.AppWorkPath, "custom") setting.AppDataPath = tmpDataPath unittest.InitSettingsForTesting() diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 1b1558f39d..9975729fd6 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -25,6 +25,7 @@ import ( "code.gitea.io/gitea/models/migrations/v1_23" "code.gitea.io/gitea/models/migrations/v1_24" "code.gitea.io/gitea/models/migrations/v1_25" + "code.gitea.io/gitea/models/migrations/v1_26" "code.gitea.io/gitea/models/migrations/v1_6" "code.gitea.io/gitea/models/migrations/v1_7" "code.gitea.io/gitea/models/migrations/v1_8" @@ -379,8 +380,8 @@ func prepareMigrationTasks() []*migration { newMigration(309, "Improve Notification table indices", v1_23.ImproveNotificationTableIndices), newMigration(310, "Add Priority to ProtectedBranch", v1_23.AddPriorityToProtectedBranch), newMigration(311, "Add TimeEstimate to Issue table", v1_23.AddTimeEstimateColumnToIssueTable), - // Gitea 1.23.0-rc0 ends at migration ID number 311 (database version 312) + newMigration(312, "Add DeleteBranchAfterMerge to AutoMerge", v1_24.AddDeleteBranchAfterMergeForAutoMerge), newMigration(313, "Move PinOrder from issue table to a new table issue_pin", v1_24.MovePinOrderToTableIssuePin), newMigration(314, "Update OwnerID as zero for repository level action tables", v1_24.UpdateOwnerIDOfRepoLevelActionsTables), @@ -390,10 +391,15 @@ func prepareMigrationTasks() []*migration { newMigration(318, "Add anonymous_access_mode for repo_unit", v1_24.AddRepoUnitAnonymousAccessMode), newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable), newMigration(320, "Migrate two_factor_policy to login_source table", v1_24.MigrateSkipTwoFactor), + // Gitea 1.24.0 ends at migration ID number 320 (database version 321) - // Gitea 1.24.0 ends at database version 321 newMigration(321, "Use LONGTEXT for some columns and fix review_state.updated_files column", v1_25.UseLongTextInSomeColumnsAndFixBugs), newMigration(322, "Extend comment tree_path length limit", v1_25.ExtendCommentTreePathLength), + // Gitea 1.25.0 ends at migration ID number 322 (database version 323) + + newMigration(323, "Add support for actions concurrency", v1_26.AddActionsConcurrency), + newMigration(324, "Fix closed milestone completeness for milestones with no issues", v1_26.FixClosedMilestoneCompleteness), + newMigration(325, "Fix missed repo_id when migrate attachments", v1_26.FixMissedRepoIDWhenMigrateAttachments), } return preparedMigrations } diff --git a/models/migrations/v1_18/v229.go b/models/migrations/v1_18/v229.go index bc15e01390..1f69724365 100644 --- a/models/migrations/v1_18/v229.go +++ b/models/migrations/v1_18/v229.go @@ -21,6 +21,7 @@ func UpdateOpenMilestoneCounts(x *xorm.Engine) error { for _, id := range openMilestoneIDs { _, err := x.ID(id). + Cols("num_issues", "num_closed_issues"). SetExpr("num_issues", builder.Select("count(*)").From("issue").Where( builder.Eq{"milestone_id": id}, )). diff --git a/models/migrations/v1_21/v276.go b/models/migrations/v1_21/v276.go index 3ab7e22cd0..be24b31902 100644 --- a/models/migrations/v1_21/v276.go +++ b/models/migrations/v1_21/v276.go @@ -5,14 +5,10 @@ package v1_21 import ( "context" - "fmt" - "path/filepath" - "strings" - "code.gitea.io/gitea/modules/git" - giturl "code.gitea.io/gitea/modules/git/url" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "xorm.io/xorm" ) @@ -163,16 +159,13 @@ func migratePushMirrors(x *xorm.Engine) error { } func getRemoteAddress(ownerName, repoName, remoteName string) (string, error) { - repoPath := filepath.Join(setting.RepoRootPath, strings.ToLower(ownerName), strings.ToLower(repoName)+".git") - if exist, _ := util.IsExist(repoPath); !exist { + ctx := context.Background() + relativePath := repo_model.RelativePath(ownerName, repoName) + if exist, _ := gitrepo.IsRepositoryExist(ctx, repo_model.StorageRepo(relativePath)); !exist { return "", nil } - remoteURL, err := git.GetRemoteAddress(context.Background(), repoPath, remoteName) - if err != nil { - return "", fmt.Errorf("get remote %s's address of %s/%s failed: %v", remoteName, ownerName, repoName, err) - } - u, err := giturl.ParseGitURL(remoteURL) + u, err := gitrepo.GitRemoteGetURL(ctx, repo_model.StorageRepo(relativePath), remoteName) if err != nil { return "", err } diff --git a/models/migrations/v1_25/v321_test.go b/models/migrations/v1_25/v321_test.go index 4897783fd3..3ef2c68aa3 100644 --- a/models/migrations/v1_25/v321_test.go +++ b/models/migrations/v1_25/v321_test.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_UseLongTextInSomeColumnsAndFixBugs(t *testing.T) { @@ -38,33 +39,26 @@ func Test_UseLongTextInSomeColumnsAndFixBugs(t *testing.T) { type Notice struct { ID int64 `xorm:"pk autoincr"` Type int - Description string `xorm:"LONGTEXT"` + Description string `xorm:"TEXT"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` } // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(ReviewState), new(PackageProperty), new(Notice)) - defer deferable() + x, deferrable := base.PrepareTestEnv(t, 0, new(ReviewState), new(PackageProperty), new(Notice)) + defer deferrable() - assert.NoError(t, UseLongTextInSomeColumnsAndFixBugs(x)) + require.NoError(t, UseLongTextInSomeColumnsAndFixBugs(x)) - tables, err := x.DBMetas() - assert.NoError(t, err) + tables := base.LoadTableSchemasMap(t, x) + table := tables["review_state"] + column := table.GetColumn("updated_files") + assert.Equal(t, "LONGTEXT", column.SQLType.Name) - for _, table := range tables { - switch table.Name { - case "review_state": - column := table.GetColumn("updated_files") - assert.NotNil(t, column) - assert.Equal(t, "LONGTEXT", column.SQLType.Name) - case "package_property": - column := table.GetColumn("value") - assert.NotNil(t, column) - assert.Equal(t, "LONGTEXT", column.SQLType.Name) - case "notice": - column := table.GetColumn("description") - assert.NotNil(t, column) - assert.Equal(t, "LONGTEXT", column.SQLType.Name) - } - } + table = tables["package_property"] + column = table.GetColumn("value") + assert.Equal(t, "LONGTEXT", column.SQLType.Name) + + table = tables["notice"] + column = table.GetColumn("description") + assert.Equal(t, "LONGTEXT", column.SQLType.Name) } diff --git a/models/migrations/v1_25/v322_test.go b/models/migrations/v1_25/v322_test.go new file mode 100644 index 0000000000..78d890704c --- /dev/null +++ b/models/migrations/v1_25/v322_test.go @@ -0,0 +1,34 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_25 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ExtendCommentTreePathLength(t *testing.T) { + if setting.Database.Type.IsSQLite3() { + t.Skip("For SQLITE, varchar or char will always be represented as TEXT") + } + + type Comment struct { + ID int64 `xorm:"pk autoincr"` + TreePath string `xorm:"VARCHAR(255)"` + } + + x, deferrable := base.PrepareTestEnv(t, 0, new(Comment)) + defer deferrable() + + require.NoError(t, ExtendCommentTreePathLength(x)) + table := base.LoadTableSchemasMap(t, x)["comment"] + column := table.GetColumn("tree_path") + assert.Contains(t, []string{"NVARCHAR", "VARCHAR"}, column.SQLType.Name) + assert.EqualValues(t, 4000, column.Length) +} diff --git a/models/migrations/v1_26/main_test.go b/models/migrations/v1_26/main_test.go new file mode 100644 index 0000000000..5aa12d553c --- /dev/null +++ b/models/migrations/v1_26/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" +) + +func TestMain(m *testing.M) { + base.MainTest(m) +} diff --git a/models/migrations/v1_26/v323.go b/models/migrations/v1_26/v323.go new file mode 100644 index 0000000000..b116f73bf0 --- /dev/null +++ b/models/migrations/v1_26/v323.go @@ -0,0 +1,43 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "xorm.io/xorm" +) + +func AddActionsConcurrency(x *xorm.Engine) error { + type ActionRun struct { + RepoID int64 `xorm:"index(repo_concurrency)"` + RawConcurrency string + ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` + ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` + } + + if _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + }, new(ActionRun)); err != nil { + return err + } + + if err := x.Sync(new(ActionRun)); err != nil { + return err + } + + type ActionRunJob struct { + RepoID int64 `xorm:"index(repo_concurrency)"` + RawConcurrency string + IsConcurrencyEvaluated bool + ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` + ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` + } + + if _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + }, new(ActionRunJob)); err != nil { + return err + } + + return nil +} diff --git a/models/migrations/v1_26/v324.go b/models/migrations/v1_26/v324.go new file mode 100644 index 0000000000..5d96bfa3ca --- /dev/null +++ b/models/migrations/v1_26/v324.go @@ -0,0 +1,24 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "fmt" + + "xorm.io/xorm" +) + +func FixClosedMilestoneCompleteness(x *xorm.Engine) error { + // Update all milestones to recalculate completeness with the new logic: + // - Closed milestones with 0 issues should show 100% + // - All other milestones should calculate based on closed/total ratio + _, err := x.Exec("UPDATE `milestone` SET completeness=(CASE WHEN is_closed = ? AND num_issues = 0 THEN 100 ELSE 100*num_closed_issues/(CASE WHEN num_issues > 0 THEN num_issues ELSE 1 END) END)", + true, + ) + if err != nil { + return fmt.Errorf("error updating milestone completeness: %w", err) + } + + return nil +} diff --git a/models/migrations/v1_26/v325.go b/models/migrations/v1_26/v325.go new file mode 100644 index 0000000000..d81540f44e --- /dev/null +++ b/models/migrations/v1_26/v325.go @@ -0,0 +1,18 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "xorm.io/xorm" +) + +func FixMissedRepoIDWhenMigrateAttachments(x *xorm.Engine) error { + _, err := x.Exec("UPDATE `attachment` SET `repo_id` = (SELECT `repo_id` FROM `issue` WHERE `issue`.`id` = `attachment`.`issue_id`) WHERE `issue_id` > 0 AND (`repo_id` IS NULL OR `repo_id` = 0);") + if err != nil { + return err + } + + _, err = x.Exec("UPDATE `attachment` SET `repo_id` = (SELECT `repo_id` FROM `release` WHERE `release`.`id` = `attachment`.`release_id`) WHERE `release_id` > 0 AND (`repo_id` IS NULL OR `repo_id` = 0);") + return err +} diff --git a/models/migrations/v1_26/v325_test.go b/models/migrations/v1_26/v325_test.go new file mode 100644 index 0000000000..d4a66fee81 --- /dev/null +++ b/models/migrations/v1_26/v325_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/modules/timeutil" + + "github.com/stretchr/testify/require" +) + +func Test_FixMissedRepoIDWhenMigrateAttachments(t *testing.T) { + type Attachment struct { + ID int64 `xorm:"pk autoincr"` + UUID string `xorm:"uuid UNIQUE"` + RepoID int64 `xorm:"INDEX"` // this should not be zero + IssueID int64 `xorm:"INDEX"` // maybe zero when creating + ReleaseID int64 `xorm:"INDEX"` // maybe zero when creating + UploaderID int64 `xorm:"INDEX DEFAULT 0"` // Notice: will be zero before this column added + CommentID int64 `xorm:"INDEX"` + Name string + DownloadCount int64 `xorm:"DEFAULT 0"` + Size int64 `xorm:"DEFAULT 0"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + } + + type Issue struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX"` + } + + type Release struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX"` + } + + // Prepare and load the testing database + x, deferrable := base.PrepareTestEnv(t, 0, new(Attachment), new(Issue), new(Release)) + defer deferrable() + + require.NoError(t, FixMissedRepoIDWhenMigrateAttachments(x)) +} diff --git a/models/migrations/v1_9/v82.go b/models/migrations/v1_9/v82.go index f0307bf07a..8796b0563d 100644 --- a/models/migrations/v1_9/v82.go +++ b/models/migrations/v1_9/v82.go @@ -6,11 +6,10 @@ package v1_9 import ( "context" "fmt" - "path/filepath" - "strings" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/gitrepo" "xorm.io/xorm" ) @@ -34,16 +33,6 @@ func FixReleaseSha1OnReleaseTable(ctx context.Context, x *xorm.Engine) error { Name string } - // UserPath returns the path absolute path of user repositories. - UserPath := func(userName string) string { - return filepath.Join(setting.RepoRootPath, strings.ToLower(userName)) - } - - // RepoPath returns repository path by given user and repository name. - RepoPath := func(userName, repoName string) string { - return filepath.Join(UserPath(userName), strings.ToLower(repoName)+".git") - } - // Update release sha1 const batchSize = 100 sess := x.NewSession() @@ -99,7 +88,7 @@ func FixReleaseSha1OnReleaseTable(ctx context.Context, x *xorm.Engine) error { userCache[repo.OwnerID] = user } - gitRepo, err = git.OpenRepository(ctx, RepoPath(user.Name, repo.Name)) + gitRepo, err = gitrepo.OpenRepository(ctx, repo_model.StorageRepo(repo_model.RelativePath(user.Name, repo.Name))) if err != nil { return err } diff --git a/models/organization/org.go b/models/organization/org.go index 9ece044d6c..b4d28f5405 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -429,6 +429,10 @@ func HasOrgOrUserVisible(ctx context.Context, orgOrUser, user *user_model.User) return true } + if !setting.Service.RequireSignInViewStrict && orgOrUser.Visibility == structs.VisibleTypePublic { + return true + } + if (orgOrUser.Visibility == structs.VisibleTypePrivate || user.IsRestricted) && !OrgFromUser(orgOrUser).hasMemberWithUserID(ctx, user.ID) { return false } diff --git a/models/organization/org_test.go b/models/organization/org_test.go index e7c4d2f9f7..7a74c5f5fc 100644 --- a/models/organization/org_test.go +++ b/models/organization/org_test.go @@ -13,7 +13,9 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -382,6 +384,12 @@ func TestHasOrgVisibleTypePublic(t *testing.T) { assert.True(t, test1) // owner of org assert.True(t, test2) // user not a part of org assert.True(t, test3) // logged out user + + restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29, IsRestricted: true}) + require.True(t, restrictedUser.IsRestricted) + assert.True(t, organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), restrictedUser)) + defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)() + assert.False(t, organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), restrictedUser)) } func TestHasOrgVisibleTypeLimited(t *testing.T) { diff --git a/models/organization/team_repo.go b/models/organization/team_repo.go index b3e266dbc7..2652b34c6f 100644 --- a/models/organization/team_repo.go +++ b/models/organization/team_repo.go @@ -53,24 +53,45 @@ func RemoveTeamRepo(ctx context.Context, teamID, repoID int64) error { // GetTeamsWithAccessToAnyRepoUnit returns all teams in an organization that have given access level to the repository special unit. // This function is only used for finding some teams that can be used as branch protection allowlist or reviewers, it isn't really used for access control. // FIXME: TEAM-UNIT-PERMISSION this logic is not complete, search the fixme keyword to see more details -func GetTeamsWithAccessToAnyRepoUnit(ctx context.Context, orgID, repoID int64, mode perm.AccessMode, unitType unit.Type, unitTypesMore ...unit.Type) ([]*Team, error) { - teams := make([]*Team, 0, 5) +func GetTeamsWithAccessToAnyRepoUnit(ctx context.Context, orgID, repoID int64, mode perm.AccessMode, unitType unit.Type, unitTypesMore ...unit.Type) (teams []*Team, err error) { + teamIDs, err := getTeamIDsWithAccessToAnyRepoUnit(ctx, orgID, repoID, mode, unitType, unitTypesMore...) + if err != nil { + return nil, err + } + if len(teamIDs) == 0 { + return teams, nil + } + err = db.GetEngine(ctx).Where(builder.In("id", teamIDs)).OrderBy("team.name").Find(&teams) + return teams, err +} +func getTeamIDsWithAccessToAnyRepoUnit(ctx context.Context, orgID, repoID int64, mode perm.AccessMode, unitType unit.Type, unitTypesMore ...unit.Type) (teamIDs []int64, err error) { sub := builder.Select("team_id").From("team_unit"). Where(builder.Expr("team_unit.team_id = team.id")). And(builder.In("team_unit.type", append([]unit.Type{unitType}, unitTypesMore...))). And(builder.Expr("team_unit.access_mode >= ?", mode)) - err := db.GetEngine(ctx). + err = db.GetEngine(ctx). + Select("team.id"). + Table("team"). Join("INNER", "team_repo", "team_repo.team_id = team.id"). - And("team_repo.org_id = ?", orgID). - And("team_repo.repo_id = ?", repoID). + And("team_repo.org_id = ? AND team_repo.repo_id = ?", orgID, repoID). And(builder.Or( builder.Expr("team.authorize >= ?", mode), builder.In("team.id", sub), )). - OrderBy("name"). - Find(&teams) - - return teams, err + Find(&teamIDs) + return teamIDs, err +} + +func GetTeamUserIDsWithAccessToAnyRepoUnit(ctx context.Context, orgID, repoID int64, mode perm.AccessMode, unitType unit.Type, unitTypesMore ...unit.Type) (userIDs []int64, err error) { + teamIDs, err := getTeamIDsWithAccessToAnyRepoUnit(ctx, orgID, repoID, mode, unitType, unitTypesMore...) + if err != nil { + return nil, err + } + if len(teamIDs) == 0 { + return userIDs, nil + } + err = db.GetEngine(ctx).Table("team_user").Select("uid").Where(builder.In("team_id", teamIDs)).Find(&userIDs) + return userIDs, err } diff --git a/models/packages/package_blob.go b/models/packages/package_blob.go index d9c30b6533..e765bbf0c2 100644 --- a/models/packages/package_blob.go +++ b/models/packages/package_blob.go @@ -43,13 +43,15 @@ func GetOrInsertBlob(ctx context.Context, pb *PackageBlob) (*PackageBlob, bool, existing := &PackageBlob{} - has, err := e.Where(builder.Eq{ + hashCond := builder.Eq{ "size": pb.Size, "hash_md5": pb.HashMD5, "hash_sha1": pb.HashSHA1, "hash_sha256": pb.HashSHA256, "hash_sha512": pb.HashSHA512, - }).Get(existing) + } + + has, err := e.Where(hashCond).Get(existing) if err != nil { return nil, false, err } @@ -57,6 +59,11 @@ func GetOrInsertBlob(ctx context.Context, pb *PackageBlob) (*PackageBlob, bool, return existing, true, nil } if _, err = e.Insert(pb); err != nil { + // Handle race condition: another request may have inserted the same blob + // between our SELECT and INSERT. Retry the SELECT to get the existing blob. + if has, _ = e.Where(hashCond).Get(existing); has { + return existing, true, nil + } return nil, false, err } return pb, false, nil diff --git a/models/packages/package_blob_test.go b/models/packages/package_blob_test.go new file mode 100644 index 0000000000..8b636b4ee0 --- /dev/null +++ b/models/packages/package_blob_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package packages + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" +) + +func TestGetOrInsertBlobConcurrent(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + testBlob := PackageBlob{ + Size: 123, + HashMD5: "md5", + HashSHA1: "sha1", + HashSHA256: "sha256", + HashSHA512: "sha512", + } + + const numGoroutines = 3 + var wg errgroup.Group + results := make([]*PackageBlob, numGoroutines) + existed := make([]bool, numGoroutines) + for idx := range numGoroutines { + wg.Go(func() error { + blob := testBlob // Create a copy of the test blob for each goroutine + var err error + results[idx], existed[idx], err = GetOrInsertBlob(t.Context(), &blob) + return err + }) + } + require.NoError(t, wg.Wait()) + + // then: all GetOrInsertBlob succeeds with the same blob ID, and only one indicates it did not exist before + existedCount := 0 + assert.NotNil(t, results[0]) + for i := range numGoroutines { + assert.Equal(t, results[0].ID, results[i].ID) + if existed[i] { + existedCount++ + } + } + assert.Equal(t, numGoroutines-1, existedCount) +} diff --git a/models/perm/access/access.go b/models/perm/access/access.go index 6a0a901f71..6433c4675c 100644 --- a/models/perm/access/access.go +++ b/models/perm/access/access.go @@ -13,6 +13,8 @@ import ( "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" "xorm.io/builder" ) @@ -41,7 +43,12 @@ func accessLevel(ctx context.Context, user *user_model.User, repo *repo_model.Re restricted = user.IsRestricted } - if !restricted && !repo.IsPrivate { + if err := repo.LoadOwner(ctx); err != nil { + return mode, err + } + + repoIsFullyPublic := !setting.Service.RequireSignInViewStrict && repo.Owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate + if (restricted && repoIsFullyPublic) || (!restricted && !repo.IsPrivate) { mode = perm.AccessModeRead } diff --git a/models/perm/access/access_test.go b/models/perm/access/access_test.go index f01993ab4e..15d18b368c 100644 --- a/models/perm/access/access_test.go +++ b/models/perm/access/access_test.go @@ -12,6 +12,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" ) @@ -51,7 +52,14 @@ func TestAccessLevel(t *testing.T) { assert.NoError(t, err) assert.Equal(t, perm_model.AccessModeNone, level) - // restricted user has no access to a public repo + // restricted user has default access to a public repo if no sign-in is required + setting.Service.RequireSignInViewStrict = false + level, err = access_model.AccessLevel(t.Context(), user29, repo1) + assert.NoError(t, err) + assert.Equal(t, perm_model.AccessModeRead, level) + + // restricted user has no access to a public repo if sign-in is required + setting.Service.RequireSignInViewStrict = true level, err = access_model.AccessLevel(t.Context(), user29, repo1) assert.NoError(t, err) assert.Equal(t, perm_model.AccessModeNone, level) diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index 678b18442e..3235d83203 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -5,15 +5,19 @@ package access import ( "context" + "errors" "fmt" "slices" + "strings" + actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" perm_model "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -166,7 +170,8 @@ func (p *Permission) ReadableUnitTypes() []unit.Type { } func (p *Permission) LogString() string { - format := "") + return fmt.Sprintf(format.String(), args...) } func applyPublicAccessPermission(unitType unit.Type, accessMode perm_model.AccessMode, modeMap *map[unit.Type]perm_model.AccessMode) { @@ -253,6 +258,50 @@ func finalProcessRepoUnitPermission(user *user_model.User, perm *Permission) { } } +// GetActionsUserRepoPermission returns the actions user permissions to the repository +func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Repository, actionsUser *user_model.User, taskID int64) (perm Permission, err error) { + if actionsUser.ID != user_model.ActionsUserID { + return perm, errors.New("api GetActionsUserRepoPermission can only be called by the actions user") + } + task, err := actions_model.GetTaskByID(ctx, taskID) + if err != nil { + return perm, err + } + + var accessMode perm_model.AccessMode + if task.RepoID != repo.ID { + taskRepo, exist, err := db.GetByID[repo_model.Repository](ctx, task.RepoID) + if err != nil || !exist { + return perm, err + } + actionsCfg := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() + if !actionsCfg.IsCollaborativeOwner(taskRepo.OwnerID) || !taskRepo.IsPrivate { + // The task repo can access the current repo only if the task repo is private and + // the owner of the task repo is a collaborative owner of the current repo. + // FIXME should owner's visibility also be considered here? + + // check permission like simple user but limit to read-only + perm, err = GetUserRepoPermission(ctx, repo, user_model.NewActionsUser()) + if err != nil { + return perm, err + } + perm.AccessMode = min(perm.AccessMode, perm_model.AccessModeRead) + return perm, nil + } + accessMode = perm_model.AccessModeRead + } else if task.IsForkPullRequest { + accessMode = perm_model.AccessModeRead + } else { + accessMode = perm_model.AccessModeWrite + } + + if err := repo.LoadUnits(ctx); err != nil { + return perm, err + } + perm.SetUnitsWithDefaultAccessMode(repo.Units, accessMode) + return perm, nil +} + // GetUserRepoPermission returns the user permissions to the repository func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (perm Permission, err error) { defer func() { @@ -458,54 +507,44 @@ func HasAnyUnitAccess(ctx context.Context, userID int64, repo *repo_model.Reposi return perm.HasAnyUnitAccess(), nil } -// getUsersWithAccessMode returns users that have at least given access mode to the repository. -func getUsersWithAccessMode(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode) (_ []*user_model.User, err error) { - if err = repo.LoadOwner(ctx); err != nil { +func GetUsersWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type) (users []*user_model.User, err error) { + userIDs, err := GetUserIDsWithUnitAccess(ctx, repo, mode, unitType) + if err != nil { return nil, err } - - e := db.GetEngine(ctx) - accesses := make([]*Access, 0, 10) - if err = e.Where("repo_id = ? AND mode >= ?", repo.ID, mode).Find(&accesses); err != nil { + if len(userIDs) == 0 { + return users, nil + } + if err = db.GetEngine(ctx).In("id", userIDs.Values()).OrderBy("`name`").Find(&users); err != nil { return nil, err } - - // Leave a seat for owner itself to append later, but if owner is an organization - // and just waste 1 unit is cheaper than re-allocate memory once. - users := make([]*user_model.User, 0, len(accesses)+1) - if len(accesses) > 0 { - userIDs := make([]int64, len(accesses)) - for i := 0; i < len(accesses); i++ { - userIDs[i] = accesses[i].UserID - } - - if err = e.In("id", userIDs).Find(&users); err != nil { - return nil, err - } - } - if !repo.Owner.IsOrganization() { - users = append(users, repo.Owner) - } - return users, nil } -// GetRepoReaders returns all users that have explicit read access or higher to the repository. -func GetRepoReaders(ctx context.Context, repo *repo_model.Repository) (_ []*user_model.User, err error) { - return getUsersWithAccessMode(ctx, repo, perm_model.AccessModeRead) -} - -// GetRepoWriters returns all users that have write access to the repository. -func GetRepoWriters(ctx context.Context, repo *repo_model.Repository) (_ []*user_model.User, err error) { - return getUsersWithAccessMode(ctx, repo, perm_model.AccessModeWrite) -} - -// IsRepoReader returns true if user has explicit read access or higher to the repository. -func IsRepoReader(ctx context.Context, repo *repo_model.Repository, userID int64) (bool, error) { - if repo.OwnerID == userID { - return true, nil +func GetUserIDsWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type) (container.Set[int64], error) { + userIDs := container.Set[int64]{} + e := db.GetEngine(ctx) + accesses := make([]*Access, 0, 10) + if err := e.Where("repo_id = ? AND mode >= ?", repo.ID, mode).Find(&accesses); err != nil { + return nil, err } - return db.GetEngine(ctx).Where("repo_id = ? AND user_id = ? AND mode >= ?", repo.ID, userID, perm_model.AccessModeRead).Get(&Access{}) + for _, a := range accesses { + userIDs.Add(a.UserID) + } + + if err := repo.LoadOwner(ctx); err != nil { + return nil, err + } + if !repo.Owner.IsOrganization() { + userIDs.Add(repo.Owner.ID) + } else { + teamUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, mode, unitType) + if err != nil { + return nil, err + } + userIDs.AddMultiple(teamUserIDs...) + } + return userIDs, nil } // CheckRepoUnitUser check whether user could visit the unit of this repository diff --git a/models/perm/access/repo_permission_test.go b/models/perm/access/repo_permission_test.go index d81dfba288..a36be213ec 100644 --- a/models/perm/access/repo_permission_test.go +++ b/models/perm/access/repo_permission_test.go @@ -169,9 +169,9 @@ func TestGetUserRepoPermission(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) team := &organization.Team{OrgID: org.ID, LowerName: "test_team"} require.NoError(t, db.Insert(ctx, team)) + require.NoError(t, db.Insert(ctx, &organization.TeamUser{OrgID: org.ID, TeamID: team.ID, UID: user.ID})) t.Run("DoerInTeamWithNoRepo", func(t *testing.T) { - require.NoError(t, db.Insert(ctx, &organization.TeamUser{OrgID: org.ID, TeamID: team.ID, UID: user.ID})) perm, err := GetUserRepoPermission(ctx, repo32, user) require.NoError(t, err) assert.Equal(t, perm_model.AccessModeRead, perm.AccessMode) @@ -219,6 +219,15 @@ func TestGetUserRepoPermission(t *testing.T) { assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode) assert.Equal(t, perm_model.AccessModeNone, perm.unitsMode[unit.TypeCode]) assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeIssues]) + + users, err := GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeRead, unit.TypeIssues) + require.NoError(t, err) + require.Len(t, users, 1) + assert.Equal(t, user.ID, users[0].ID) + + users, err = GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues) + require.NoError(t, err) + require.Empty(t, users) }) require.NoError(t, db.Insert(ctx, repo_model.Collaboration{RepoID: repo3.ID, UserID: user.ID, Mode: perm_model.AccessModeWrite})) @@ -229,5 +238,10 @@ func TestGetUserRepoPermission(t *testing.T) { assert.Equal(t, perm_model.AccessModeWrite, perm.AccessMode) assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeCode]) assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeIssues]) + + users, err := GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues) + require.NoError(t, err) + require.Len(t, users, 1) + assert.Equal(t, user.ID, users[0].ID) }) } diff --git a/models/project/column.go b/models/project/column.go index 9b9d874997..79f6dfe911 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -213,6 +213,18 @@ func GetColumn(ctx context.Context, columnID int64) (*Column, error) { return column, nil } +func GetColumnByIDAndProjectID(ctx context.Context, columnID, projectID int64) (*Column, error) { + column := new(Column) + has, err := db.GetEngine(ctx).ID(columnID).And("project_id=?", projectID).Get(column) + if err != nil { + return nil, err + } else if !has { + return nil, ErrProjectColumnNotExist{ColumnID: columnID} + } + + return column, nil +} + // UpdateColumn updates a project column func UpdateColumn(ctx context.Context, column *Column) error { var fieldToUpdate []string diff --git a/models/project/project.go b/models/project/project.go index c003664fa3..7646c3dd71 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -302,6 +302,18 @@ func GetProjectByID(ctx context.Context, id int64) (*Project, error) { return p, nil } +func GetProjectByIDAndOwner(ctx context.Context, id, ownerID int64) (*Project, error) { + p := new(Project) + has, err := db.GetEngine(ctx).ID(id).And("owner_id = ?", ownerID).Get(p) + if err != nil { + return nil, err + } else if !has { + return nil, ErrProjectNotExist{ID: id} + } + + return p, nil +} + // GetProjectForRepoByID returns the projects in a repository func GetProjectForRepoByID(ctx context.Context, repoID, id int64) (*Project, error) { p := new(Project) diff --git a/models/pull/review_state.go b/models/pull/review_state.go index 137af00eab..a0f5548dd4 100644 --- a/models/pull/review_state.go +++ b/models/pull/review_state.go @@ -49,6 +49,19 @@ func init() { db.RegisterModel(new(ReviewState)) } +func (rs *ReviewState) GetViewedFileCount() int { + if len(rs.UpdatedFiles) == 0 { + return 0 + } + var numViewedFiles int + for _, state := range rs.UpdatedFiles { + if state == Viewed { + numViewedFiles++ + } + } + return numViewedFiles +} + // GetReviewState returns the ReviewState with all given values prefilled, whether or not it exists in the database. // If the review didn't exist before in the database, it won't afterwards either. // The returned boolean shows whether the review exists in the database @@ -60,18 +73,18 @@ func GetReviewState(ctx context.Context, userID, pullID int64, commitSHA string) // UpdateReviewState updates the given review inside the database, regardless of whether it existed before or not // The given map of files with their viewed state will be merged with the previous review, if present -func UpdateReviewState(ctx context.Context, userID, pullID int64, commitSHA string, updatedFiles map[string]ViewedState) error { +func UpdateReviewState(ctx context.Context, userID, pullID int64, commitSHA string, updatedFiles map[string]ViewedState) (*ReviewState, error) { log.Trace("Updating review for user %d, repo %d, commit %s with the updated files %v.", userID, pullID, commitSHA, updatedFiles) review, exists, err := GetReviewState(ctx, userID, pullID, commitSHA) if err != nil { - return err + return nil, err } if exists { review.UpdatedFiles = mergeFiles(review.UpdatedFiles, updatedFiles) } else if previousReview, err := getNewestReviewStateApartFrom(ctx, userID, pullID, commitSHA); err != nil { - return err + return nil, err // Overwrite the viewed files of the previous review if present } else if previousReview != nil { @@ -85,11 +98,11 @@ func UpdateReviewState(ctx context.Context, userID, pullID int64, commitSHA stri if !exists { log.Trace("Inserting new review for user %d, repo %d, commit %s with the updated files %v.", userID, pullID, commitSHA, review.UpdatedFiles) _, err := engine.Insert(review) - return err + return nil, err } log.Trace("Updating already existing review with ID %d (user %d, repo %d, commit %s) with the updated files %v.", review.ID, userID, pullID, commitSHA, review.UpdatedFiles) - _, err = engine.ID(review.ID).Update(&ReviewState{UpdatedFiles: review.UpdatedFiles}) - return err + _, err = engine.ID(review.ID).Cols("updated_files").Update(review) + return review, err } // mergeFiles merges the given maps of files with their viewing state into one map. diff --git a/models/renderhelper/repo_file.go b/models/renderhelper/repo_file.go index e0375ed280..f1df8e89e0 100644 --- a/models/renderhelper/repo_file.go +++ b/models/renderhelper/repo_file.go @@ -70,6 +70,6 @@ func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository, "repo": helper.opts.DeprecatedRepoName, }) } - rctx = rctx.WithHelper(helper) + rctx = rctx.WithHelper(helper).WithEnableHeadingIDGeneration(true) return rctx } diff --git a/models/renderhelper/repo_wiki.go b/models/renderhelper/repo_wiki.go index b75f1b9701..218b1e4a67 100644 --- a/models/renderhelper/repo_wiki.go +++ b/models/renderhelper/repo_wiki.go @@ -71,7 +71,7 @@ func NewRenderContextRepoWiki(ctx context.Context, repo *repo_model.Repository, "markupAllowShortIssuePattern": "true", }) } - rctx = rctx.WithHelper(helper) + rctx = rctx.WithHelper(helper).WithEnableHeadingIDGeneration(true) helper.ctx = rctx return rctx } diff --git a/models/repo/attachment.go b/models/repo/attachment.go index 835bee5402..27856f2d2e 100644 --- a/models/repo/attachment.go +++ b/models/repo/attachment.go @@ -166,6 +166,11 @@ func GetAttachmentByReleaseIDFileName(ctx context.Context, releaseID int64, file return attach, nil } +func GetUnlinkedAttachmentsByUserID(ctx context.Context, userID int64) ([]*Attachment, error) { + attachments := make([]*Attachment, 0, 10) + return attachments, db.GetEngine(ctx).Where("uploader_id = ? AND issue_id = 0 AND release_id = 0 AND comment_id = 0", userID).Find(&attachments) +} + // DeleteAttachment deletes the given attachment and optionally the associated file. func DeleteAttachment(ctx context.Context, a *Attachment, remove bool) error { _, err := DeleteAttachments(ctx, []*Attachment{a}, remove) diff --git a/models/repo/attachment_test.go b/models/repo/attachment_test.go index d41008344d..07f4c587a7 100644 --- a/models/repo/attachment_test.go +++ b/models/repo/attachment_test.go @@ -101,3 +101,19 @@ func TestGetAttachmentsByUUIDs(t *testing.T) { assert.Equal(t, int64(1), attachList[0].IssueID) assert.Equal(t, int64(5), attachList[1].IssueID) } + +func TestGetUnlinkedAttachmentsByUserID(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + attachments, err := repo_model.GetUnlinkedAttachmentsByUserID(t.Context(), 8) + assert.NoError(t, err) + assert.Len(t, attachments, 1) + assert.Equal(t, int64(10), attachments[0].ID) + assert.Zero(t, attachments[0].IssueID) + assert.Zero(t, attachments[0].ReleaseID) + assert.Zero(t, attachments[0].CommentID) + + attachments, err = repo_model.GetUnlinkedAttachmentsByUserID(t.Context(), 1) + assert.NoError(t, err) + assert.Empty(t, attachments) +} diff --git a/models/repo/pull_request_default.go b/models/repo/pull_request_default.go new file mode 100644 index 0000000000..89f8eb6a2c --- /dev/null +++ b/models/repo/pull_request_default.go @@ -0,0 +1,16 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "context" + + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/util" +) + +func (repo *Repository) GetPullRequestTargetBranch(ctx context.Context) string { + unitPRConfig := repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig() + return util.IfZero(unitPRConfig.DefaultTargetBranch, repo.DefaultBranch) +} diff --git a/models/repo/pull_request_default_test.go b/models/repo/pull_request_default_test.go new file mode 100644 index 0000000000..1c4f585ed9 --- /dev/null +++ b/models/repo/pull_request_default_test.go @@ -0,0 +1,32 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultTargetBranchSelection(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + ctx := t.Context() + repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 1}) + + assert.Equal(t, repo.DefaultBranch, repo.GetPullRequestTargetBranch(ctx)) + + repo.Units = nil + prUnit, err := repo.GetUnit(ctx, unit.TypePullRequests) + assert.NoError(t, err) + prConfig := prUnit.PullRequestsConfig() + prConfig.DefaultTargetBranch = "branch2" + prUnit.Config = prConfig + assert.NoError(t, UpdateRepoUnit(ctx, prUnit)) + repo.Units = nil + assert.Equal(t, "branch2", repo.GetPullRequestTargetBranch(ctx)) +} diff --git a/models/repo/release.go b/models/repo/release.go index 67aa390e6d..e2010c8a38 100644 --- a/models/repo/release.go +++ b/models/repo/release.go @@ -93,15 +93,25 @@ func init() { db.RegisterModel(new(Release)) } -// LoadAttributes load repo and publisher attributes for a release -func (r *Release) LoadAttributes(ctx context.Context) error { - var err error - if r.Repo == nil { - r.Repo, err = GetRepositoryByID(ctx, r.RepoID) - if err != nil { - return err - } +// LegacyAttachmentMissingRepoIDCutoff marks the date when repo_id started to be written during uploads +// (2026-01-16T00:00:00Z). Older rows might have repo_id=0 and should be tolerated once. +const LegacyAttachmentMissingRepoIDCutoff timeutil.TimeStamp = 1768521600 + +func (r *Release) LoadRepo(ctx context.Context) (err error) { + if r.Repo != nil { + return nil } + + r.Repo, err = GetRepositoryByID(ctx, r.RepoID) + return err +} + +// LoadAttributes load repo and publisher attributes for a release +func (r *Release) LoadAttributes(ctx context.Context) (err error) { + if err := r.LoadRepo(ctx); err != nil { + return err + } + if r.Publisher == nil { r.Publisher, err = user_model.GetUserByID(ctx, r.PublisherID) if err != nil { @@ -168,6 +178,11 @@ func UpdateReleaseNumCommits(ctx context.Context, rel *Release) error { // AddReleaseAttachments adds a release attachments func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs []string) (err error) { + rel, err := GetReleaseByID(ctx, releaseID) + if err != nil { + return err + } + // Check attachments attachments, err := GetAttachmentsByUUIDs(ctx, attachmentUUIDs) if err != nil { @@ -175,6 +190,17 @@ func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs } for i := range attachments { + if attachments[i].RepoID == 0 && attachments[i].CreatedUnix < LegacyAttachmentMissingRepoIDCutoff { + attachments[i].RepoID = rel.RepoID + if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("repo_id").Update(attachments[i]); err != nil { + return fmt.Errorf("update attachment repo_id [%d]: %w", attachments[i].ID, err) + } + } + + if attachments[i].RepoID != rel.RepoID { + return util.NewPermissionDeniedErrorf("attachment belongs to different repository") + } + if attachments[i].ReleaseID != 0 { return util.NewPermissionDeniedErrorf("release permission denied") } diff --git a/models/repo/release_test.go b/models/repo/release_test.go index 01f0fb3cff..2a09ffb36d 100644 --- a/models/repo/release_test.go +++ b/models/repo/release_test.go @@ -6,7 +6,9 @@ package repo import ( "testing" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" ) @@ -37,3 +39,54 @@ func Test_FindTagsByCommitIDs(t *testing.T) { assert.Equal(t, "delete-tag", rels[1].TagName) assert.Equal(t, "v1.0", rels[2].TagName) } + +func TestAddReleaseAttachmentsRejectsDifferentRepo(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + uuid := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12" // attachment 2 belongs to repo 2 + err := AddReleaseAttachments(t.Context(), 1, []string{uuid}) + assert.Error(t, err) + assert.ErrorIs(t, err, util.ErrPermissionDenied) + + attach, err := GetAttachmentByUUID(t.Context(), uuid) + assert.NoError(t, err) + assert.Zero(t, attach.ReleaseID, "attachment should not be linked to release on failure") +} + +func TestAddReleaseAttachmentsAllowsLegacyMissingRepoID(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + legacyUUID := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20" // attachment 10 has repo_id 0 + err := AddReleaseAttachments(t.Context(), 1, []string{legacyUUID}) + assert.NoError(t, err) + + attach, err := GetAttachmentByUUID(t.Context(), legacyUUID) + assert.NoError(t, err) + assert.EqualValues(t, 1, attach.RepoID) + assert.EqualValues(t, 1, attach.ReleaseID) +} + +func TestAddReleaseAttachmentsRejectsRecentZeroRepoID(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + recentUUID := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd3800aa" + attachment := &Attachment{ + UUID: recentUUID, + RepoID: 0, + IssueID: 0, + ReleaseID: 0, + CommentID: 0, + Name: "recent-zero", + CreatedUnix: LegacyAttachmentMissingRepoIDCutoff + 1, + } + assert.NoError(t, db.Insert(t.Context(), attachment)) + + err := AddReleaseAttachments(t.Context(), 1, []string{recentUUID}) + assert.Error(t, err) + assert.ErrorIs(t, err, util.ErrPermissionDenied) + + attach, err := GetAttachmentByUUID(t.Context(), recentUUID) + assert.NoError(t, err) + assert.Zero(t, attach.ReleaseID) + assert.Zero(t, attach.RepoID) +} diff --git a/models/repo/repo.go b/models/repo/repo.go index 401775047b..0846dbcd05 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -229,10 +229,6 @@ func RelativePath(ownerName, repoName string) string { return strings.ToLower(ownerName) + "/" + strings.ToLower(repoName) + ".git" } -func RelativeWikiPath(ownerName, repoName string) string { - return strings.ToLower(ownerName) + "/" + strings.ToLower(repoName) + ".wiki.git" -} - // RelativePath should be an unix style path like username/reponame.git func (repo *Repository) RelativePath() string { return RelativePath(repo.OwnerName, repo.Name) @@ -245,12 +241,6 @@ func (sr StorageRepo) RelativePath() string { return string(sr) } -// WikiStorageRepo returns the storage repo for the wiki -// The wiki repository should have the same object format as the code repository -func (repo *Repository) WikiStorageRepo() StorageRepo { - return StorageRepo(RelativeWikiPath(repo.OwnerName, repo.Name)) -} - // SanitizedOriginalURL returns a sanitized OriginalURL func (repo *Repository) SanitizedOriginalURL() string { if repo.OriginalURL == "" { @@ -623,16 +613,13 @@ func (repo *Repository) ComposeCompareURL(oldCommitID, newCommitID string) strin return fmt.Sprintf("%s/%s/compare/%s...%s", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name), util.PathEscapeSegments(oldCommitID), util.PathEscapeSegments(newCommitID)) } -func (repo *Repository) ComposeBranchCompareURL(baseRepo *Repository, branchName string) string { - if baseRepo == nil { - baseRepo = repo - } +func (repo *Repository) ComposeBranchCompareURL(baseRepo *Repository, baseBranch, branchName string) string { var cmpBranchEscaped string if repo.ID != baseRepo.ID { cmpBranchEscaped = fmt.Sprintf("%s/%s:", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name)) } cmpBranchEscaped = fmt.Sprintf("%s%s", cmpBranchEscaped, util.PathEscapeSegments(branchName)) - return fmt.Sprintf("%s/compare/%s...%s", baseRepo.Link(), util.PathEscapeSegments(baseRepo.DefaultBranch), cmpBranchEscaped) + return fmt.Sprintf("%s/compare/%s...%s", baseRepo.Link(), util.PathEscapeSegments(baseBranch), cmpBranchEscaped) } // IsOwnedBy returns true when user owns this repository @@ -879,16 +866,6 @@ func GetRepositoriesMapByIDs(ctx context.Context, ids []int64) (map[int64]*Repos return repos, db.GetEngine(ctx).In("id", ids).Find(&repos) } -// IsRepositoryModelOrDirExist returns true if the repository with given name under user has already existed. -func IsRepositoryModelOrDirExist(ctx context.Context, u *user_model.User, repoName string) (bool, error) { - has, err := IsRepositoryModelExist(ctx, u, repoName) - if err != nil { - return false, err - } - isDir, err := util.IsDir(RepoPath(u.Name, repoName)) - return has || isDir, err -} - func IsRepositoryModelExist(ctx context.Context, u *user_model.User, repoName string) (bool, error) { return db.GetEngine(ctx).Get(&Repository{ OwnerID: u.ID, diff --git a/models/repo/repo_list.go b/models/repo/repo_list.go index f2cdd2f284..811f83c999 100644 --- a/models/repo/repo_list.go +++ b/models/repo/repo_list.go @@ -642,6 +642,17 @@ func SearchRepositoryIDsByCondition(ctx context.Context, cond builder.Cond) ([]i Find(&repoIDs) } +func userAllPublicRepoCond(cond builder.Cond, orgVisibilityLimit []structs.VisibleType) builder.Cond { + return cond.Or(builder.And( + builder.Eq{"`repository`.is_private": false}, + // Aren't in a private organisation or limited organisation if we're not logged in + builder.NotIn("`repository`.owner_id", builder.Select("id").From("`user`").Where( + builder.And( + builder.Eq{"type": user_model.UserTypeOrganization}, + builder.In("visibility", orgVisibilityLimit)), + )))) +} + // AccessibleRepositoryCondition takes a user a returns a condition for checking if a repository is accessible func AccessibleRepositoryCondition(user *user_model.User, unitType unit.Type) builder.Cond { cond := builder.NewCond() @@ -651,15 +662,8 @@ func AccessibleRepositoryCondition(user *user_model.User, unitType unit.Type) bu if user == nil || user.ID <= 0 { orgVisibilityLimit = append(orgVisibilityLimit, structs.VisibleTypeLimited) } - // 1. Be able to see all non-private repositories that either: - cond = cond.Or(builder.And( - builder.Eq{"`repository`.is_private": false}, - // 2. Aren't in an private organisation or limited organisation if we're not logged in - builder.NotIn("`repository`.owner_id", builder.Select("id").From("`user`").Where( - builder.And( - builder.Eq{"type": user_model.UserTypeOrganization}, - builder.In("visibility", orgVisibilityLimit)), - )))) + // 1. Be able to see all non-private repositories + cond = userAllPublicRepoCond(cond, orgVisibilityLimit) } if user != nil { @@ -683,6 +687,9 @@ func AccessibleRepositoryCondition(user *user_model.User, unitType unit.Type) bu if !user.IsRestricted { // 5. Be able to see all public repos in private organizations that we are an org_user of cond = cond.Or(userOrgPublicRepoCond(user.ID)) + } else if !setting.Service.RequireSignInViewStrict { + orgVisibilityLimit := []structs.VisibleType{structs.VisibleTypePrivate, structs.VisibleTypeLimited} + cond = userAllPublicRepoCond(cond, orgVisibilityLimit) } } diff --git a/models/repo/repo_list_test.go b/models/repo/repo_list_test.go index 6cc0d3155c..943e0c5025 100644 --- a/models/repo/repo_list_test.go +++ b/models/repo/repo_list_test.go @@ -10,9 +10,14 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func getTestCases() []struct { @@ -182,7 +187,16 @@ func getTestCases() []struct { func TestSearchRepository(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) + t.Run("SearchRepositoryPublic", testSearchRepositoryPublic) + t.Run("SearchRepositoryPublicRestricted", testSearchRepositoryRestricted) + t.Run("SearchRepositoryPrivate", testSearchRepositoryPrivate) + t.Run("SearchRepositoryNonExistingOwner", testSearchRepositoryNonExistingOwner) + t.Run("SearchRepositoryWithInDescription", testSearchRepositoryWithInDescription) + t.Run("SearchRepositoryNotInDescription", testSearchRepositoryNotInDescription) + t.Run("SearchRepositoryCases", testSearchRepositoryCases) +} +func testSearchRepositoryPublic(t *testing.T) { // test search public repository on explore page repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ @@ -211,9 +225,54 @@ func TestSearchRepository(t *testing.T) { assert.NoError(t, err) assert.Equal(t, int64(2), count) assert.Len(t, repos, 2) +} +func testSearchRepositoryRestricted(t *testing.T) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29, IsRestricted: true}) + + performSearch := func(t *testing.T, user *user_model.User) (publicRepoIDs []int64) { + repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{Page: 1, PageSize: 10000}, + Actor: user, + }) + require.NoError(t, err) + assert.Len(t, repos, int(count)) + for _, repo := range repos { + require.NoError(t, repo.LoadOwner(t.Context())) + if repo.Owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate { + publicRepoIDs = append(publicRepoIDs, repo.ID) + } + } + return publicRepoIDs + } + + normalPublicRepoIDs := performSearch(t, user2) + require.Greater(t, len(normalPublicRepoIDs), 10) // quite a lot + + t.Run("RestrictedUser-NoSignInRequirement", func(t *testing.T) { + // restricted user can also see public repositories if no "required sign-in" + repoIDs := performSearch(t, restrictedUser) + assert.ElementsMatch(t, normalPublicRepoIDs, repoIDs) + }) + + defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)() + + t.Run("NormalUser-RequiredSignIn", func(t *testing.T) { + // normal user can still see all public repos, not affected by "required sign-in" + repoIDs := performSearch(t, user2) + assert.ElementsMatch(t, normalPublicRepoIDs, repoIDs) + }) + t.Run("RestrictedUser-RequiredSignIn", func(t *testing.T) { + // restricted user can see only their own repo + repoIDs := performSearch(t, restrictedUser) + assert.Equal(t, []int64{4}, repoIDs) + }) +} + +func testSearchRepositoryPrivate(t *testing.T) { // test search private repository on explore page - repos, count, err = repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{ + repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ Page: 1, PageSize: 10, @@ -242,16 +301,18 @@ func TestSearchRepository(t *testing.T) { assert.NoError(t, err) assert.Equal(t, int64(3), count) assert.Len(t, repos, 3) +} - // Test non existing owner - repos, count, err = repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{OwnerID: unittest.NonexistentID}) +func testSearchRepositoryNonExistingOwner(t *testing.T) { + repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{OwnerID: unittest.NonexistentID}) assert.NoError(t, err) assert.Empty(t, repos) assert.Equal(t, int64(0), count) +} - // Test search within description - repos, count, err = repo_model.SearchRepository(t.Context(), repo_model.SearchRepoOptions{ +func testSearchRepositoryWithInDescription(t *testing.T) { + repos, count, err := repo_model.SearchRepository(t.Context(), repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ Page: 1, PageSize: 10, @@ -266,9 +327,10 @@ func TestSearchRepository(t *testing.T) { assert.Equal(t, "test_repo_14", repos[0].Name) } assert.Equal(t, int64(1), count) +} - // Test NOT search within description - repos, count, err = repo_model.SearchRepository(t.Context(), repo_model.SearchRepoOptions{ +func testSearchRepositoryNotInDescription(t *testing.T) { + repos, count, err := repo_model.SearchRepository(t.Context(), repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ Page: 1, PageSize: 10, @@ -281,7 +343,9 @@ func TestSearchRepository(t *testing.T) { assert.NoError(t, err) assert.Empty(t, repos) assert.Equal(t, int64(0), count) +} +func testSearchRepositoryCases(t *testing.T) { testCases := getTestCases() for _, testCase := range testCases { diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index a5207bc22a..d03d5e1e6a 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -131,6 +131,7 @@ type PullRequestsConfig struct { DefaultDeleteBranchAfterMerge bool DefaultMergeStyle MergeStyle DefaultAllowMaintainerEdit bool + DefaultTargetBranch string } // FromDB fills up a PullRequestsConfig from serialized format. @@ -170,6 +171,9 @@ func (cfg *PullRequestsConfig) GetDefaultMergeStyle() MergeStyle { type ActionsConfig struct { DisabledWorkflows []string + // CollaborativeOwnerIDs is a list of owner IDs used to share actions from private repos. + // Only workflows from the private repos whose owners are in CollaborativeOwnerIDs can access the current repo's actions. + CollaborativeOwnerIDs []int64 } func (cfg *ActionsConfig) EnableWorkflow(file string) { @@ -192,6 +196,20 @@ func (cfg *ActionsConfig) DisableWorkflow(file string) { cfg.DisabledWorkflows = append(cfg.DisabledWorkflows, file) } +func (cfg *ActionsConfig) AddCollaborativeOwner(ownerID int64) { + if !slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) { + cfg.CollaborativeOwnerIDs = append(cfg.CollaborativeOwnerIDs, ownerID) + } +} + +func (cfg *ActionsConfig) RemoveCollaborativeOwner(ownerID int64) { + cfg.CollaborativeOwnerIDs = util.SliceRemoveAll(cfg.CollaborativeOwnerIDs, ownerID) +} + +func (cfg *ActionsConfig) IsCollaborativeOwner(ownerID int64) bool { + return slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) +} + // FromDB fills up a ActionsConfig from serialized format. func (cfg *ActionsConfig) FromDB(bs []byte) error { return json.UnmarshalHandleDoubleEncode(bs, &cfg) diff --git a/models/repo/topic.go b/models/repo/topic.go index baeae01efa..f8f706fc1a 100644 --- a/models/repo/topic.go +++ b/models/repo/topic.go @@ -159,7 +159,7 @@ func RemoveTopicsFromRepo(ctx context.Context, repoID int64) error { builder.In("id", builder.Select("topic_id").From("repo_topic").Where(builder.Eq{"repo_id": repoID}), ), - ).Cols("repo_count").SetExpr("repo_count", "repo_count-1").Update(&Topic{}) + ).Decr("repo_count").Update(&Topic{}) if err != nil { return err } diff --git a/models/repo/update.go b/models/repo/update.go index 3228ae11a4..bf560cf695 100644 --- a/models/repo/update.go +++ b/models/repo/update.go @@ -9,8 +9,6 @@ import ( "time" "code.gitea.io/gitea/models/db" - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" ) @@ -106,35 +104,6 @@ func (err ErrRepoFilesAlreadyExist) Unwrap() error { return util.ErrAlreadyExist } -// CheckCreateRepository check if doer could create a repository in new owner -func CheckCreateRepository(ctx context.Context, doer, owner *user_model.User, name string, overwriteOrAdopt bool) error { - if !doer.CanCreateRepoIn(owner) { - return ErrReachLimitOfRepo{owner.MaxRepoCreation} - } - - if err := IsUsableRepoName(name); err != nil { - return err - } - - has, err := IsRepositoryModelOrDirExist(ctx, owner, name) - if err != nil { - return fmt.Errorf("IsRepositoryExist: %w", err) - } else if has { - return ErrRepoAlreadyExist{owner.Name, name} - } - - repoPath := RepoPath(owner.Name, name) - isExist, err := util.IsExist(repoPath) - if err != nil { - log.Error("Unable to check if %s exists. Error: %v", repoPath, err) - return err - } - if !overwriteOrAdopt && isExist { - return ErrRepoFilesAlreadyExist{owner.Name, name} - } - return nil -} - // UpdateRepoSize updates the repository size, calculating it using getDirectorySize func UpdateRepoSize(ctx context.Context, repoID, gitSize, lfsSize int64) error { _, err := db.GetEngine(ctx).ID(repoID).Cols("size", "git_size", "lfs_size").NoAutoTime().Update(&Repository{ diff --git a/models/repo/upload.go b/models/repo/upload.go index f7d4749842..b9bda8fdbf 100644 --- a/models/repo/upload.go +++ b/models/repo/upload.go @@ -127,16 +127,9 @@ func DeleteUploads(ctx context.Context, uploads ...*Upload) (err error) { for _, upload := range uploads { localPath := upload.LocalPath() - isFile, err := util.IsFile(localPath) - if err != nil { - log.Error("Unable to check if %s is a file. Error: %v", localPath, err) - } - if !isFile { - continue - } - if err := util.Remove(localPath); err != nil { - return fmt.Errorf("remove upload: %w", err) + // just continue, don't fail the whole operation if a file is missing (removed by others) + log.Error("unable to remove upload file %s: %v", localPath, err) } } diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go index 232087d865..08cf964bc8 100644 --- a/models/repo/user_repo.go +++ b/models/repo/user_repo.go @@ -151,7 +151,7 @@ func GetRepoAssignees(ctx context.Context, repo *Repository) (_ []*user_model.Us func GetIssuePostersWithSearch(ctx context.Context, repo *Repository, isPull bool, search string, isShowFullName bool) ([]*user_model.User, error) { users := make([]*user_model.User, 0, 30) var prefixCond builder.Cond = builder.Like{"lower_name", strings.ToLower(search) + "%"} - if isShowFullName { + if search != "" && isShowFullName { prefixCond = prefixCond.Or(db.BuildCaseInsensitiveLike("full_name", "%"+search+"%")) } diff --git a/models/repo/watch.go b/models/repo/watch.go index a616544cae..1e63d5c3d2 100644 --- a/models/repo/watch.go +++ b/models/repo/watch.go @@ -176,3 +176,13 @@ func WatchIfAuto(ctx context.Context, userID, repoID int64, isWrite bool) error } return watchRepoMode(ctx, watch, WatchModeAuto) } + +// ClearRepoWatches clears all watches for a repository and from the user that watched it. +// Used when a repository is set to private. +func ClearRepoWatches(ctx context.Context, repoID int64) error { + if _, err := db.Exec(ctx, "UPDATE `repository` SET num_watches = 0 WHERE id = ?", repoID); err != nil { + return err + } + + return db.DeleteBeans(ctx, Watch{RepoID: repoID}) +} diff --git a/models/repo/watch_test.go b/models/repo/watch_test.go index 19e363f6b0..97576fb787 100644 --- a/models/repo/watch_test.go +++ b/models/repo/watch_test.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIsWatching(t *testing.T) { @@ -119,3 +120,21 @@ func TestWatchIfAuto(t *testing.T) { assert.NoError(t, err) assert.Len(t, watchers, prevCount) } + +func TestClearRepoWatches(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + const repoID int64 = 1 + watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repoID) + require.NoError(t, err) + require.NotEmpty(t, watchers) + + assert.NoError(t, repo_model.ClearRepoWatches(t.Context(), repoID)) + + watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repoID) + assert.NoError(t, err) + assert.Empty(t, watchers) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) + assert.Zero(t, repo.NumWatches) +} diff --git a/models/repo/wiki.go b/models/repo/wiki.go index 9f41445bf8..47c8fa43ab 100644 --- a/models/repo/wiki.go +++ b/models/repo/wiki.go @@ -7,7 +7,6 @@ package repo import ( "context" "fmt" - "path/filepath" "strings" user_model "code.gitea.io/gitea/models/user" @@ -76,12 +75,12 @@ func (repo *Repository) WikiCloneLink(ctx context.Context, doer *user_model.User return repo.cloneLink(ctx, doer, repo.Name+".wiki") } -// WikiPath returns wiki data path by given user and repository name. -func WikiPath(userName, repoName string) string { - return filepath.Join(user_model.UserPath(userName), strings.ToLower(repoName)+".wiki.git") +func RelativeWikiPath(ownerName, repoName string) string { + return strings.ToLower(ownerName) + "/" + strings.ToLower(repoName) + ".wiki.git" } -// WikiPath returns wiki data path for given repository. -func (repo *Repository) WikiPath() string { - return WikiPath(repo.OwnerName, repo.Name) +// WikiStorageRepo returns the storage repo for the wiki +// The wiki repository should have the same object format as the code repository +func (repo *Repository) WikiStorageRepo() StorageRepo { + return StorageRepo(RelativeWikiPath(repo.OwnerName, repo.Name)) } diff --git a/models/repo/wiki_test.go b/models/repo/wiki_test.go index 41e53d93d9..636c78009b 100644 --- a/models/repo/wiki_test.go +++ b/models/repo/wiki_test.go @@ -4,12 +4,10 @@ package repo_test import ( - "path/filepath" "testing" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" ) @@ -23,15 +21,10 @@ func TestRepository_WikiCloneLink(t *testing.T) { assert.Equal(t, "https://try.gitea.io/user2/repo1.wiki.git", cloneLink.HTTPS) } -func TestWikiPath(t *testing.T) { +func TestRepository_RelativeWikiPath(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - expected := filepath.Join(setting.RepoRootPath, "user2/repo1.wiki.git") - assert.Equal(t, expected, repo_model.WikiPath("user2", "repo1")) -} -func TestRepository_WikiPath(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - expected := filepath.Join(setting.RepoRootPath, "user2/repo1.wiki.git") - assert.Equal(t, expected, repo.WikiPath()) + assert.Equal(t, "user2/repo1.wiki.git", repo_model.RelativeWikiPath(repo.OwnerName, repo.Name)) + assert.Equal(t, "user2/repo1.wiki.git", repo.WikiStorageRepo().RelativePath()) } diff --git a/models/secret/secret.go b/models/secret/secret.go index 10a0287dfd..a82a924c39 100644 --- a/models/secret/secret.go +++ b/models/secret/secret.go @@ -178,8 +178,8 @@ func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[ for _, secret := range append(ownerSecrets, repoSecrets...) { v, err := secret_module.DecryptSecret(setting.SecretKey, secret.Data) if err != nil { - log.Error("decrypt secret %v %q: %v", secret.ID, secret.Name, err) - return nil, err + log.Error("Unable to decrypt Actions secret %v %q, maybe SECRET_KEY is wrong: %v", secret.ID, secret.Name, err) + continue } secrets[secret.Name] = v } diff --git a/models/unittest/fixtures_test.go b/models/unittest/fixtures_test.go index 8a4c5f1793..ebf209f5f4 100644 --- a/models/unittest/fixtures_test.go +++ b/models/unittest/fixtures_test.go @@ -9,7 +9,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/require" "xorm.io/xorm" @@ -60,7 +60,8 @@ func NewFixturesLoaderVendorGoTestfixtures(e *xorm.Engine, opts unittest.Fixture func prepareTestFixturesLoaders(t testing.TB) unittest.FixturesOptions { _ = user_model.User{} - opts := unittest.FixturesOptions{Dir: filepath.Join(test.SetupGiteaRoot(), "models", "fixtures"), Files: []string{ + giteaRoot := setting.SetupGiteaTestEnv() + opts := unittest.FixturesOptions{Dir: filepath.Join(giteaRoot, "models", "fixtures"), Files: []string{ "user.yml", }} require.NoError(t, unittest.CreateTestEngine(opts)) diff --git a/models/unittest/testdb.go b/models/unittest/testdb.go index 4611a079ec..398090760e 100644 --- a/models/unittest/testdb.go +++ b/models/unittest/testdb.go @@ -21,7 +21,6 @@ import ( "code.gitea.io/gitea/modules/setting/config" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/tempdir" - "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" @@ -60,7 +59,6 @@ func InitSettingsForTesting() { _ = hash.Register("dummy", hash.NewDummyHasher) setting.PasswordHashAlgo, _ = hash.SetDefaultPasswordHashAlgorithm("dummy") - setting.InitGiteaEnvVarsForTesting() } // TestOptions represents test options @@ -74,8 +72,7 @@ type TestOptions struct { // test database. Creates the test database, and sets necessary settings. func MainTest(m *testing.M, testOptsArg ...*TestOptions) { testOpts := util.OptionalArg(testOptsArg, &TestOptions{}) - giteaRoot = test.SetupGiteaRoot() - setting.CustomPath = filepath.Join(giteaRoot, "custom") + giteaRoot = setting.SetupGiteaTestEnv() InitSettingsForTesting() fixturesOpts := FixturesOptions{Dir: filepath.Join(giteaRoot, "models", "fixtures"), Files: testOpts.FixtureFiles} @@ -172,7 +169,7 @@ func CreateTestEngine(opts FixturesOptions) error { x, err := xorm.NewEngine("sqlite3", "file::memory:?cache=shared&_txlock=immediate") if err != nil { if strings.Contains(err.Error(), "unknown driver") { - return fmt.Errorf(`sqlite3 requires: -tags sqlite,sqlite_unlock_notify%s%w`, "\n", err) + return fmt.Errorf("sqlite3 requires: -tags sqlite,sqlite_unlock_notify\n%w", err) } return err } @@ -182,7 +179,7 @@ func CreateTestEngine(opts FixturesOptions) error { if err = db.SyncAllTables(); err != nil { return err } - switch os.Getenv("GITEA_UNIT_TESTS_LOG_SQL") { + switch os.Getenv("GITEA_TEST_LOG_SQL") { case "true", "1": x.ShowSQL(true) } @@ -201,5 +198,5 @@ func PrepareTestEnv(t testing.TB) { assert.NoError(t, PrepareTestDatabase()) metaPath := filepath.Join(giteaRoot, "tests", "gitea-repositories-meta") assert.NoError(t, SyncDirs(metaPath, setting.RepoRootPath)) - test.SetupGiteaRoot() // Makes sure GITEA_ROOT is set + setting.SetupGiteaTestEnv() } diff --git a/models/user/email_address.go b/models/user/email_address.go index 67aa1bdd82..2b58edaeb5 100644 --- a/models/user/email_address.go +++ b/models/user/email_address.go @@ -276,17 +276,22 @@ func updateActivation(ctx context.Context, email *EmailAddress, activate bool) e return UpdateUserCols(ctx, user, "rands") } -func MakeActiveEmailPrimary(ctx context.Context, emailID int64) error { - return makeEmailPrimaryInternal(ctx, emailID, true) +func MakeActiveEmailPrimary(ctx context.Context, ownerID, emailID int64) error { + return makeEmailPrimaryInternal(ctx, ownerID, emailID, true) } -func MakeInactiveEmailPrimary(ctx context.Context, emailID int64) error { - return makeEmailPrimaryInternal(ctx, emailID, false) +func MakeInactiveEmailPrimary(ctx context.Context, ownerID, emailID int64) error { + return makeEmailPrimaryInternal(ctx, ownerID, emailID, false) } -func makeEmailPrimaryInternal(ctx context.Context, emailID int64, isActive bool) error { +func makeEmailPrimaryInternal(ctx context.Context, ownerID, emailID int64, isActive bool) error { email := &EmailAddress{} - if has, err := db.GetEngine(ctx).ID(emailID).Where(builder.Eq{"is_activated": isActive}).Get(email); err != nil { + if has, err := db.GetEngine(ctx).ID(emailID). + Where(builder.Eq{ + "uid": ownerID, + "is_activated": isActive, + }). + Get(email); err != nil { return err } else if !has { return ErrEmailAddressNotExist{} @@ -336,7 +341,7 @@ func ChangeInactivePrimaryEmail(ctx context.Context, uid int64, oldEmailAddr, ne if err != nil { return err } - return MakeInactiveEmailPrimary(ctx, newEmail.ID) + return MakeInactiveEmailPrimary(ctx, uid, newEmail.ID) }) } diff --git a/models/user/email_address_test.go b/models/user/email_address_test.go index 6ef18fb0f6..4167aaac0d 100644 --- a/models/user/email_address_test.go +++ b/models/user/email_address_test.go @@ -46,22 +46,22 @@ func TestIsEmailUsed(t *testing.T) { func TestMakeEmailPrimary(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - err := user_model.MakeActiveEmailPrimary(t.Context(), 9999999) + err := user_model.MakeActiveEmailPrimary(t.Context(), 1, 9999999) assert.Error(t, err) assert.ErrorIs(t, err, user_model.ErrEmailAddressNotExist{}) email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{Email: "user11@example.com"}) - err = user_model.MakeActiveEmailPrimary(t.Context(), email.ID) + err = user_model.MakeActiveEmailPrimary(t.Context(), email.UID, email.ID) assert.Error(t, err) assert.ErrorIs(t, err, user_model.ErrEmailAddressNotExist{}) // inactive email is considered as not exist for "MakeActiveEmailPrimary" email = unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{Email: "user9999999@example.com"}) - err = user_model.MakeActiveEmailPrimary(t.Context(), email.ID) + err = user_model.MakeActiveEmailPrimary(t.Context(), email.UID, email.ID) assert.Error(t, err) assert.True(t, user_model.IsErrUserNotExist(err)) email = unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{Email: "user101@example.com"}) - err = user_model.MakeActiveEmailPrimary(t.Context(), email.ID) + err = user_model.MakeActiveEmailPrimary(t.Context(), email.UID, email.ID) assert.NoError(t, err) user, _ := user_model.GetUserByID(t.Context(), int64(10)) diff --git a/models/user/openid.go b/models/user/openid.go index 420c67ca18..5baa48c824 100644 --- a/models/user/openid.go +++ b/models/user/openid.go @@ -102,7 +102,13 @@ func DeleteUserOpenID(ctx context.Context, openid *UserOpenID) (err error) { } // ToggleUserOpenIDVisibility toggles visibility of an openid address of given user. -func ToggleUserOpenIDVisibility(ctx context.Context, id int64) (err error) { - _, err = db.GetEngine(ctx).Exec("update `user_open_id` set `show` = not `show` where `id` = ?", id) - return err +func ToggleUserOpenIDVisibility(ctx context.Context, id int64, user *User) error { + affected, err := db.GetEngine(ctx).Exec("update `user_open_id` set `show` = not `show` where `id` = ? AND uid = ?", id, user.ID) + if err != nil { + return err + } + if n, _ := affected.RowsAffected(); n != 1 { + return util.NewNotExistErrorf("OpenID is unknown") + } + return nil } diff --git a/models/user/openid_test.go b/models/user/openid_test.go index fa260e7a9e..6d2260324f 100644 --- a/models/user/openid_test.go +++ b/models/user/openid_test.go @@ -8,6 +8,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -33,12 +34,14 @@ func TestGetUserOpenIDs(t *testing.T) { func TestToggleUserOpenIDVisibility(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) + user, err := user_model.GetUserByID(t.Context(), int64(2)) + require.NoError(t, err) oids, err := user_model.GetUserOpenIDs(t.Context(), int64(2)) require.NoError(t, err) require.Len(t, oids, 1) assert.True(t, oids[0].Show) - err = user_model.ToggleUserOpenIDVisibility(t.Context(), oids[0].ID) + err = user_model.ToggleUserOpenIDVisibility(t.Context(), oids[0].ID, user) require.NoError(t, err) oids, err = user_model.GetUserOpenIDs(t.Context(), int64(2)) @@ -46,7 +49,7 @@ func TestToggleUserOpenIDVisibility(t *testing.T) { require.Len(t, oids, 1) assert.False(t, oids[0].Show) - err = user_model.ToggleUserOpenIDVisibility(t.Context(), oids[0].ID) + err = user_model.ToggleUserOpenIDVisibility(t.Context(), oids[0].ID, user) require.NoError(t, err) oids, err = user_model.GetUserOpenIDs(t.Context(), int64(2)) @@ -55,3 +58,13 @@ func TestToggleUserOpenIDVisibility(t *testing.T) { assert.True(t, oids[0].Show) } } + +func TestToggleUserOpenIDVisibilityRequiresOwnership(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + unauthorizedUser, err := user_model.GetUserByID(t.Context(), int64(2)) + require.NoError(t, err) + + err = user_model.ToggleUserOpenIDVisibility(t.Context(), int64(1), unauthorizedUser) + require.Error(t, err) + assert.ErrorIs(t, err, util.ErrNotExist) +} diff --git a/models/user/search.go b/models/user/search.go index cfd0d011bc..36d1d3913b 100644 --- a/models/user/search.go +++ b/models/user/search.go @@ -6,6 +6,7 @@ package user import ( "context" "fmt" + "slices" "strings" "code.gitea.io/gitea/models/db" @@ -17,12 +18,29 @@ import ( "xorm.io/xorm" ) +// AdminUserOrderByMap represents all possible admin user search orders +// This should only be used for admin API endpoints as we should not expose "updated" ordering which could expose recent user activity including logins. +var AdminUserOrderByMap = map[string]map[string]db.SearchOrderBy{ + "asc": { + "name": db.SearchOrderByAlphabetically, + "created": db.SearchOrderByOldest, + "updated": db.SearchOrderByLeastUpdated, + "id": db.SearchOrderByID, + }, + "desc": { + "name": db.SearchOrderByAlphabeticallyReverse, + "created": db.SearchOrderByNewest, + "updated": db.SearchOrderByRecentUpdated, + "id": db.SearchOrderByIDReverse, + }, +} + // SearchUserOptions contains the options for searching type SearchUserOptions struct { db.ListOptions Keyword string - Type UserType + Types []UserType UID int64 LoginName string // this option should be used only for admin user SourceID int64 // this option should be used only for admin user @@ -43,16 +61,16 @@ type SearchUserOptions struct { func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) *xorm.Session { var cond builder.Cond - cond = builder.Eq{"type": opts.Type} + cond = builder.In("type", opts.Types) if opts.IncludeReserved { - switch opts.Type { - case UserTypeIndividual: + switch { + case slices.Contains(opts.Types, UserTypeIndividual): cond = cond.Or(builder.Eq{"type": UserTypeUserReserved}).Or( builder.Eq{"type": UserTypeBot}, ).Or( builder.Eq{"type": UserTypeRemoteUser}, ) - case UserTypeOrganization: + case slices.Contains(opts.Types, UserTypeOrganization): cond = cond.Or(builder.Eq{"type": UserTypeOrganizationReserved}) } } diff --git a/models/user/user.go b/models/user/user.go index 80d5eb5ec4..59a0b4e5d1 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -13,6 +13,7 @@ import ( "net/url" "path/filepath" "regexp" + "strconv" "strings" "sync" "time" @@ -212,7 +213,7 @@ func (u *User) SetLastLogin() { // GetPlaceholderEmail returns an noreply email func (u *User) GetPlaceholderEmail() string { - return fmt.Sprintf("%s@%s", u.LowerName, setting.Service.NoReplyAddress) + return fmt.Sprintf("%s+%d@%s", u.LowerName, u.ID, setting.Service.NoReplyAddress) } // GetEmail returns a noreply email, if the user has set to keep his @@ -249,8 +250,13 @@ func (u *User) MaxCreationLimit() int { } // CanCreateRepoIn checks whether the doer(u) can create a repository in the owner -// NOTE: functions calling this assume a failure due to repository count limit; it ONLY checks the repo number LIMIT, if new checks are added, those functions should be revised +// NOTE: functions calling this assume a failure due to repository count limit, or the owner is not a real user. +// It ONLY checks the repo number LIMIT or whether owner user is real. If new checks are added, those functions should be revised. +// TODO: the callers can only return ErrReachLimitOfRepo, need to fine tune to support other error types in the future. func (u *User) CanCreateRepoIn(owner *User) bool { + if u.ID <= 0 || owner.ID <= 0 { + return false // fake user like Ghost or Actions user + } if u.IsAdmin { return true } @@ -1192,14 +1198,18 @@ func GetUsersByEmails(ctx context.Context, emails []string) (*EmailUserMap, erro needCheckEmails := make(container.Set[string]) needCheckUserNames := make(container.Set[string]) + needCheckUserIDs := make(container.Set[int64]) noReplyAddressSuffix := "@" + strings.ToLower(setting.Service.NoReplyAddress) for _, email := range emails { emailLower := strings.ToLower(email) - if noReplyUserNameLower, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok { - needCheckUserNames.Add(noReplyUserNameLower) - needCheckEmails.Add(emailLower) - } else { - needCheckEmails.Add(emailLower) + needCheckEmails.Add(emailLower) + if localPart, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok { + name, id := parseLocalPartToNameID(localPart) + if id != 0 { + needCheckUserIDs.Add(id) + } else if name != "" { + needCheckUserNames.Add(name) + } } } @@ -1229,16 +1239,57 @@ func GetUsersByEmails(ctx context.Context, emails []string) (*EmailUserMap, erro } } - users := make(map[int64]*User, len(needCheckUserNames)) - if err := db.GetEngine(ctx).In("lower_name", needCheckUserNames.Values()).Find(&users); err != nil { - return nil, err + usersByIDs := make(map[int64]*User) + if len(needCheckUserIDs) > 0 || len(needCheckUserNames) > 0 { + cond := builder.NewCond() + if len(needCheckUserIDs) > 0 { + cond = cond.Or(builder.In("id", needCheckUserIDs.Values())) + } + if len(needCheckUserNames) > 0 { + cond = cond.Or(builder.In("lower_name", needCheckUserNames.Values())) + } + if err := db.GetEngine(ctx).Where(cond).Find(&usersByIDs); err != nil { + return nil, err + } } - for _, user := range users { - results[strings.ToLower(user.GetPlaceholderEmail())] = user + + usersByName := make(map[string]*User) + for _, user := range usersByIDs { + usersByName[user.LowerName] = user } + + for _, email := range emails { + emailLower := strings.ToLower(email) + if _, ok := results[emailLower]; ok { + continue + } + + localPart, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix) + if !ok { + continue + } + name, id := parseLocalPartToNameID(localPart) + if user, ok := usersByIDs[id]; ok { + results[emailLower] = user + } else if user, ok := usersByName[name]; ok { + results[emailLower] = user + } + } + return &EmailUserMap{results}, nil } +// parseLocalPartToNameID attempts to unparse local-part of email that's in format user+id +// returns user and id if possible +func parseLocalPartToNameID(localPart string) (string, int64) { + var id int64 + name, idstr, hasPlus := strings.Cut(localPart, "+") + if hasPlus { + id, _ = strconv.ParseInt(idstr, 10, 64) + } + return name, id +} + // GetUserByEmail returns the user object by given e-mail if exists. func GetUserByEmail(ctx context.Context, email string) (*User, error) { if len(email) == 0 { @@ -1257,16 +1308,12 @@ func GetUserByEmail(ctx context.Context, email string) (*User, error) { } // Finally, if email address is the protected email address: - if strings.HasSuffix(email, "@"+setting.Service.NoReplyAddress) { - username := strings.TrimSuffix(email, "@"+setting.Service.NoReplyAddress) - user := &User{} - has, err := db.GetEngine(ctx).Where("lower_name=?", username).Get(user) - if err != nil { - return nil, err - } - if has { - return user, nil + if localPart, ok := strings.CutSuffix(email, strings.ToLower("@"+setting.Service.NoReplyAddress)); ok { + name, id := parseLocalPartToNameID(localPart) + if id != 0 { + return GetUserByID(ctx, id) } + return GetUserByName(ctx, name) } return nil, ErrUserNotExist{Name: email} @@ -1444,3 +1491,27 @@ func DisabledFeaturesWithLoginType(user *User) *container.Set[string] { } return &setting.Admin.UserDisabledFeatures } + +// GetUserOrOrgIDByName returns the id for a user or an org by name +func GetUserOrOrgIDByName(ctx context.Context, name string) (int64, error) { + var id int64 + has, err := db.GetEngine(ctx).Table("user").Where("name = ?", name).Cols("id").Get(&id) + if err != nil { + return 0, err + } else if !has { + return 0, fmt.Errorf("user or org with name %s: %w", name, util.ErrNotExist) + } + return id, nil +} + +// GetUserOrOrgByName returns the user or org by name +func GetUserOrOrgByName(ctx context.Context, name string) (*User, error) { + var u User + has, err := db.GetEngine(ctx).Where("lower_name = ?", strings.ToLower(name)).Get(&u) + if err != nil { + return nil, err + } else if !has { + return nil, ErrUserNotExist{Name: name} + } + return &u, nil +} diff --git a/models/user/user_system.go b/models/user/user_system.go index e07274d291..cf4306df25 100644 --- a/models/user/user_system.go +++ b/models/user/user_system.go @@ -4,6 +4,7 @@ package user import ( + "strconv" "strings" "code.gitea.io/gitea/modules/structs" @@ -23,10 +24,6 @@ func NewGhostUser() *User { } } -func IsGhostUserName(name string) bool { - return strings.EqualFold(name, GhostUserName) -} - // IsGhost check if user is fake user for a deleted account func (u *User) IsGhost() bool { if u == nil { @@ -41,36 +38,52 @@ const ( ActionsUserEmail = "teabot@gitea.io" ) -func IsGiteaActionsUserName(name string) bool { - return strings.EqualFold(name, ActionsUserName) -} - // NewActionsUser creates and returns a fake user for running the actions. func NewActionsUser() *User { return &User{ - ID: ActionsUserID, - Name: ActionsUserName, - LowerName: ActionsUserName, - IsActive: true, - FullName: "Gitea Actions", - Email: ActionsUserEmail, - KeepEmailPrivate: true, - LoginName: ActionsUserName, - Type: UserTypeBot, - AllowCreateOrganization: true, - Visibility: structs.VisibleTypePublic, + ID: ActionsUserID, + Name: ActionsUserName, + LowerName: ActionsUserName, + IsActive: true, + FullName: "Gitea Actions", + Email: ActionsUserEmail, + KeepEmailPrivate: true, + LoginName: ActionsUserName, + Type: UserTypeBot, + Visibility: structs.VisibleTypePublic, } } +func NewActionsUserWithTaskID(id int64) *User { + u := NewActionsUser() + // LoginName is for only internal usage in this case, so it can be moved to other fields in the future + u.LoginSource = -1 + u.LoginName = "@" + ActionsUserName + "/" + strconv.FormatInt(id, 10) + return u +} + +func GetActionsUserTaskID(u *User) (int64, bool) { + if u == nil || u.ID != ActionsUserID { + return 0, false + } + prefix, payload, _ := strings.Cut(u.LoginName, "/") + if prefix != "@"+ActionsUserName { + return 0, false + } else if taskID, err := strconv.ParseInt(payload, 10, 64); err == nil { + return taskID, true + } + return 0, false +} + func (u *User) IsGiteaActions() bool { return u != nil && u.ID == ActionsUserID } func GetSystemUserByName(name string) *User { - if IsGhostUserName(name) { + if strings.EqualFold(name, GhostUserName) { return NewGhostUser() } - if IsGiteaActionsUserName(name) { + if strings.EqualFold(name, ActionsUserName) { return NewActionsUser() } return nil diff --git a/models/user/user_system_test.go b/models/user/user_system_test.go index 5aa3fa463c..70a900378f 100644 --- a/models/user/user_system_test.go +++ b/models/user/user_system_test.go @@ -16,14 +16,20 @@ func TestSystemUser(t *testing.T) { assert.Equal(t, "Ghost", u.Name) assert.Equal(t, "ghost", u.LowerName) assert.True(t, u.IsGhost()) - assert.True(t, IsGhostUserName("gHost")) + + u = GetSystemUserByName("gHost") + require.NotNil(t, u) + assert.Equal(t, "Ghost", u.Name) u, err = GetPossibleUserByID(t.Context(), -2) require.NoError(t, err) assert.Equal(t, "gitea-actions", u.Name) assert.Equal(t, "gitea-actions", u.LowerName) assert.True(t, u.IsGiteaActions()) - assert.True(t, IsGiteaActionsUserName("Gitea-actionS")) + + u = GetSystemUserByName("Gitea-actionS") + require.NotNil(t, u) + assert.Equal(t, "Gitea Actions", u.FullName) _, err = GetPossibleUserByID(t.Context(), -3) require.Error(t, err) diff --git a/models/user/user_test.go b/models/user/user_test.go index 4201ec4816..378acc4180 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -51,12 +51,27 @@ func TestOAuth2Application_LoadUser(t *testing.T) { func TestUserEmails(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) + defer test.MockVariableValue(&setting.Service.NoReplyAddress, "NoReply.gitea.internal")() t.Run("GetUserEmailsByNames", func(t *testing.T) { - // ignore none active user email + // ignore not active user email assert.ElementsMatch(t, []string{"user8@example.com"}, user_model.GetUserEmailsByNames(t.Context(), []string{"user8", "user9"})) assert.ElementsMatch(t, []string{"user8@example.com", "user5@example.com"}, user_model.GetUserEmailsByNames(t.Context(), []string{"user8", "user5"})) assert.ElementsMatch(t, []string{"user8@example.com"}, user_model.GetUserEmailsByNames(t.Context(), []string{"user8", "org7"})) }) + + cases := []struct { + Email string + UID int64 + }{ + {"UseR1@example.com", 1}, + {"user1-2@example.COM", 1}, + {"USER2@" + setting.Service.NoReplyAddress, 2}, + {"user2+2@" + setting.Service.NoReplyAddress, 2}, + {"oldUser2UsernameWhichDoesNotMatterForQuery+2@" + setting.Service.NoReplyAddress, 2}, + {"badUser+99999@" + setting.Service.NoReplyAddress, 0}, + {"user4@example.com", 4}, + {"no-such", 0}, + } t.Run("GetUsersByEmails", func(t *testing.T) { defer test.MockVariableValue(&setting.Service.NoReplyAddress, "NoReply.gitea.internal")() testGetUserByEmail := func(t *testing.T, email string, uid int64) { @@ -70,15 +85,27 @@ func TestUserEmails(t *testing.T) { require.NotNil(t, user) assert.Equal(t, uid, user.ID) } - cases := []struct { - Email string - UID int64 - }{ - {"UseR1@example.com", 1}, - {"user1-2@example.COM", 1}, - {"USER2@" + setting.Service.NoReplyAddress, 2}, - {"user4@example.com", 4}, - {"no-such", 0}, + for _, c := range cases { + t.Run(c.Email, func(t *testing.T) { + testGetUserByEmail(t, c.Email, c.UID) + }) + } + + t.Run("NoReplyConflict", func(t *testing.T) { + setting.Service.NoReplyAddress = "example.com" + testGetUserByEmail(t, "user1-2@example.COM", 1) + }) + }) + t.Run("GetUserByEmail", func(t *testing.T) { + testGetUserByEmail := func(t *testing.T, email string, uid int64) { + user, err := user_model.GetUserByEmail(t.Context(), email) + if uid == 0 { + require.Error(t, err) + assert.Nil(t, user) + } else { + require.NotNil(t, user) + assert.Equal(t, uid, user.ID) + } } for _, c := range cases { t.Run(c.Email, func(t *testing.T) { @@ -126,7 +153,7 @@ func TestSearchUsers(t *testing.T) { // test orgs testOrgSuccess := func(opts user_model.SearchUserOptions, expectedOrgIDs []int64) { - opts.Type = user_model.UserTypeOrganization + opts.Types = []user_model.UserType{user_model.UserTypeOrganization} testSuccess(opts, expectedOrgIDs) } @@ -150,7 +177,7 @@ func TestSearchUsers(t *testing.T) { // test users testUserSuccess := func(opts user_model.SearchUserOptions, expectedUserIDs []int64) { - opts.Type = user_model.UserTypeIndividual + opts.Types = []user_model.UserType{user_model.UserTypeIndividual} testSuccess(opts, expectedUserIDs) } @@ -648,33 +675,36 @@ func TestGetInactiveUsers(t *testing.T) { func TestCanCreateRepo(t *testing.T) { defer test.MockVariableValue(&setting.Repository.MaxCreationLimit)() const noLimit = -1 - doerNormal := &user_model.User{} - doerAdmin := &user_model.User{IsAdmin: true} + doerActions := user_model.NewActionsUser() + doerNormal := &user_model.User{ID: 2} + doerAdmin := &user_model.User{ID: 1, IsAdmin: true} t.Run("NoGlobalLimit", func(t *testing.T) { setting.Repository.MaxCreationLimit = noLimit - assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: noLimit})) - assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 0})) - assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 100})) + assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 0})) + assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100})) + assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: noLimit})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 0})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 100})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 0})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit})) + assert.False(t, doerActions.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit})) + assert.False(t, doerAdmin.CanCreateRepoIn(doerActions)) }) t.Run("GlobalLimit50", func(t *testing.T) { setting.Repository.MaxCreationLimit = 50 - assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: noLimit})) - assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 60, MaxRepoCreation: noLimit})) // limited by global limit - assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 0})) - assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 100})) - assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 60, MaxRepoCreation: 100})) + assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit})) + assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: noLimit})) // limited by global limit + assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 0})) + assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100})) + assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: 100})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: noLimit})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 60, MaxRepoCreation: noLimit})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 0})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 100})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 60, MaxRepoCreation: 100})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: noLimit})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 0})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: 100})) }) } diff --git a/models/webhook/webhook_system.go b/models/webhook/webhook_system.go index 58d9d4a5c1..e8b5040c96 100644 --- a/models/webhook/webhook_system.go +++ b/models/webhook/webhook_system.go @@ -9,19 +9,32 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/optional" + + "xorm.io/builder" ) -// GetSystemOrDefaultWebhooks returns webhooks by given argument or all if argument is missing. -func GetSystemOrDefaultWebhooks(ctx context.Context, isSystemWebhook optional.Option[bool]) ([]*Webhook, error) { - webhooks := make([]*Webhook, 0, 5) - if !isSystemWebhook.Has() { - return webhooks, db.GetEngine(ctx).Where("repo_id=? AND owner_id=?", 0, 0). - Find(&webhooks) - } +// ListSystemWebhookOptions options for listing system or default webhooks +type ListSystemWebhookOptions struct { + db.ListOptions + IsActive optional.Option[bool] + IsSystem optional.Option[bool] +} - return webhooks, db.GetEngine(ctx). - Where("repo_id=? AND owner_id=? AND is_system_webhook=?", 0, 0, isSystemWebhook.Value()). - Find(&webhooks) +func (opts ListSystemWebhookOptions) ToConds() builder.Cond { + cond := builder.NewCond() + cond = cond.And(builder.Eq{"webhook.repo_id": 0}, builder.Eq{"webhook.owner_id": 0}) + if opts.IsActive.Has() { + cond = cond.And(builder.Eq{"webhook.is_active": opts.IsActive.Value()}) + } + if opts.IsSystem.Has() { + cond = cond.And(builder.Eq{"is_system_webhook": opts.IsSystem.Value()}) + } + return cond +} + +// GetGlobalWebhooks returns global (default and/or system) webhooks +func GetGlobalWebhooks(ctx context.Context, opts *ListSystemWebhookOptions) ([]*Webhook, int64, error) { + return db.FindAndCount[Webhook](ctx, opts) } // GetDefaultWebhooks returns all admin-default webhooks. diff --git a/models/webhook/webhook_system_test.go b/models/webhook/webhook_system_test.go index 8aac693995..d0013c6873 100644 --- a/models/webhook/webhook_system_test.go +++ b/models/webhook/webhook_system_test.go @@ -12,23 +12,24 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGetSystemOrDefaultWebhooks(t *testing.T) { +func TestListSystemWebhookOptions(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - - hooks, err := GetSystemOrDefaultWebhooks(t.Context(), optional.None[bool]()) + opts := ListSystemWebhookOptions{IsSystem: optional.None[bool]()} + hooks, _, err := GetGlobalWebhooks(t.Context(), &opts) assert.NoError(t, err) if assert.Len(t, hooks, 2) { assert.Equal(t, int64(5), hooks[0].ID) assert.Equal(t, int64(6), hooks[1].ID) } - - hooks, err = GetSystemOrDefaultWebhooks(t.Context(), optional.Some(true)) + opts.IsSystem = optional.Some(true) + hooks, _, err = GetGlobalWebhooks(t.Context(), &opts) assert.NoError(t, err) if assert.Len(t, hooks, 1) { assert.Equal(t, int64(5), hooks[0].ID) } - hooks, err = GetSystemOrDefaultWebhooks(t.Context(), optional.Some(false)) + opts.IsSystem = optional.Some(false) + hooks, _, err = GetGlobalWebhooks(t.Context(), &opts) assert.NoError(t, err) if assert.Len(t, hooks, 1) { assert.Equal(t, int64(6), hooks[0].ID) diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go index 69f71bf651..26a6ebc370 100644 --- a/modules/actions/workflows.go +++ b/modules/actions/workflows.go @@ -5,7 +5,6 @@ package actions import ( "bytes" - "io" "slices" "strings" @@ -13,6 +12,7 @@ import ( "code.gitea.io/gitea/modules/glob" "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" "github.com/nektos/act/pkg/jobparser" @@ -77,7 +77,7 @@ func GetContentFromEntry(entry *git.TreeEntry) ([]byte, error) { if err != nil { return nil, err } - content, err := io.ReadAll(f) + content, err := util.ReadWithLimit(f, 1024*1024) _ = f.Close() if err != nil { return nil, err diff --git a/modules/analyze/code_language.go b/modules/analyze/code_language.go index 74e7a06d06..d8589861d3 100644 --- a/modules/analyze/code_language.go +++ b/modules/analyze/code_language.go @@ -4,12 +4,13 @@ package analyze import ( - "path/filepath" + "path" "github.com/go-enry/go-enry/v2" ) // GetCodeLanguage detects code language based on file name and content +// It can be slow when the content is used for detection func GetCodeLanguage(filename string, content []byte) string { if language, ok := enry.GetLanguageByExtension(filename); ok { return language @@ -23,5 +24,5 @@ func GetCodeLanguage(filename string, content []byte) string { return enry.OtherLanguage } - return enry.GetLanguage(filepath.Base(filename), content) + return enry.GetLanguage(path.Base(filename), content) } diff --git a/modules/analyze/vendor.go b/modules/analyze/vendor.go index adcca923dd..f3e75f535f 100644 --- a/modules/analyze/vendor.go +++ b/modules/analyze/vendor.go @@ -4,10 +4,28 @@ package analyze import ( + "path" + "strings" + "github.com/go-enry/go-enry/v2" ) -// IsVendor returns whether or not path is a vendor path. -func IsVendor(path string) bool { - return enry.IsVendor(path) +// IsVendor returns whether the path is a vendor path. +// It uses go-enry's IsVendor function but overrides its detection for certain +// special cases that shouldn't be marked as vendored in the diff view. +func IsVendor(treePath string) bool { + if !enry.IsVendor(treePath) { + return false + } + + // Override detection for single files + basename := path.Base(treePath) + switch basename { + case ".gitignore", ".gitattributes", ".gitmodules": + return false + } + if strings.HasPrefix(treePath, ".github/") || strings.HasPrefix(treePath, ".gitea/") { + return false + } + return true } diff --git a/modules/analyze/vendor_test.go b/modules/analyze/vendor_test.go index 02a51d4c8f..6efb825de6 100644 --- a/modules/analyze/vendor_test.go +++ b/modules/analyze/vendor_test.go @@ -14,6 +14,7 @@ func TestIsVendor(t *testing.T) { path string want bool }{ + // Original go-enry test cases {"cache/", true}, {"random/cache/", true}, {"cache", false}, @@ -34,6 +35,14 @@ func TestIsVendor(t *testing.T) { {"a/docs/_build/", true}, {"a/dasdocs/_build-vsdoc.js", true}, {"a/dasdocs/_build-vsdoc.j", false}, + + // Override: Git/GitHub/Gitea-related paths should NOT be detected as vendored + {".gitignore", false}, + {".gitattributes", false}, + {".gitmodules", false}, + {"src/.gitignore", false}, + {".github/workflows/ci.yml", false}, + {".gitea/workflows/ci.yml", false}, } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { diff --git a/modules/assetfs/layered.go b/modules/assetfs/layered.go index ce55475bd9..41e4ca7376 100644 --- a/modules/assetfs/layered.go +++ b/modules/assetfs/layered.go @@ -6,9 +6,7 @@ package assetfs import ( "context" "fmt" - "io" "io/fs" - "net/http" "os" "path/filepath" "sort" @@ -25,7 +23,7 @@ import ( // Layer represents a layer in a layered asset file-system. It has a name and works like http.FileSystem type Layer struct { name string - fs http.FileSystem + fs fs.FS localPath string } @@ -34,7 +32,7 @@ func (l *Layer) Name() string { } // Open opens the named file. The caller is responsible for closing the file. -func (l *Layer) Open(name string) (http.File, error) { +func (l *Layer) Open(name string) (fs.File, error) { return l.fs.Open(name) } @@ -48,12 +46,12 @@ func Local(name, base string, sub ...string) *Layer { panic(fmt.Sprintf("Unable to get absolute path for %q: %v", base, err)) } root := util.FilePathJoinAbs(base, sub...) - return &Layer{name: name, fs: http.Dir(root), localPath: root} + return &Layer{name: name, fs: os.DirFS(root), localPath: root} } // Bindata returns a new Layer with the given name, it serves files from the given bindata asset. func Bindata(name string, fs fs.FS) *Layer { - return &Layer{name: name, fs: http.FS(fs)} + return &Layer{name: name, fs: fs} } // LayeredFS is a layered asset file-system. It works like http.FileSystem, but it can have multiple layers. @@ -69,7 +67,7 @@ func Layered(layers ...*Layer) *LayeredFS { } // Open opens the named file. The caller is responsible for closing the file. -func (l *LayeredFS) Open(name string) (http.File, error) { +func (l *LayeredFS) Open(name string) (fs.File, error) { for _, layer := range l.layers { f, err := layer.Open(name) if err == nil || !os.IsNotExist(err) { @@ -89,40 +87,34 @@ func (l *LayeredFS) ReadFile(elems ...string) ([]byte, error) { func (l *LayeredFS) ReadLayeredFile(elems ...string) ([]byte, string, error) { name := util.PathJoinRel(elems...) for _, layer := range l.layers { - f, err := layer.Open(name) + bs, err := fs.ReadFile(layer, name) if os.IsNotExist(err) { continue } else if err != nil { return nil, layer.name, err } - bs, err := io.ReadAll(f) - _ = f.Close() return bs, layer.name, err } return nil, "", fs.ErrNotExist } -func shouldInclude(info fs.FileInfo, fileMode ...bool) bool { - if util.IsCommonHiddenFileName(info.Name()) { +func shouldInclude(dirEntry fs.DirEntry, fileMode ...bool) bool { + if util.IsCommonHiddenFileName(dirEntry.Name()) { return false } if len(fileMode) == 0 { return true } else if len(fileMode) == 1 { - return fileMode[0] == !info.Mode().IsDir() + return fileMode[0] == !dirEntry.IsDir() } panic("too many arguments for fileMode in shouldInclude") } -func readDir(layer *Layer, name string) ([]fs.FileInfo, error) { - f, err := layer.Open(name) - if os.IsNotExist(err) { +func readDirOptional(layer *Layer, name string) (entries []fs.DirEntry, err error) { + if entries, err = fs.ReadDir(layer, name); os.IsNotExist(err) { return nil, nil - } else if err != nil { - return nil, err } - defer f.Close() - return f.Readdir(-1) + return entries, err } // ListFiles lists files/directories in the given directory. The fileMode controls the returned files. @@ -133,13 +125,13 @@ func readDir(layer *Layer, name string) ([]fs.FileInfo, error) { func (l *LayeredFS) ListFiles(name string, fileMode ...bool) ([]string, error) { fileSet := make(container.Set[string]) for _, layer := range l.layers { - infos, err := readDir(layer, name) + entries, err := readDirOptional(layer, name) if err != nil { return nil, err } - for _, info := range infos { - if shouldInclude(info, fileMode...) { - fileSet.Add(info.Name()) + for _, entry := range entries { + if shouldInclude(entry, fileMode...) { + fileSet.Add(entry.Name()) } } } @@ -163,16 +155,16 @@ func listAllFiles(layers []*Layer, name string, fileMode ...bool) ([]string, err var list func(dir string) error list = func(dir string) error { for _, layer := range layers { - infos, err := readDir(layer, dir) + entries, err := readDirOptional(layer, dir) if err != nil { return err } - for _, info := range infos { - path := util.PathJoinRelX(dir, info.Name()) - if shouldInclude(info, fileMode...) { + for _, entry := range entries { + path := util.PathJoinRelX(dir, entry.Name()) + if shouldInclude(entry, fileMode...) { fileSet.Add(path) } - if info.IsDir() { + if entry.IsDir() { if err = list(path); err != nil { return err } diff --git a/modules/auth/webauthn/webauthn.go b/modules/auth/webauthn/webauthn.go index cbf5279c65..86f55c6b24 100644 --- a/modules/auth/webauthn/webauthn.go +++ b/modules/auth/webauthn/webauthn.go @@ -22,7 +22,7 @@ var WebAuthn *webauthn.WebAuthn // Init initializes the WebAuthn instance from the config. func Init() { - gob.Register(&webauthn.SessionData{}) + gob.Register(&webauthn.SessionData{}) // TODO: CHI-SESSION-GOB-REGISTER. appURL, _ := protocol.FullyQualifiedOrigin(setting.AppURL) diff --git a/modules/base/natural_sort.go b/modules/base/natural_sort.go index acb9002276..d1ee7b04ec 100644 --- a/modules/base/natural_sort.go +++ b/modules/base/natural_sort.go @@ -41,8 +41,8 @@ func naturalSortAdvance(str string, pos int) (end int, isNumber bool) { return end, isNumber } -// NaturalSortLess compares two strings so that they could be sorted in natural order -func NaturalSortLess(s1, s2 string) bool { +// NaturalSortCompare compares two strings so that they could be sorted in natural order +func NaturalSortCompare(s1, s2 string) int { // There is a bug in Golang's collate package: https://github.com/golang/go/issues/67997 // text/collate: CompareString(collate.Numeric) returns wrong result for "0.0" vs "1.0" #67997 // So we need to handle the number parts by ourselves @@ -55,16 +55,16 @@ func NaturalSortLess(s1, s2 string) bool { if isNum1 && isNum2 { if part1 != part2 { if len(part1) != len(part2) { - return len(part1) < len(part2) + return len(part1) - len(part2) } - return part1 < part2 + return c.CompareString(part1, part2) } } else { if cmp := c.CompareString(part1, part2); cmp != 0 { - return cmp < 0 + return cmp } } pos1, pos2 = end1, end2 } - return len(s1) < len(s2) + return len(s1) - len(s2) } diff --git a/modules/base/natural_sort_test.go b/modules/base/natural_sort_test.go index b001bc4ac9..451aba6618 100644 --- a/modules/base/natural_sort_test.go +++ b/modules/base/natural_sort_test.go @@ -11,12 +11,10 @@ import ( func TestNaturalSortLess(t *testing.T) { testLess := func(s1, s2 string) { - assert.True(t, NaturalSortLess(s1, s2), "s1= len(content) { - break + if opts.ErrorReturnOrigin { + return content } + if opts.ErrorReplacement == nil { + decoded = append(decoded, content[idx+n]) + } else { + decoded = append(decoded, opts.ErrorReplacement...) + } + idx += n + 1 } - - return MaybeRemoveBOM(decoded, opts) + return maybeRemoveBOM(decoded, opts) } -// MaybeRemoveBOM removes a UTF-8 BOM from a []byte when opts.KeepBOM is false -func MaybeRemoveBOM(content []byte, opts ConvertOpts) []byte { +// maybeRemoveBOM removes a UTF-8 BOM from a []byte when opts.KeepBOM is false +func maybeRemoveBOM(content []byte, opts ConvertOpts) []byte { if opts.KeepBOM { return content } - if len(content) > 2 && bytes.Equal(content[0:3], UTF8BOM) { - return content[3:] - } - return content + return bytes.TrimPrefix(content, UTF8BOM) } // DetectEncoding detect the encoding of content -func DetectEncoding(content []byte) (string, error) { +// it always returns a detected or guessed "encoding" string, no matter error happens or not +func DetectEncoding(content []byte) (encoding string, _ error) { // First we check if the content represents valid utf8 content excepting a truncated character at the end. // Now we could decode all the runes in turn but this is not necessarily the cheapest thing to do - // instead we walk backwards from the end to trim off a the incomplete character + // instead we walk backwards from the end to trim off the incomplete character toValidate := content end := len(toValidate) - 1 - if end < 0 { - // no-op - } else if toValidate[end]>>5 == 0b110 { - // Incomplete 1 byte extension e.g. © which has been truncated to - toValidate = toValidate[:end] - } else if end > 0 && toValidate[end]>>6 == 0b10 && toValidate[end-1]>>4 == 0b1110 { - // Incomplete 2 byte extension e.g. ⛔ <9b><94> which has been truncated to <9b> - toValidate = toValidate[:end-1] - } else if end > 1 && toValidate[end]>>6 == 0b10 && toValidate[end-1]>>6 == 0b10 && toValidate[end-2]>>3 == 0b11110 { - // Incomplete 3 byte extension e.g. 💩 <9f><92> which has been truncated to <9f><92> - toValidate = toValidate[:end-2] + // U+0000 U+007F 0yyyzzzz + // U+0080 U+07FF 110xxxyy 10yyzzzz + // U+0800 U+FFFF 1110wwww 10xxxxyy 10yyzzzz + // U+010000 U+10FFFF 11110uvv 10vvwwww 10xxxxyy 10yyzzzz + cnt := 0 + for end >= 0 && cnt < 4 { + c := toValidate[end] + if c>>5 == 0b110 || c>>4 == 0b1110 || c>>3 == 0b11110 { + // a leading byte + toValidate = toValidate[:end] + break + } else if c>>6 == 0b10 { + // a continuation byte + end-- + } else { + // not an utf-8 byte + break + } + cnt++ } + if utf8.Valid(toValidate) { - log.Debug("Detected encoding: utf-8 (fast)") return "UTF-8", nil } @@ -160,7 +148,7 @@ func DetectEncoding(content []byte) (string, error) { if len(content) < 1024 { // Check if original content is valid if _, err := textDetector.DetectBest(content); err != nil { - return "", err + return util.IfZero(setting.Repository.AnsiCharset, "UTF-8"), err } times := 1024 / len(content) detectContent = make([]byte, 0, times*len(content)) @@ -171,14 +159,10 @@ func DetectEncoding(content []byte) (string, error) { detectContent = content } - // Now we can't use DetectBest or just results[0] because the result isn't stable - so we need a tie break + // Now we can't use DetectBest or just results[0] because the result isn't stable - so we need a tie-break results, err := textDetector.DetectAll(detectContent) if err != nil { - if err == chardet.NotDetectedError && len(setting.Repository.AnsiCharset) > 0 { - log.Debug("Using default AnsiCharset: %s", setting.Repository.AnsiCharset) - return setting.Repository.AnsiCharset, nil - } - return "", err + return util.IfZero(setting.Repository.AnsiCharset, "UTF-8"), err } topConfidence := results[0].Confidence @@ -201,11 +185,9 @@ func DetectEncoding(content []byte) (string, error) { } // FIXME: to properly decouple this function the fallback ANSI charset should be passed as an argument - if topResult.Charset != "UTF-8" && len(setting.Repository.AnsiCharset) > 0 { - log.Debug("Using default AnsiCharset: %s", setting.Repository.AnsiCharset) + if topResult.Charset != "UTF-8" && setting.Repository.AnsiCharset != "" { return setting.Repository.AnsiCharset, err } - log.Debug("Detected encoding: %s", topResult.Charset) - return topResult.Charset, err + return topResult.Charset, nil } diff --git a/modules/charset/charset_test.go b/modules/charset/charset_test.go index cd2e3b9aaa..0314abc347 100644 --- a/modules/charset/charset_test.go +++ b/modules/charset/charset_test.go @@ -4,108 +4,89 @@ package charset import ( - "bytes" "io" + "os" "strings" "testing" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" "github.com/stretchr/testify/assert" ) -func resetDefaultCharsetsOrder() { - defaultDetectedCharsetsOrder := make([]string, 0, len(setting.Repository.DetectedCharsetsOrder)) - for _, charset := range setting.Repository.DetectedCharsetsOrder { - defaultDetectedCharsetsOrder = append(defaultDetectedCharsetsOrder, strings.ToLower(strings.TrimSpace(charset))) - } +func TestMain(m *testing.M) { setting.Repository.DetectedCharsetScore = map[string]int{} - i := 0 - for _, charset := range defaultDetectedCharsetsOrder { - canonicalCharset := strings.ToLower(strings.TrimSpace(charset)) - if _, has := setting.Repository.DetectedCharsetScore[canonicalCharset]; !has { - setting.Repository.DetectedCharsetScore[canonicalCharset] = i - i++ - } + for i, charset := range setting.Repository.DetectedCharsetsOrder { + setting.Repository.DetectedCharsetScore[strings.ToLower(charset)] = i } + os.Exit(m.Run()) } func TestMaybeRemoveBOM(t *testing.T) { - res := MaybeRemoveBOM([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) + res := maybeRemoveBOM([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res) - res = MaybeRemoveBOM([]byte{0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) + res = maybeRemoveBOM([]byte{0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res) } func TestToUTF8(t *testing.T) { - resetDefaultCharsetsOrder() - // Note: golang compiler seems so behave differently depending on the current // locale, so some conversions might behave differently. For that reason, we don't // depend on particular conversions but in expected behaviors. - res, err := ToUTF8([]byte{0x41, 0x42, 0x43}, ConvertOpts{}) - assert.NoError(t, err) - assert.Equal(t, "ABC", res) + res := ToUTF8([]byte{0x41, 0x42, 0x43}, ConvertOpts{}) + assert.Equal(t, "ABC", string(res)) // "áéíóú" - res, err = ToUTF8([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) - assert.NoError(t, err) - assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, []byte(res)) + res = ToUTF8([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) + assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res) // "áéíóú" - res, err = ToUTF8([]byte{ + res = ToUTF8([]byte{ 0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba, }, ConvertOpts{}) - assert.NoError(t, err) - assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, []byte(res)) + assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res) - res, err = ToUTF8([]byte{ + res = ToUTF8([]byte{ 0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0xF1, 0x6F, 0x73, 0x41, 0x41, 0x41, 0x2e, }, ConvertOpts{}) - assert.NoError(t, err) stringMustStartWith(t, "Hola,", res) stringMustEndWith(t, "AAA.", res) - res, err = ToUTF8([]byte{ + res = ToUTF8([]byte{ 0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x07, 0xA4, 0x6F, 0x73, 0x41, 0x41, 0x41, 0x2e, }, ConvertOpts{}) - assert.NoError(t, err) stringMustStartWith(t, "Hola,", res) stringMustEndWith(t, "AAA.", res) - res, err = ToUTF8([]byte{ + res = ToUTF8([]byte{ 0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x81, 0xA4, 0x6F, 0x73, 0x41, 0x41, 0x41, 0x2e, }, ConvertOpts{}) - assert.NoError(t, err) stringMustStartWith(t, "Hola,", res) stringMustEndWith(t, "AAA.", res) // Japanese (Shift-JIS) // 日属秘ぞしちゅ。 - res, err = ToUTF8([]byte{ + res = ToUTF8([]byte{ 0x93, 0xFA, 0x91, 0xAE, 0x94, 0xE9, 0x82, 0xBC, 0x82, 0xB5, 0x82, 0xBF, 0x82, 0xE3, 0x81, 0x42, }, ConvertOpts{}) - assert.NoError(t, err) assert.Equal(t, []byte{ 0xE6, 0x97, 0xA5, 0xE5, 0xB1, 0x9E, 0xE7, 0xA7, 0x98, 0xE3, 0x81, 0x9E, 0xE3, 0x81, 0x97, 0xE3, 0x81, 0xA1, 0xE3, 0x82, 0x85, 0xE3, 0x80, 0x82, - }, - []byte(res)) + }, res) - res, err = ToUTF8([]byte{0x00, 0x00, 0x00, 0x00}, ConvertOpts{}) - assert.NoError(t, err) - assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, []byte(res)) + res = ToUTF8([]byte{0x00, 0x00, 0x00, 0x00}, ConvertOpts{}) + assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res) } func TestToUTF8WithFallback(t *testing.T) { - resetDefaultCharsetsOrder() // "ABC" res := ToUTF8WithFallback([]byte{0x41, 0x42, 0x43}, ConvertOpts{}) assert.Equal(t, []byte{0x41, 0x42, 0x43}, res) @@ -152,54 +133,58 @@ func TestToUTF8WithFallback(t *testing.T) { } func TestToUTF8DropErrors(t *testing.T) { - resetDefaultCharsetsOrder() // "ABC" - res := ToUTF8DropErrors([]byte{0x41, 0x42, 0x43}, ConvertOpts{}) + res := ToUTF8DropErrors([]byte{0x41, 0x42, 0x43}) assert.Equal(t, []byte{0x41, 0x42, 0x43}, res) // "áéíóú" - res = ToUTF8DropErrors([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) + res = ToUTF8DropErrors([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}) assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res) // UTF8 BOM + "áéíóú" - res = ToUTF8DropErrors([]byte{0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) + res = ToUTF8DropErrors([]byte{0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}) assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res) // "Hola, así cómo ños" - res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0xF1, 0x6F, 0x73}, ConvertOpts{}) + res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0xF1, 0x6F, 0x73}) assert.Equal(t, []byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73}, res[:8]) assert.Equal(t, []byte{0x73}, res[len(res)-1:]) // "Hola, así cómo " minmatch := []byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xC3, 0xAD, 0x20, 0x63, 0xC3, 0xB3, 0x6D, 0x6F, 0x20} - res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x07, 0xA4, 0x6F, 0x73}, ConvertOpts{}) + res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x07, 0xA4, 0x6F, 0x73}) // Do not fail for differences in invalid cases, as the library might change the conversion criteria for those assert.Equal(t, minmatch, res[0:len(minmatch)]) - res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x81, 0xA4, 0x6F, 0x73}, ConvertOpts{}) + res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x81, 0xA4, 0x6F, 0x73}) // Do not fail for differences in invalid cases, as the library might change the conversion criteria for those assert.Equal(t, minmatch, res[0:len(minmatch)]) // Japanese (Shift-JIS) // "日属秘ぞしちゅ。" - res = ToUTF8DropErrors([]byte{0x93, 0xFA, 0x91, 0xAE, 0x94, 0xE9, 0x82, 0xBC, 0x82, 0xB5, 0x82, 0xBF, 0x82, 0xE3, 0x81, 0x42}, ConvertOpts{}) + res = ToUTF8DropErrors([]byte{0x93, 0xFA, 0x91, 0xAE, 0x94, 0xE9, 0x82, 0xBC, 0x82, 0xB5, 0x82, 0xBF, 0x82, 0xE3, 0x81, 0x42}) assert.Equal(t, []byte{ 0xE6, 0x97, 0xA5, 0xE5, 0xB1, 0x9E, 0xE7, 0xA7, 0x98, 0xE3, 0x81, 0x9E, 0xE3, 0x81, 0x97, 0xE3, 0x81, 0xA1, 0xE3, 0x82, 0x85, 0xE3, 0x80, 0x82, }, res) - res = ToUTF8DropErrors([]byte{0x00, 0x00, 0x00, 0x00}, ConvertOpts{}) + res = ToUTF8DropErrors([]byte{0x00, 0x00, 0x00, 0x00}) assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res) } func TestDetectEncoding(t *testing.T) { - resetDefaultCharsetsOrder() testSuccess := func(b []byte, expected string) { encoding, err := DetectEncoding(b) assert.NoError(t, err) assert.Equal(t, expected, encoding) } + + // invalid bytes + encoding, err := DetectEncoding([]byte{0xfa}) + assert.Error(t, err) + assert.Equal(t, "UTF-8", encoding) + // utf-8 b := []byte("just some ascii") testSuccess(b, "UTF-8") @@ -214,169 +199,49 @@ func TestDetectEncoding(t *testing.T) { // iso-8859-1: dcor b = []byte{0x44, 0xe9, 0x63, 0x6f, 0x72, 0x0a} - encoding, err := DetectEncoding(b) + encoding, err = DetectEncoding(b) assert.NoError(t, err) assert.Contains(t, encoding, "ISO-8859-1") - old := setting.Repository.AnsiCharset - setting.Repository.AnsiCharset = "placeholder" - defer func() { - setting.Repository.AnsiCharset = old - }() - testSuccess(b, "placeholder") - - // invalid bytes - b = []byte{0xfa} - _, err = DetectEncoding(b) - assert.Error(t, err) + defer test.MockVariableValue(&setting.Repository.AnsiCharset, "MyEncoding")() + testSuccess(b, "MyEncoding") } -func stringMustStartWith(t *testing.T, expected, value string) { - assert.Equal(t, expected, value[:len(expected)]) +func stringMustStartWith(t *testing.T, expected string, value []byte) { + assert.Equal(t, expected, string(value[:len(expected)])) } -func stringMustEndWith(t *testing.T, expected, value string) { - assert.Equal(t, expected, value[len(value)-len(expected):]) +func stringMustEndWith(t *testing.T, expected string, value []byte) { + assert.Equal(t, expected, string(value[len(value)-len(expected):])) } func TestToUTF8WithFallbackReader(t *testing.T) { - resetDefaultCharsetsOrder() + test.MockVariableValue(&ToUTF8WithFallbackReaderPrefetchSize) - for testLen := range 2048 { - pattern := " test { () }\n" - input := "" - for len(input) < testLen { - input += pattern - } - input = input[:testLen] - input += "// Выключаем" - rd := ToUTF8WithFallbackReader(bytes.NewReader([]byte(input)), ConvertOpts{}) + block := "aá啊🤔" + runes := []rune(block) + assert.Len(t, string(runes[0]), 1) + assert.Len(t, string(runes[1]), 2) + assert.Len(t, string(runes[2]), 3) + assert.Len(t, string(runes[3]), 4) + + content := strings.Repeat(block, 2) + for i := 1; i < len(content); i++ { + encoding, err := DetectEncoding([]byte(content[:i])) + assert.NoError(t, err) + assert.Equal(t, "UTF-8", encoding) + + ToUTF8WithFallbackReaderPrefetchSize = i + rd := ToUTF8WithFallbackReader(strings.NewReader(content), ConvertOpts{}) r, _ := io.ReadAll(rd) - assert.Equalf(t, input, string(r), "testing string len=%d", testLen) + assert.Equal(t, content, string(r)) + } + for _, r := range runes { + content = "abc abc " + string(r) + string(r) + string(r) + for i := 0; i < len(content); i++ { + encoding, err := DetectEncoding([]byte(content[:i])) + assert.NoError(t, err) + assert.Equal(t, "UTF-8", encoding) + } } - - truncatedOneByteExtension := failFastBytes - encoding, _ := DetectEncoding(truncatedOneByteExtension) - assert.Equal(t, "UTF-8", encoding) - - truncatedTwoByteExtension := failFastBytes - truncatedTwoByteExtension[len(failFastBytes)-1] = 0x9b - truncatedTwoByteExtension[len(failFastBytes)-2] = 0xe2 - - encoding, _ = DetectEncoding(truncatedTwoByteExtension) - assert.Equal(t, "UTF-8", encoding) - - truncatedThreeByteExtension := failFastBytes - truncatedThreeByteExtension[len(failFastBytes)-1] = 0x92 - truncatedThreeByteExtension[len(failFastBytes)-2] = 0x9f - truncatedThreeByteExtension[len(failFastBytes)-3] = 0xf0 - - encoding, _ = DetectEncoding(truncatedThreeByteExtension) - assert.Equal(t, "UTF-8", encoding) -} - -var failFastBytes = []byte{ - 0x69, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x20, 0x6f, 0x72, 0x67, 0x2e, 0x61, 0x70, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x74, 0x6f, - 0x6f, 0x6c, 0x73, 0x2e, 0x61, 0x6e, 0x74, 0x2e, 0x74, 0x61, 0x73, 0x6b, 0x64, 0x65, 0x66, 0x73, 0x2e, 0x63, 0x6f, 0x6e, - 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x4f, 0x73, 0x0a, 0x69, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x20, 0x6f, 0x72, 0x67, - 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f, - 0x74, 0x2e, 0x67, 0x72, 0x61, 0x64, 0x6c, 0x65, 0x2e, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2e, 0x72, 0x75, 0x6e, 0x2e, 0x42, - 0x6f, 0x6f, 0x74, 0x52, 0x75, 0x6e, 0x0a, 0x0a, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x20, 0x7b, 0x0a, 0x20, 0x20, - 0x20, 0x20, 0x69, 0x64, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, - 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x22, 0x29, 0x0a, 0x7d, 0x0a, 0x0a, 0x64, 0x65, 0x70, 0x65, - 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, - 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x61, 0x70, 0x69, 0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, - 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, - 0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x61, 0x70, 0x69, 0x2d, 0x64, 0x6f, 0x63, 0x73, 0x22, 0x29, - 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x64, 0x62, - 0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, - 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x66, - 0x73, 0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x3a, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x6d, 0x71, 0x22, 0x29, 0x29, 0x0a, 0x0a, - 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, - 0x6a, 0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, - 0x2d, 0x61, 0x75, 0x74, 0x68, 0x2d, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2d, 0x73, 0x74, 0x61, 0x72, 0x74, - 0x65, 0x72, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6a, 0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63, - 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2d, 0x68, 0x61, 0x6c, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6a, 0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e, - 0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2d, 0x63, 0x6f, 0x72, 0x65, 0x22, 0x29, 0x0a, - 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, - 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, - 0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x2d, 0x73, 0x74, - 0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x77, 0x65, 0x62, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, - 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x3a, 0x73, 0x70, 0x72, - 0x69, 0x6e, 0x67, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x2d, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x61, 0x6f, 0x70, - 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, - 0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x2d, - 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x61, 0x63, 0x74, 0x75, 0x61, 0x74, 0x6f, 0x72, 0x22, 0x29, 0x0a, 0x20, - 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, - 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x63, - 0x6c, 0x6f, 0x75, 0x64, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x74, - 0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x73, 0x74, 0x72, 0x61, 0x70, 0x22, 0x29, 0x0a, 0x20, 0x20, - 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72, - 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x63, 0x6c, - 0x6f, 0x75, 0x64, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x74, 0x61, - 0x72, 0x74, 0x65, 0x72, 0x2d, 0x63, 0x6f, 0x6e, 0x73, 0x75, 0x6c, 0x2d, 0x61, 0x6c, 0x6c, 0x22, 0x29, 0x0a, 0x20, 0x20, - 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72, - 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x63, 0x6c, - 0x6f, 0x75, 0x64, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x74, 0x61, - 0x72, 0x74, 0x65, 0x72, 0x2d, 0x73, 0x6c, 0x65, 0x75, 0x74, 0x68, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, - 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, - 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x72, 0x65, 0x74, 0x72, 0x79, 0x3a, - 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x72, 0x65, 0x74, 0x72, 0x79, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, - 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x63, 0x68, 0x2e, 0x71, - 0x6f, 0x73, 0x2e, 0x6c, 0x6f, 0x67, 0x62, 0x61, 0x63, 0x6b, 0x3a, 0x6c, 0x6f, 0x67, 0x62, 0x61, 0x63, 0x6b, 0x2d, 0x63, - 0x6c, 0x61, 0x73, 0x73, 0x69, 0x63, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x69, 0x6f, 0x2e, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x6d, 0x65, - 0x74, 0x65, 0x72, 0x3a, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x2d, 0x72, 0x65, 0x67, 0x69, 0x73, - 0x74, 0x72, 0x79, 0x2d, 0x70, 0x72, 0x6f, 0x6d, 0x65, 0x74, 0x68, 0x65, 0x75, 0x73, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20, - 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x6b, 0x6f, 0x74, - 0x6c, 0x69, 0x6e, 0x28, 0x22, 0x73, 0x74, 0x64, 0x6c, 0x69, 0x62, 0x22, 0x29, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, - 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, - 0x2f, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x2f, 0x2f, 0x20, 0x54, 0x65, 0x73, 0x74, 0x20, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, - 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x2e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, - 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x74, - 0x65, 0x73, 0x74, 0x49, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6a, - 0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2d, - 0x74, 0x65, 0x73, 0x74, 0x22, 0x29, 0x0a, 0x7d, 0x0a, 0x0a, 0x76, 0x61, 0x6c, 0x20, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4a, - 0x61, 0x72, 0x20, 0x62, 0x79, 0x20, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, - 0x69, 0x6e, 0x67, 0x28, 0x4a, 0x61, 0x72, 0x3a, 0x3a, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20, - 0x20, 0x20, 0x61, 0x72, 0x63, 0x68, 0x69, 0x76, 0x65, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x69, 0x66, 0x69, 0x65, 0x72, 0x2e, - 0x73, 0x65, 0x74, 0x28, 0x22, 0x70, 0x61, 0x74, 0x63, 0x68, 0x65, 0x64, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, - 0x76, 0x61, 0x6c, 0x20, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x70, 0x61, 0x74, 0x68, - 0x20, 0x62, 0x79, 0x20, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x67, - 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, - 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, - 0x73, 0x28, 0x22, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x2d, 0x50, 0x61, 0x74, 0x68, 0x22, 0x20, 0x74, 0x6f, 0x20, 0x6f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70, - 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x20, 0x76, 0x61, 0x6c, 0x20, 0x70, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x20, 0x3d, - 0x20, 0x22, 0x66, 0x69, 0x6c, 0x65, 0x3a, 0x2f, 0x2b, 0x22, 0x2e, 0x74, 0x6f, 0x52, 0x65, 0x67, 0x65, 0x78, 0x28, 0x29, - 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x6f, 0x76, 0x65, 0x72, 0x72, 0x69, 0x64, - 0x65, 0x20, 0x66, 0x75, 0x6e, 0x20, 0x74, 0x6f, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x28, 0x29, 0x3a, 0x20, 0x53, 0x74, - 0x72, 0x69, 0x6e, 0x67, 0x20, 0x3d, 0x20, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x70, - 0x61, 0x74, 0x68, 0x2e, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x2e, 0x6a, 0x6f, 0x69, 0x6e, 0x54, 0x6f, 0x53, 0x74, 0x72, 0x69, - 0x6e, 0x67, 0x28, 0x22, 0x20, 0x22, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, - 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x69, 0x74, 0x2e, 0x74, 0x6f, 0x55, 0x52, 0x49, 0x28, 0x29, 0x2e, 0x74, 0x6f, 0x55, - 0x52, 0x4c, 0x28, 0x29, 0x2e, 0x74, 0x6f, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x28, 0x29, 0x2e, 0x72, 0x65, 0x70, 0x6c, - 0x61, 0x63, 0x65, 0x46, 0x69, 0x72, 0x73, 0x74, 0x28, 0x70, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x2c, 0x20, 0x22, 0x2f, - 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x0a, 0x20, 0x20, 0x20, - 0x20, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x0a, 0x7d, 0x0a, 0x0a, 0x74, 0x61, 0x73, - 0x6b, 0x73, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x64, 0x3c, 0x42, 0x6f, 0x6f, 0x74, 0x52, 0x75, 0x6e, 0x3e, 0x28, 0x22, 0x62, - 0x6f, 0x6f, 0x74, 0x52, 0x75, 0x6e, 0x22, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x66, 0x20, 0x28, 0x4f, - 0x73, 0x2e, 0x69, 0x73, 0x46, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x28, 0x4f, 0x73, 0x2e, 0x46, 0x41, 0x4d, 0x49, 0x4c, 0x59, - 0x5f, 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, 0x53, 0x29, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, - 0x20, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x70, 0x61, 0x74, 0x68, 0x20, 0x3d, 0x20, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x28, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x74, 0x73, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x64, 0x28, 0x22, 0x6d, 0x61, 0x69, - 0x6e, 0x22, 0x29, 0x2e, 0x6d, 0x61, 0x70, 0x20, 0x7b, 0x20, 0x69, 0x74, 0x2e, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x20, - 0x7d, 0x2c, 0x20, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4a, 0x61, 0x72, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x0a, 0x0a, - 0x20, 0x20, 0x20, 0x20, 0x2f, 0x2f, 0x20, 0xd0, } diff --git a/modules/charset/escape.go b/modules/charset/escape.go index 92e417d1f7..167683a298 100644 --- a/modules/charset/escape.go +++ b/modules/charset/escape.go @@ -20,14 +20,17 @@ import ( // RuneNBSP is the codepoint for NBSP const RuneNBSP = 0xa0 -// EscapeControlHTML escapes the unicode control sequences in a provided html document +// EscapeControlHTML escapes the Unicode control sequences in a provided html document func EscapeControlHTML(html template.HTML, locale translation.Locale, allowed ...rune) (escaped *EscapeStatus, output template.HTML) { + if !setting.UI.AmbiguousUnicodeDetection { + return &EscapeStatus{}, html + } sb := &strings.Builder{} escaped, _ = EscapeControlReader(strings.NewReader(string(html)), sb, locale, allowed...) // err has been handled in EscapeControlReader return escaped, template.HTML(sb.String()) } -// EscapeControlReader escapes the unicode control sequences in a provided reader of HTML content and writer in a locale and returns the findings as an EscapeStatus +// EscapeControlReader escapes the Unicode control sequences in a provided reader of HTML content and writer in a locale and returns the findings as an EscapeStatus func EscapeControlReader(reader io.Reader, writer io.Writer, locale translation.Locale, allowed ...rune) (escaped *EscapeStatus, err error) { if !setting.UI.AmbiguousUnicodeDetection { _, err = io.Copy(writer, reader) diff --git a/modules/emoji/emoji.go b/modules/emoji/emoji.go index 3d4ef8599b..891a0b9ab3 100644 --- a/modules/emoji/emoji.go +++ b/modules/emoji/emoji.go @@ -8,7 +8,9 @@ import ( "io" "sort" "strings" - "sync" + "sync/atomic" + + "code.gitea.io/gitea/modules/setting" ) // Gemoji is a set of emoji data. @@ -23,74 +25,78 @@ type Emoji struct { SkinTones bool } -var ( - // codeMap provides a map of the emoji unicode code to its emoji data. - codeMap map[string]int +type globalVarsStruct struct { + codeMap map[string]int // emoji unicode code to its emoji data. + aliasMap map[string]int // the alias to its emoji data. + emptyReplacer *strings.Replacer // string replacer for emoji codes, used for finding emoji positions. + codeReplacer *strings.Replacer // string replacer for emoji codes. + aliasReplacer *strings.Replacer // string replacer for emoji aliases. +} - // aliasMap provides a map of the alias to its emoji data. - aliasMap map[string]int +var globalVarsStore atomic.Pointer[globalVarsStruct] - // emptyReplacer is the string replacer for emoji codes. - emptyReplacer *strings.Replacer +func globalVars() *globalVarsStruct { + vars := globalVarsStore.Load() + if vars != nil { + return vars + } + // although there can be concurrent calls, the result should be the same, and there is no performance problem + vars = &globalVarsStruct{} + vars.codeMap = make(map[string]int, len(GemojiData)) + vars.aliasMap = make(map[string]int, len(GemojiData)) - // codeReplacer is the string replacer for emoji codes. - codeReplacer *strings.Replacer + // process emoji codes and aliases + codePairs := make([]string, 0) + emptyPairs := make([]string, 0) + aliasPairs := make([]string, 0) - // aliasReplacer is the string replacer for emoji aliases. - aliasReplacer *strings.Replacer + // sort from largest to small so we match combined emoji first + sort.Slice(GemojiData, func(i, j int) bool { + return len(GemojiData[i].Emoji) > len(GemojiData[j].Emoji) + }) - once sync.Once -) - -func loadMap() { - once.Do(func() { - // initialize - codeMap = make(map[string]int, len(GemojiData)) - aliasMap = make(map[string]int, len(GemojiData)) - - // process emoji codes and aliases - codePairs := make([]string, 0) - emptyPairs := make([]string, 0) - aliasPairs := make([]string, 0) - - // sort from largest to small so we match combined emoji first - sort.Slice(GemojiData, func(i, j int) bool { - return len(GemojiData[i].Emoji) > len(GemojiData[j].Emoji) - }) - - for i, e := range GemojiData { - if e.Emoji == "" || len(e.Aliases) == 0 { - continue - } - - // setup codes - codeMap[e.Emoji] = i - codePairs = append(codePairs, e.Emoji, ":"+e.Aliases[0]+":") - emptyPairs = append(emptyPairs, e.Emoji, e.Emoji) - - // setup aliases - for _, a := range e.Aliases { - if a == "" { - continue - } - - aliasMap[a] = i - aliasPairs = append(aliasPairs, ":"+a+":", e.Emoji) - } + for idx, emoji := range GemojiData { + if emoji.Emoji == "" || len(emoji.Aliases) == 0 { + continue } - // create replacers - emptyReplacer = strings.NewReplacer(emptyPairs...) - codeReplacer = strings.NewReplacer(codePairs...) - aliasReplacer = strings.NewReplacer(aliasPairs...) - }) + // process aliases + firstAlias := "" + for _, alias := range emoji.Aliases { + if alias == "" { + continue + } + enabled := len(setting.UI.EnabledEmojisSet) == 0 || setting.UI.EnabledEmojisSet.Contains(alias) + if !enabled { + continue + } + if firstAlias == "" { + firstAlias = alias + } + vars.aliasMap[alias] = idx + aliasPairs = append(aliasPairs, ":"+alias+":", emoji.Emoji) + } + + // process emoji code + if firstAlias != "" { + vars.codeMap[emoji.Emoji] = idx + codePairs = append(codePairs, emoji.Emoji, ":"+emoji.Aliases[0]+":") + emptyPairs = append(emptyPairs, emoji.Emoji, emoji.Emoji) + } + } + + // create replacers + vars.emptyReplacer = strings.NewReplacer(emptyPairs...) + vars.codeReplacer = strings.NewReplacer(codePairs...) + vars.aliasReplacer = strings.NewReplacer(aliasPairs...) + globalVarsStore.Store(vars) + return vars } // FromCode retrieves the emoji data based on the provided unicode code (ie, // "\u2618" will return the Gemoji data for "shamrock"). func FromCode(code string) *Emoji { - loadMap() - i, ok := codeMap[code] + i, ok := globalVars().codeMap[code] if !ok { return nil } @@ -102,12 +108,11 @@ func FromCode(code string) *Emoji { // "alias" or ":alias:" (ie, "shamrock" or ":shamrock:" will return the Gemoji // data for "shamrock"). func FromAlias(alias string) *Emoji { - loadMap() if strings.HasPrefix(alias, ":") && strings.HasSuffix(alias, ":") { alias = alias[1 : len(alias)-1] } - i, ok := aliasMap[alias] + i, ok := globalVars().aliasMap[alias] if !ok { return nil } @@ -119,15 +124,13 @@ func FromAlias(alias string) *Emoji { // alias (in the form of ":alias:") (ie, "\u2618" will be converted to // ":shamrock:"). func ReplaceCodes(s string) string { - loadMap() - return codeReplacer.Replace(s) + return globalVars().codeReplacer.Replace(s) } // ReplaceAliases replaces all aliases of the form ":alias:" with its // corresponding unicode value. func ReplaceAliases(s string) string { - loadMap() - return aliasReplacer.Replace(s) + return globalVars().aliasReplacer.Replace(s) } type rememberSecondWriteWriter struct { @@ -163,7 +166,6 @@ func (n *rememberSecondWriteWriter) WriteString(s string) (int, error) { // FindEmojiSubmatchIndex returns index pair of longest emoji in a string func FindEmojiSubmatchIndex(s string) []int { - loadMap() secondWriteWriter := rememberSecondWriteWriter{} // A faster and clean implementation would copy the trie tree formation in strings.NewReplacer but @@ -175,7 +177,7 @@ func FindEmojiSubmatchIndex(s string) []int { // Therefore we can simply take the index of the second write as our first emoji // // FIXME: just copy the trie implementation from strings.NewReplacer - _, _ = emptyReplacer.WriteString(&secondWriteWriter, s) + _, _ = globalVars().emptyReplacer.WriteString(&secondWriteWriter, s) // if we wrote less than twice then we never "replaced" if secondWriteWriter.writecount < 2 { diff --git a/modules/emoji/emoji_data.go b/modules/emoji/emoji_data.go index 8d0ae0a43e..ef25e3d9ea 100644 --- a/modules/emoji/emoji_data.go +++ b/modules/emoji/emoji_data.go @@ -4,7 +4,7 @@ package emoji // Code generated by build/generate-emoji.go. DO NOT EDIT. -// Sourced from https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json +// Sourced from https://raw.githubusercontent.com/rhysd/gemoji/537ff2d7e0496e9964824f7f73ec7ece88c9765a/db/emoji.json var GemojiData = Gemoji{ {"\U0001f44d", "thumbs up", []string{"+1", "thumbsup"}, "6.0", true}, {"\U0001f44d\U0001f3ff", "thumbs up: Dark Skin Tone", []string{"+1_Dark_Skin_Tone"}, "12.0", false}, @@ -345,10 +345,12 @@ var GemojiData = Gemoji{ {"\U0001f1ee\U0001f1f4", "flag: British Indian Ocean Territory", []string{"british_indian_ocean_territory"}, "6.0", false}, {"\U0001f1fb\U0001f1ec", "flag: British Virgin Islands", []string{"british_virgin_islands"}, "6.0", false}, {"\U0001f966", "broccoli", []string{"broccoli"}, "11.0", false}, + {"\u26d3\ufe0f\u200d\U0001f4a5", "broken chain", []string{"broken_chain"}, "15.1", false}, {"\U0001f494", "broken heart", []string{"broken_heart"}, "6.0", false}, {"\U0001f9f9", "broom", []string{"broom"}, "11.0", false}, {"\U0001f7e4", "brown circle", []string{"brown_circle"}, "12.0", false}, {"\U0001f90e", "brown heart", []string{"brown_heart"}, "12.0", false}, + {"\U0001f344\u200d\U0001f7eb", "brown mushroom", []string{"brown_mushroom"}, "15.1", false}, {"\U0001f7eb", "brown square", []string{"brown_square"}, "12.0", false}, {"\U0001f1e7\U0001f1f3", "flag: Brunei", []string{"brunei"}, "6.0", false}, {"\U0001f9cb", "bubble tea", []string{"bubble_tea"}, "13.0", false}, @@ -838,6 +840,7 @@ var GemojiData = Gemoji{ {"\U0001f62e\u200d\U0001f4a8", "face exhaling", []string{"face_exhaling"}, "13.1", false}, {"\U0001f979", "face holding back tears", []string{"face_holding_back_tears"}, "14.0", false}, {"\U0001f636\u200d\U0001f32b\ufe0f", "face in clouds", []string{"face_in_clouds"}, "13.1", false}, + {"\U0001fae9", "face with bags under eyes", []string{"face_with_bags_under_eyes"}, "16.0", false}, {"\U0001fae4", "face with diagonal mouth", []string{"face_with_diagonal_mouth"}, "14.0", false}, {"\U0001f915", "face with head-bandage", []string{"face_with_head_bandage"}, "8.0", false}, {"\U0001fae2", "face with open eyes and hand over mouth", []string{"face_with_open_eyes_and_hand_over_mouth"}, "14.0", false}, @@ -879,6 +882,10 @@ var GemojiData = Gemoji{ {"\U0001f1eb\U0001f1f0", "flag: Falkland Islands", []string{"falkland_islands"}, "6.0", false}, {"\U0001f342", "fallen leaf", []string{"fallen_leaf"}, "6.0", false}, {"\U0001f46a", "family", []string{"family"}, "6.0", false}, + {"\U0001f9d1\u200d\U0001f9d1\u200d\U0001f9d2", "family: adult, adult, child", []string{"family_adult_adult_child"}, "15.1", false}, + {"\U0001f9d1\u200d\U0001f9d1\u200d\U0001f9d2\u200d\U0001f9d2", "family: adult, adult, child, child", []string{"family_adult_adult_child_child"}, "15.1", false}, + {"\U0001f9d1\u200d\U0001f9d2", "family: adult, child", []string{"family_adult_child"}, "15.1", false}, + {"\U0001f9d1\u200d\U0001f9d2\u200d\U0001f9d2", "family: adult, child, child", []string{"family_adult_child_child"}, "15.1", false}, {"\U0001f468\u200d\U0001f466", "family: man, boy", []string{"family_man_boy"}, "6.0", false}, {"\U0001f468\u200d\U0001f466\u200d\U0001f466", "family: man, boy, boy", []string{"family_man_boy_boy"}, "6.0", false}, {"\U0001f468\u200d\U0001f467", "family: man, girl", []string{"family_man_girl"}, "6.0", false}, @@ -931,6 +938,7 @@ var GemojiData = Gemoji{ {"\U0001f4c1", "file folder", []string{"file_folder"}, "6.0", false}, {"\U0001f4fd\ufe0f", "film projector", []string{"film_projector"}, "7.0", false}, {"\U0001f39e\ufe0f", "film frames", []string{"film_strip"}, "7.0", false}, + {"\U0001fac6", "fingerprint", []string{"fingerprint"}, "16.0", false}, {"\U0001f1eb\U0001f1ee", "flag: Finland", []string{"finland"}, "6.0", false}, {"\U0001f525", "fire", []string{"fire"}, "6.0", false}, {"\U0001f692", "fire engine", []string{"fire_engine"}, "6.0", false}, @@ -973,6 +981,7 @@ var GemojiData = Gemoji{ {"\U0001f91c\U0001f3fc", "right-facing fist: Medium-Light Skin Tone", []string{"fist_right_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f91c\U0001f3fd", "right-facing fist: Medium Skin Tone", []string{"fist_right_Medium_Skin_Tone"}, "12.0", false}, {"5\ufe0f\u20e3", "keycap: 5", []string{"five"}, "", false}, + {"\U0001f1e8\U0001f1f6", "flag: Sark", []string{"flag_sark"}, "16.0", false}, {"\U0001f38f", "carp streamer", []string{"flags"}, "6.0", false}, {"\U0001f9a9", "flamingo", []string{"flamingo"}, "12.0", false}, {"\U0001f526", "flashlight", []string{"flashlight"}, "6.0", false}, @@ -1189,9 +1198,12 @@ var GemojiData = Gemoji{ {"\U0001f91d\U0001f3fc", "handshake: Medium-Light Skin Tone", []string{"handshake_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f91d\U0001f3fd", "handshake: Medium Skin Tone", []string{"handshake_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f4a9", "pile of poo", []string{"hankey", "poop", "shit"}, "6.0", false}, + {"\U0001fa89", "harp", []string{"harp"}, "16.0", false}, {"#\ufe0f\u20e3", "keycap: #", []string{"hash"}, "", false}, {"\U0001f425", "front-facing baby chick", []string{"hatched_chick"}, "6.0", false}, {"\U0001f423", "hatching chick", []string{"hatching_chick"}, "6.0", false}, + {"\U0001f642\u200d\u2194\ufe0f", "head shaking horizontally", []string{"head_shaking_horizontally"}, "15.1", false}, + {"\U0001f642\u200d\u2195\ufe0f", "head shaking vertically", []string{"head_shaking_vertically"}, "15.1", false}, {"\U0001f3a7", "headphone", []string{"headphones"}, "6.0", false}, {"\U0001faa6", "headstone", []string{"headstone"}, "13.0", false}, {"\U0001f9d1\u200d\u2695\ufe0f", "health worker", []string{"health_worker"}, "12.1", true}, @@ -1380,6 +1392,7 @@ var GemojiData = Gemoji{ {"\u271d\ufe0f", "latin cross", []string{"latin_cross"}, "", false}, {"\U0001f1f1\U0001f1fb", "flag: Latvia", []string{"latvia"}, "6.0", false}, {"\U0001f606", "grinning squinting face", []string{"laughing", "satisfied", "laugh"}, "6.0", false}, + {"\U0001fabe", "leafless tree", []string{"leafless_tree"}, "16.0", false}, {"\U0001f96c", "leafy green", []string{"leafy_green"}, "11.0", false}, {"\U0001f343", "leaf fluttering in wind", []string{"leaves"}, "6.0", false}, {"\U0001f1f1\U0001f1e7", "flag: Lebanon", []string{"lebanon"}, "6.0", false}, @@ -1417,6 +1430,7 @@ var GemojiData = Gemoji{ {"\U0001f1f1\U0001f1ee", "flag: Liechtenstein", []string{"liechtenstein"}, "6.0", false}, {"\U0001fa75", "light blue heart", []string{"light_blue_heart"}, "15.0", false}, {"\U0001f688", "light rail", []string{"light_rail"}, "6.0", false}, + {"\U0001f34b\u200d\U0001f7e9", "lime", []string{"lime"}, "15.1", false}, {"\U0001f517", "link", []string{"link"}, "6.0", false}, {"\U0001f981", "lion", []string{"lion"}, "8.0", false}, {"\U0001f444", "mouth", []string{"lips"}, "6.0", false}, @@ -1594,12 +1608,24 @@ var GemojiData = Gemoji{ {"\U0001f468\U0001f3fe\u200d\U0001f9bd", "man in manual wheelchair: Medium-Dark Skin Tone", []string{"man_in_manual_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fc\u200d\U0001f9bd", "man in manual wheelchair: Medium-Light Skin Tone", []string{"man_in_manual_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fd\u200d\U0001f9bd", "man in manual wheelchair: Medium Skin Tone", []string{"man_in_manual_wheelchair_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f468\u200d\U0001f9bd\u200d\u27a1\ufe0f", "man in manual wheelchair facing right", []string{"man_in_manual_wheelchair_facing_right"}, "15.1", true}, + {"\U0001f468\U0001f3ff\u200d\U0001f9bd\u200d\u27a1\ufe0f", "man in manual wheelchair facing right: Dark Skin Tone", []string{"man_in_manual_wheelchair_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fb\u200d\U0001f9bd\u200d\u27a1\ufe0f", "man in manual wheelchair facing right: Light Skin Tone", []string{"man_in_manual_wheelchair_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fe\u200d\U0001f9bd\u200d\u27a1\ufe0f", "man in manual wheelchair facing right: Medium-Dark Skin Tone", []string{"man_in_manual_wheelchair_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fc\u200d\U0001f9bd\u200d\u27a1\ufe0f", "man in manual wheelchair facing right: Medium-Light Skin Tone", []string{"man_in_manual_wheelchair_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fd\u200d\U0001f9bd\u200d\u27a1\ufe0f", "man in manual wheelchair facing right: Medium Skin Tone", []string{"man_in_manual_wheelchair_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f468\u200d\U0001f9bc", "man in motorized wheelchair", []string{"man_in_motorized_wheelchair"}, "12.0", true}, {"\U0001f468\U0001f3ff\u200d\U0001f9bc", "man in motorized wheelchair: Dark Skin Tone", []string{"man_in_motorized_wheelchair_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fb\u200d\U0001f9bc", "man in motorized wheelchair: Light Skin Tone", []string{"man_in_motorized_wheelchair_Light_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fe\u200d\U0001f9bc", "man in motorized wheelchair: Medium-Dark Skin Tone", []string{"man_in_motorized_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fc\u200d\U0001f9bc", "man in motorized wheelchair: Medium-Light Skin Tone", []string{"man_in_motorized_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fd\u200d\U0001f9bc", "man in motorized wheelchair: Medium Skin Tone", []string{"man_in_motorized_wheelchair_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f468\u200d\U0001f9bc\u200d\u27a1\ufe0f", "man in motorized wheelchair facing right", []string{"man_in_motorized_wheelchair_facing_right"}, "15.1", true}, + {"\U0001f468\U0001f3ff\u200d\U0001f9bc\u200d\u27a1\ufe0f", "man in motorized wheelchair facing right: Dark Skin Tone", []string{"man_in_motorized_wheelchair_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fb\u200d\U0001f9bc\u200d\u27a1\ufe0f", "man in motorized wheelchair facing right: Light Skin Tone", []string{"man_in_motorized_wheelchair_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fe\u200d\U0001f9bc\u200d\u27a1\ufe0f", "man in motorized wheelchair facing right: Medium-Dark Skin Tone", []string{"man_in_motorized_wheelchair_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fc\u200d\U0001f9bc\u200d\u27a1\ufe0f", "man in motorized wheelchair facing right: Medium-Light Skin Tone", []string{"man_in_motorized_wheelchair_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fd\u200d\U0001f9bc\u200d\u27a1\ufe0f", "man in motorized wheelchair facing right: Medium Skin Tone", []string{"man_in_motorized_wheelchair_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f935\u200d\u2642\ufe0f", "man in tuxedo", []string{"man_in_tuxedo"}, "13.0", true}, {"\U0001f935\U0001f3ff\u200d\u2642\ufe0f", "man in tuxedo: Dark Skin Tone", []string{"man_in_tuxedo_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f935\U0001f3fb\u200d\u2642\ufe0f", "man in tuxedo: Light Skin Tone", []string{"man_in_tuxedo_Light_Skin_Tone"}, "12.0", false}, @@ -1618,6 +1644,12 @@ var GemojiData = Gemoji{ {"\U0001f939\U0001f3fe\u200d\u2642\ufe0f", "man juggling: Medium-Dark Skin Tone", []string{"man_juggling_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f939\U0001f3fc\u200d\u2642\ufe0f", "man juggling: Medium-Light Skin Tone", []string{"man_juggling_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f939\U0001f3fd\u200d\u2642\ufe0f", "man juggling: Medium Skin Tone", []string{"man_juggling_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man kneeling facing right", []string{"man_kneeling_facing_right"}, "15.1", true}, + {"\U0001f9ce\U0001f3ff\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man kneeling facing right: Dark Skin Tone", []string{"man_kneeling_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fb\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man kneeling facing right: Light Skin Tone", []string{"man_kneeling_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fe\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man kneeling facing right: Medium-Dark Skin Tone", []string{"man_kneeling_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fc\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man kneeling facing right: Medium-Light Skin Tone", []string{"man_kneeling_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fd\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man kneeling facing right: Medium Skin Tone", []string{"man_kneeling_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f468\u200d\U0001f527", "man mechanic", []string{"man_mechanic"}, "", true}, {"\U0001f468\U0001f3ff\u200d\U0001f527", "man mechanic: Dark Skin Tone", []string{"man_mechanic_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fb\u200d\U0001f527", "man mechanic: Light Skin Tone", []string{"man_mechanic_Light_Skin_Tone"}, "12.0", false}, @@ -1648,6 +1680,12 @@ var GemojiData = Gemoji{ {"\U0001f93d\U0001f3fe\u200d\u2642\ufe0f", "man playing water polo: Medium-Dark Skin Tone", []string{"man_playing_water_polo_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f93d\U0001f3fc\u200d\u2642\ufe0f", "man playing water polo: Medium-Light Skin Tone", []string{"man_playing_water_polo_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f93d\U0001f3fd\u200d\u2642\ufe0f", "man playing water polo: Medium Skin Tone", []string{"man_playing_water_polo_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man running facing right", []string{"man_running_facing_right"}, "15.1", true}, + {"\U0001f3c3\U0001f3ff\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man running facing right: Dark Skin Tone", []string{"man_running_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fb\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man running facing right: Light Skin Tone", []string{"man_running_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fe\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man running facing right: Medium-Dark Skin Tone", []string{"man_running_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fc\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man running facing right: Medium-Light Skin Tone", []string{"man_running_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fd\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man running facing right: Medium Skin Tone", []string{"man_running_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f468\u200d\U0001f52c", "man scientist", []string{"man_scientist"}, "", true}, {"\U0001f468\U0001f3ff\u200d\U0001f52c", "man scientist: Dark Skin Tone", []string{"man_scientist_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fb\u200d\U0001f52c", "man scientist: Light Skin Tone", []string{"man_scientist_Light_Skin_Tone"}, "12.0", false}, @@ -1684,6 +1722,12 @@ var GemojiData = Gemoji{ {"\U0001f468\U0001f3fe\u200d\U0001f4bb", "man technologist: Medium-Dark Skin Tone", []string{"man_technologist_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fc\u200d\U0001f4bb", "man technologist: Medium-Light Skin Tone", []string{"man_technologist_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fd\u200d\U0001f4bb", "man technologist: Medium Skin Tone", []string{"man_technologist_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man walking facing right", []string{"man_walking_facing_right"}, "15.1", true}, + {"\U0001f6b6\U0001f3ff\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man walking facing right: Dark Skin Tone", []string{"man_walking_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fb\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man walking facing right: Light Skin Tone", []string{"man_walking_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fe\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man walking facing right: Medium-Dark Skin Tone", []string{"man_walking_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fc\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man walking facing right: Medium-Light Skin Tone", []string{"man_walking_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fd\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man walking facing right: Medium Skin Tone", []string{"man_walking_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f472", "person with skullcap", []string{"man_with_gua_pi_mao"}, "6.0", true}, {"\U0001f472\U0001f3ff", "person with skullcap: Dark Skin Tone", []string{"man_with_gua_pi_mao_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f472\U0001f3fb", "person with skullcap: Light Skin Tone", []string{"man_with_gua_pi_mao_Light_Skin_Tone"}, "12.0", false}, @@ -1708,6 +1752,12 @@ var GemojiData = Gemoji{ {"\U0001f470\U0001f3fe\u200d\u2642\ufe0f", "man with veil: Medium-Dark Skin Tone", []string{"man_with_veil_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f470\U0001f3fc\u200d\u2642\ufe0f", "man with veil: Medium-Light Skin Tone", []string{"man_with_veil_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f470\U0001f3fd\u200d\u2642\ufe0f", "man with veil: Medium Skin Tone", []string{"man_with_veil_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f468\u200d\U0001f9af\u200d\u27a1\ufe0f", "man with white cane facing right", []string{"man_with_white_cane_facing_right"}, "15.1", true}, + {"\U0001f468\U0001f3ff\u200d\U0001f9af\u200d\u27a1\ufe0f", "man with white cane facing right: Dark Skin Tone", []string{"man_with_white_cane_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fb\u200d\U0001f9af\u200d\u27a1\ufe0f", "man with white cane facing right: Light Skin Tone", []string{"man_with_white_cane_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fe\u200d\U0001f9af\u200d\u27a1\ufe0f", "man with white cane facing right: Medium-Dark Skin Tone", []string{"man_with_white_cane_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fc\u200d\U0001f9af\u200d\u27a1\ufe0f", "man with white cane facing right: Medium-Light Skin Tone", []string{"man_with_white_cane_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fd\u200d\U0001f9af\u200d\u27a1\ufe0f", "man with white cane facing right: Medium Skin Tone", []string{"man_with_white_cane_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f96d", "mango", []string{"mango"}, "11.0", false}, {"\U0001f45e", "man’s shoe", []string{"mans_shoe", "shoe"}, "6.0", false}, {"\U0001f570\ufe0f", "mantelpiece clock", []string{"mantelpiece_clock"}, "7.0", false}, @@ -1874,12 +1924,12 @@ var GemojiData = Gemoji{ {"\U0001f3b5", "musical note", []string{"musical_note"}, "6.0", false}, {"\U0001f3bc", "musical score", []string{"musical_score"}, "6.0", false}, {"\U0001f507", "muted speaker", []string{"mute"}, "6.0", false}, - {"\U0001f9d1\u200d\U0001f384", "mx claus", []string{"mx_claus"}, "13.0", true}, - {"\U0001f9d1\U0001f3ff\u200d\U0001f384", "mx claus: Dark Skin Tone", []string{"mx_claus_Dark_Skin_Tone"}, "12.0", false}, - {"\U0001f9d1\U0001f3fb\u200d\U0001f384", "mx claus: Light Skin Tone", []string{"mx_claus_Light_Skin_Tone"}, "12.0", false}, - {"\U0001f9d1\U0001f3fe\u200d\U0001f384", "mx claus: Medium-Dark Skin Tone", []string{"mx_claus_Medium-Dark_Skin_Tone"}, "12.0", false}, - {"\U0001f9d1\U0001f3fc\u200d\U0001f384", "mx claus: Medium-Light Skin Tone", []string{"mx_claus_Medium-Light_Skin_Tone"}, "12.0", false}, - {"\U0001f9d1\U0001f3fd\u200d\U0001f384", "mx claus: Medium Skin Tone", []string{"mx_claus_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\u200d\U0001f384", "Mx Claus", []string{"mx_claus"}, "13.0", true}, + {"\U0001f9d1\U0001f3ff\u200d\U0001f384", "Mx Claus: Dark Skin Tone", []string{"mx_claus_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fb\u200d\U0001f384", "Mx Claus: Light Skin Tone", []string{"mx_claus_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fe\u200d\U0001f384", "Mx Claus: Medium-Dark Skin Tone", []string{"mx_claus_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fc\u200d\U0001f384", "Mx Claus: Medium-Light Skin Tone", []string{"mx_claus_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fd\u200d\U0001f384", "Mx Claus: Medium Skin Tone", []string{"mx_claus_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f1f2\U0001f1f2", "flag: Myanmar (Burma)", []string{"myanmar"}, "6.0", false}, {"\U0001f485", "nail polish", []string{"nail_care"}, "6.0", true}, {"\U0001f485\U0001f3ff", "nail polish: Dark Skin Tone", []string{"nail_care_Dark_Skin_Tone"}, "12.0", false}, @@ -2140,24 +2190,54 @@ var GemojiData = Gemoji{ {"\U0001f9d1\U0001f3fe\u200d\U0001f9bd", "person in manual wheelchair: Medium-Dark Skin Tone", []string{"person_in_manual_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fc\u200d\U0001f9bd", "person in manual wheelchair: Medium-Light Skin Tone", []string{"person_in_manual_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fd\u200d\U0001f9bd", "person in manual wheelchair: Medium Skin Tone", []string{"person_in_manual_wheelchair_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\u200d\U0001f9bd\u200d\u27a1\ufe0f", "person in manual wheelchair facing right", []string{"person_in_manual_wheelchair_facing_right"}, "15.1", true}, + {"\U0001f9d1\U0001f3ff\u200d\U0001f9bd\u200d\u27a1\ufe0f", "person in manual wheelchair facing right: Dark Skin Tone", []string{"person_in_manual_wheelchair_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fb\u200d\U0001f9bd\u200d\u27a1\ufe0f", "person in manual wheelchair facing right: Light Skin Tone", []string{"person_in_manual_wheelchair_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fe\u200d\U0001f9bd\u200d\u27a1\ufe0f", "person in manual wheelchair facing right: Medium-Dark Skin Tone", []string{"person_in_manual_wheelchair_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fc\u200d\U0001f9bd\u200d\u27a1\ufe0f", "person in manual wheelchair facing right: Medium-Light Skin Tone", []string{"person_in_manual_wheelchair_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fd\u200d\U0001f9bd\u200d\u27a1\ufe0f", "person in manual wheelchair facing right: Medium Skin Tone", []string{"person_in_manual_wheelchair_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\u200d\U0001f9bc", "person in motorized wheelchair", []string{"person_in_motorized_wheelchair"}, "12.1", true}, {"\U0001f9d1\U0001f3ff\u200d\U0001f9bc", "person in motorized wheelchair: Dark Skin Tone", []string{"person_in_motorized_wheelchair_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fb\u200d\U0001f9bc", "person in motorized wheelchair: Light Skin Tone", []string{"person_in_motorized_wheelchair_Light_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fe\u200d\U0001f9bc", "person in motorized wheelchair: Medium-Dark Skin Tone", []string{"person_in_motorized_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fc\u200d\U0001f9bc", "person in motorized wheelchair: Medium-Light Skin Tone", []string{"person_in_motorized_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fd\u200d\U0001f9bc", "person in motorized wheelchair: Medium Skin Tone", []string{"person_in_motorized_wheelchair_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\u200d\U0001f9bc\u200d\u27a1\ufe0f", "person in motorized wheelchair facing right", []string{"person_in_motorized_wheelchair_facing_right"}, "15.1", true}, + {"\U0001f9d1\U0001f3ff\u200d\U0001f9bc\u200d\u27a1\ufe0f", "person in motorized wheelchair facing right: Dark Skin Tone", []string{"person_in_motorized_wheelchair_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fb\u200d\U0001f9bc\u200d\u27a1\ufe0f", "person in motorized wheelchair facing right: Light Skin Tone", []string{"person_in_motorized_wheelchair_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fe\u200d\U0001f9bc\u200d\u27a1\ufe0f", "person in motorized wheelchair facing right: Medium-Dark Skin Tone", []string{"person_in_motorized_wheelchair_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fc\u200d\U0001f9bc\u200d\u27a1\ufe0f", "person in motorized wheelchair facing right: Medium-Light Skin Tone", []string{"person_in_motorized_wheelchair_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fd\u200d\U0001f9bc\u200d\u27a1\ufe0f", "person in motorized wheelchair facing right: Medium Skin Tone", []string{"person_in_motorized_wheelchair_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f935", "person in tuxedo", []string{"person_in_tuxedo"}, "9.0", true}, {"\U0001f935\U0001f3ff", "person in tuxedo: Dark Skin Tone", []string{"person_in_tuxedo_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f935\U0001f3fb", "person in tuxedo: Light Skin Tone", []string{"person_in_tuxedo_Light_Skin_Tone"}, "12.0", false}, {"\U0001f935\U0001f3fe", "person in tuxedo: Medium-Dark Skin Tone", []string{"person_in_tuxedo_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f935\U0001f3fc", "person in tuxedo: Medium-Light Skin Tone", []string{"person_in_tuxedo_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f935\U0001f3fd", "person in tuxedo: Medium Skin Tone", []string{"person_in_tuxedo_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\u200d\u27a1\ufe0f", "person kneeling facing right", []string{"person_kneeling_facing_right"}, "15.1", true}, + {"\U0001f9ce\U0001f3ff\u200d\u27a1\ufe0f", "person kneeling facing right: Dark Skin Tone", []string{"person_kneeling_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fb\u200d\u27a1\ufe0f", "person kneeling facing right: Light Skin Tone", []string{"person_kneeling_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fe\u200d\u27a1\ufe0f", "person kneeling facing right: Medium-Dark Skin Tone", []string{"person_kneeling_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fc\u200d\u27a1\ufe0f", "person kneeling facing right: Medium-Light Skin Tone", []string{"person_kneeling_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fd\u200d\u27a1\ufe0f", "person kneeling facing right: Medium Skin Tone", []string{"person_kneeling_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\u200d\U0001f9b0", "person: red hair", []string{"person_red_hair"}, "12.1", true}, {"\U0001f9d1\U0001f3ff\u200d\U0001f9b0", "person: red hair: Dark Skin Tone", []string{"person_red_hair_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fb\u200d\U0001f9b0", "person: red hair: Light Skin Tone", []string{"person_red_hair_Light_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fe\u200d\U0001f9b0", "person: red hair: Medium-Dark Skin Tone", []string{"person_red_hair_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fc\u200d\U0001f9b0", "person: red hair: Medium-Light Skin Tone", []string{"person_red_hair_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fd\u200d\U0001f9b0", "person: red hair: Medium Skin Tone", []string{"person_red_hair_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\u200d\u27a1\ufe0f", "person running facing right", []string{"person_running_facing_right"}, "15.1", true}, + {"\U0001f3c3\U0001f3ff\u200d\u27a1\ufe0f", "person running facing right: Dark Skin Tone", []string{"person_running_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fb\u200d\u27a1\ufe0f", "person running facing right: Light Skin Tone", []string{"person_running_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fe\u200d\u27a1\ufe0f", "person running facing right: Medium-Dark Skin Tone", []string{"person_running_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fc\u200d\u27a1\ufe0f", "person running facing right: Medium-Light Skin Tone", []string{"person_running_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fd\u200d\u27a1\ufe0f", "person running facing right: Medium Skin Tone", []string{"person_running_facing_right_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\u200d\u27a1\ufe0f", "person walking facing right", []string{"person_walking_facing_right"}, "15.1", true}, + {"\U0001f6b6\U0001f3ff\u200d\u27a1\ufe0f", "person walking facing right: Dark Skin Tone", []string{"person_walking_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fb\u200d\u27a1\ufe0f", "person walking facing right: Light Skin Tone", []string{"person_walking_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fe\u200d\u27a1\ufe0f", "person walking facing right: Medium-Dark Skin Tone", []string{"person_walking_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fc\u200d\u27a1\ufe0f", "person walking facing right: Medium-Light Skin Tone", []string{"person_walking_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fd\u200d\u27a1\ufe0f", "person walking facing right: Medium Skin Tone", []string{"person_walking_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\u200d\U0001f9b3", "person: white hair", []string{"person_white_hair"}, "12.1", true}, {"\U0001f9d1\U0001f3ff\u200d\U0001f9b3", "person: white hair: Dark Skin Tone", []string{"person_white_hair_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fb\u200d\U0001f9b3", "person: white hair: Light Skin Tone", []string{"person_white_hair_Light_Skin_Tone"}, "12.0", false}, @@ -2188,9 +2268,16 @@ var GemojiData = Gemoji{ {"\U0001f470\U0001f3fe", "person with veil: Medium-Dark Skin Tone", []string{"person_with_veil_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f470\U0001f3fc", "person with veil: Medium-Light Skin Tone", []string{"person_with_veil_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f470\U0001f3fd", "person with veil: Medium Skin Tone", []string{"person_with_veil_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\u200d\U0001f9af\u200d\u27a1\ufe0f", "person with white cane facing right", []string{"person_with_white_cane_facing_right"}, "15.1", true}, + {"\U0001f9d1\U0001f3ff\u200d\U0001f9af\u200d\u27a1\ufe0f", "person with white cane facing right: Dark Skin Tone", []string{"person_with_white_cane_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fb\u200d\U0001f9af\u200d\u27a1\ufe0f", "person with white cane facing right: Light Skin Tone", []string{"person_with_white_cane_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fe\u200d\U0001f9af\u200d\u27a1\ufe0f", "person with white cane facing right: Medium-Dark Skin Tone", []string{"person_with_white_cane_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fc\u200d\U0001f9af\u200d\u27a1\ufe0f", "person with white cane facing right: Medium-Light Skin Tone", []string{"person_with_white_cane_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fd\u200d\U0001f9af\u200d\u27a1\ufe0f", "person with white cane facing right: Medium Skin Tone", []string{"person_with_white_cane_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f1f5\U0001f1ea", "flag: Peru", []string{"peru"}, "6.0", false}, {"\U0001f9eb", "petri dish", []string{"petri_dish"}, "11.0", false}, {"\U0001f1f5\U0001f1ed", "flag: Philippines", []string{"philippines"}, "6.0", false}, + {"\U0001f426\u200d\U0001f525", "phoenix", []string{"phoenix"}, "15.1", false}, {"\u260e\ufe0f", "telephone", []string{"phone", "telephone"}, "", false}, {"\u26cf\ufe0f", "pick", []string{"pick"}, "5.2", false}, {"\U0001f6fb", "pickup truck", []string{"pickup_truck"}, "13.0", false}, @@ -2480,6 +2567,7 @@ var GemojiData = Gemoji{ {"\U0001f6fc", "roller skate", []string{"roller_skate"}, "13.0", false}, {"\U0001f1f7\U0001f1f4", "flag: Romania", []string{"romania"}, "6.0", false}, {"\U0001f413", "rooster", []string{"rooster"}, "6.0", false}, + {"\U0001fadc", "root vegetable", []string{"root_vegetable"}, "16.0", false}, {"\U0001f339", "rose", []string{"rose"}, "6.0", false}, {"\U0001f3f5\ufe0f", "rosette", []string{"rosette"}, "7.0", false}, {"\U0001f6a8", "police car light", []string{"rotating_light"}, "6.0", false}, @@ -2613,6 +2701,7 @@ var GemojiData = Gemoji{ {"\U0001f6cd\ufe0f", "shopping bags", []string{"shopping"}, "7.0", false}, {"\U0001f6d2", "shopping cart", []string{"shopping_cart"}, "9.0", false}, {"\U0001fa73", "shorts", []string{"shorts"}, "12.0", false}, + {"\U0001fa8f", "shovel", []string{"shovel"}, "16.0", false}, {"\U0001f6bf", "shower", []string{"shower"}, "6.0", false}, {"\U0001f990", "shrimp", []string{"shrimp"}, "9.0", false}, {"\U0001f937", "person shrugging", []string{"shrug"}, "11.0", true}, @@ -2711,6 +2800,7 @@ var GemojiData = Gemoji{ {"\U0001f578\ufe0f", "spider web", []string{"spider_web"}, "7.0", false}, {"\U0001f5d3\ufe0f", "spiral calendar", []string{"spiral_calendar"}, "7.0", false}, {"\U0001f5d2\ufe0f", "spiral notepad", []string{"spiral_notepad"}, "7.0", false}, + {"\U0001fadf", "splatter", []string{"splatter"}, "16.0", false}, {"\U0001f9fd", "sponge", []string{"sponge"}, "11.0", false}, {"\U0001f944", "spoon", []string{"spoon"}, "9.0", false}, {"\U0001f991", "squid", []string{"squid"}, "9.0", false}, @@ -2945,7 +3035,7 @@ var GemojiData = Gemoji{ {"\U0001f51d", "TOP arrow", []string{"top"}, "6.0", false}, {"\U0001f3a9", "top hat", []string{"tophat"}, "6.0", false}, {"\U0001f32a\ufe0f", "tornado", []string{"tornado"}, "7.0", false}, - {"\U0001f1f9\U0001f1f7", "flag: Turkey", []string{"tr"}, "8.0", false}, + {"\U0001f1f9\U0001f1f7", "flag: Türkiye", []string{"tr"}, "8.0", false}, {"\U0001f5b2\ufe0f", "trackball", []string{"trackball"}, "7.0", false}, {"\U0001f69c", "tractor", []string{"tractor"}, "6.0", false}, {"\U0001f6a5", "horizontal traffic light", []string{"traffic_light"}, "6.0", false}, @@ -3247,12 +3337,24 @@ var GemojiData = Gemoji{ {"\U0001f469\U0001f3fe\u200d\U0001f9bd", "woman in manual wheelchair: Medium-Dark Skin Tone", []string{"woman_in_manual_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fc\u200d\U0001f9bd", "woman in manual wheelchair: Medium-Light Skin Tone", []string{"woman_in_manual_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fd\u200d\U0001f9bd", "woman in manual wheelchair: Medium Skin Tone", []string{"woman_in_manual_wheelchair_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f469\u200d\U0001f9bd\u200d\u27a1\ufe0f", "woman in manual wheelchair facing right", []string{"woman_in_manual_wheelchair_facing_right"}, "15.1", true}, + {"\U0001f469\U0001f3ff\u200d\U0001f9bd\u200d\u27a1\ufe0f", "woman in manual wheelchair facing right: Dark Skin Tone", []string{"woman_in_manual_wheelchair_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fb\u200d\U0001f9bd\u200d\u27a1\ufe0f", "woman in manual wheelchair facing right: Light Skin Tone", []string{"woman_in_manual_wheelchair_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fe\u200d\U0001f9bd\u200d\u27a1\ufe0f", "woman in manual wheelchair facing right: Medium-Dark Skin Tone", []string{"woman_in_manual_wheelchair_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fc\u200d\U0001f9bd\u200d\u27a1\ufe0f", "woman in manual wheelchair facing right: Medium-Light Skin Tone", []string{"woman_in_manual_wheelchair_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fd\u200d\U0001f9bd\u200d\u27a1\ufe0f", "woman in manual wheelchair facing right: Medium Skin Tone", []string{"woman_in_manual_wheelchair_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f469\u200d\U0001f9bc", "woman in motorized wheelchair", []string{"woman_in_motorized_wheelchair"}, "12.0", true}, {"\U0001f469\U0001f3ff\u200d\U0001f9bc", "woman in motorized wheelchair: Dark Skin Tone", []string{"woman_in_motorized_wheelchair_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fb\u200d\U0001f9bc", "woman in motorized wheelchair: Light Skin Tone", []string{"woman_in_motorized_wheelchair_Light_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fe\u200d\U0001f9bc", "woman in motorized wheelchair: Medium-Dark Skin Tone", []string{"woman_in_motorized_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fc\u200d\U0001f9bc", "woman in motorized wheelchair: Medium-Light Skin Tone", []string{"woman_in_motorized_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fd\u200d\U0001f9bc", "woman in motorized wheelchair: Medium Skin Tone", []string{"woman_in_motorized_wheelchair_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f469\u200d\U0001f9bc\u200d\u27a1\ufe0f", "woman in motorized wheelchair facing right", []string{"woman_in_motorized_wheelchair_facing_right"}, "15.1", true}, + {"\U0001f469\U0001f3ff\u200d\U0001f9bc\u200d\u27a1\ufe0f", "woman in motorized wheelchair facing right: Dark Skin Tone", []string{"woman_in_motorized_wheelchair_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fb\u200d\U0001f9bc\u200d\u27a1\ufe0f", "woman in motorized wheelchair facing right: Light Skin Tone", []string{"woman_in_motorized_wheelchair_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fe\u200d\U0001f9bc\u200d\u27a1\ufe0f", "woman in motorized wheelchair facing right: Medium-Dark Skin Tone", []string{"woman_in_motorized_wheelchair_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fc\u200d\U0001f9bc\u200d\u27a1\ufe0f", "woman in motorized wheelchair facing right: Medium-Light Skin Tone", []string{"woman_in_motorized_wheelchair_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fd\u200d\U0001f9bc\u200d\u27a1\ufe0f", "woman in motorized wheelchair facing right: Medium Skin Tone", []string{"woman_in_motorized_wheelchair_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f935\u200d\u2640\ufe0f", "woman in tuxedo", []string{"woman_in_tuxedo"}, "13.0", true}, {"\U0001f935\U0001f3ff\u200d\u2640\ufe0f", "woman in tuxedo: Dark Skin Tone", []string{"woman_in_tuxedo_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f935\U0001f3fb\u200d\u2640\ufe0f", "woman in tuxedo: Light Skin Tone", []string{"woman_in_tuxedo_Light_Skin_Tone"}, "12.0", false}, @@ -3271,6 +3373,12 @@ var GemojiData = Gemoji{ {"\U0001f939\U0001f3fe\u200d\u2640\ufe0f", "woman juggling: Medium-Dark Skin Tone", []string{"woman_juggling_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f939\U0001f3fc\u200d\u2640\ufe0f", "woman juggling: Medium-Light Skin Tone", []string{"woman_juggling_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f939\U0001f3fd\u200d\u2640\ufe0f", "woman juggling: Medium Skin Tone", []string{"woman_juggling_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman kneeling facing right", []string{"woman_kneeling_facing_right"}, "15.1", true}, + {"\U0001f9ce\U0001f3ff\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman kneeling facing right: Dark Skin Tone", []string{"woman_kneeling_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fb\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman kneeling facing right: Light Skin Tone", []string{"woman_kneeling_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fe\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman kneeling facing right: Medium-Dark Skin Tone", []string{"woman_kneeling_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fc\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman kneeling facing right: Medium-Light Skin Tone", []string{"woman_kneeling_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fd\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman kneeling facing right: Medium Skin Tone", []string{"woman_kneeling_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f469\u200d\U0001f527", "woman mechanic", []string{"woman_mechanic"}, "", true}, {"\U0001f469\U0001f3ff\u200d\U0001f527", "woman mechanic: Dark Skin Tone", []string{"woman_mechanic_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fb\u200d\U0001f527", "woman mechanic: Light Skin Tone", []string{"woman_mechanic_Light_Skin_Tone"}, "12.0", false}, @@ -3301,6 +3409,12 @@ var GemojiData = Gemoji{ {"\U0001f93d\U0001f3fe\u200d\u2640\ufe0f", "woman playing water polo: Medium-Dark Skin Tone", []string{"woman_playing_water_polo_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f93d\U0001f3fc\u200d\u2640\ufe0f", "woman playing water polo: Medium-Light Skin Tone", []string{"woman_playing_water_polo_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f93d\U0001f3fd\u200d\u2640\ufe0f", "woman playing water polo: Medium Skin Tone", []string{"woman_playing_water_polo_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman running facing right", []string{"woman_running_facing_right"}, "15.1", true}, + {"\U0001f3c3\U0001f3ff\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman running facing right: Dark Skin Tone", []string{"woman_running_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fb\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman running facing right: Light Skin Tone", []string{"woman_running_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fe\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman running facing right: Medium-Dark Skin Tone", []string{"woman_running_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fc\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman running facing right: Medium-Light Skin Tone", []string{"woman_running_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fd\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman running facing right: Medium Skin Tone", []string{"woman_running_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f469\u200d\U0001f52c", "woman scientist", []string{"woman_scientist"}, "", true}, {"\U0001f469\U0001f3ff\u200d\U0001f52c", "woman scientist: Dark Skin Tone", []string{"woman_scientist_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fb\u200d\U0001f52c", "woman scientist: Light Skin Tone", []string{"woman_scientist_Light_Skin_Tone"}, "12.0", false}, @@ -3337,6 +3451,12 @@ var GemojiData = Gemoji{ {"\U0001f469\U0001f3fe\u200d\U0001f4bb", "woman technologist: Medium-Dark Skin Tone", []string{"woman_technologist_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fc\u200d\U0001f4bb", "woman technologist: Medium-Light Skin Tone", []string{"woman_technologist_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fd\u200d\U0001f4bb", "woman technologist: Medium Skin Tone", []string{"woman_technologist_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman walking facing right", []string{"woman_walking_facing_right"}, "15.1", true}, + {"\U0001f6b6\U0001f3ff\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman walking facing right: Dark Skin Tone", []string{"woman_walking_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fb\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman walking facing right: Light Skin Tone", []string{"woman_walking_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fe\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman walking facing right: Medium-Dark Skin Tone", []string{"woman_walking_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fc\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman walking facing right: Medium-Light Skin Tone", []string{"woman_walking_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fd\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman walking facing right: Medium Skin Tone", []string{"woman_walking_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f9d5", "woman with headscarf", []string{"woman_with_headscarf"}, "11.0", true}, {"\U0001f9d5\U0001f3ff", "woman with headscarf: Dark Skin Tone", []string{"woman_with_headscarf_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f9d5\U0001f3fb", "woman with headscarf: Light Skin Tone", []string{"woman_with_headscarf_Light_Skin_Tone"}, "12.0", false}, @@ -3361,6 +3481,12 @@ var GemojiData = Gemoji{ {"\U0001f470\U0001f3fe\u200d\u2640\ufe0f", "woman with veil: Medium-Dark Skin Tone", []string{"woman_with_veil_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f470\U0001f3fc\u200d\u2640\ufe0f", "woman with veil: Medium-Light Skin Tone", []string{"woman_with_veil_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f470\U0001f3fd\u200d\u2640\ufe0f", "woman with veil: Medium Skin Tone", []string{"woman_with_veil_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f469\u200d\U0001f9af\u200d\u27a1\ufe0f", "woman with white cane facing right", []string{"woman_with_white_cane_facing_right"}, "15.1", true}, + {"\U0001f469\U0001f3ff\u200d\U0001f9af\u200d\u27a1\ufe0f", "woman with white cane facing right: Dark Skin Tone", []string{"woman_with_white_cane_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fb\u200d\U0001f9af\u200d\u27a1\ufe0f", "woman with white cane facing right: Light Skin Tone", []string{"woman_with_white_cane_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fe\u200d\U0001f9af\u200d\u27a1\ufe0f", "woman with white cane facing right: Medium-Dark Skin Tone", []string{"woman_with_white_cane_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fc\u200d\U0001f9af\u200d\u27a1\ufe0f", "woman with white cane facing right: Medium-Light Skin Tone", []string{"woman_with_white_cane_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fd\u200d\U0001f9af\u200d\u27a1\ufe0f", "woman with white cane facing right: Medium Skin Tone", []string{"woman_with_white_cane_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f45a", "woman’s clothes", []string{"womans_clothes"}, "6.0", false}, {"\U0001f452", "woman’s hat", []string{"womans_hat"}, "6.0", false}, {"\U0001f93c\u200d\u2640\ufe0f", "women wrestling", []string{"women_wrestling"}, "9.0", false}, diff --git a/modules/emoji/emoji_test.go b/modules/emoji/emoji_test.go index fbf80fe41a..607299cdc1 100644 --- a/modules/emoji/emoji_test.go +++ b/modules/emoji/emoji_test.go @@ -7,14 +7,13 @@ package emoji import ( "testing" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "github.com/stretchr/testify/assert" ) -func TestDumpInfo(t *testing.T) { - t.Logf("codes: %d", len(codeMap)) - t.Logf("aliases: %d", len(aliasMap)) -} - func TestLookup(t *testing.T) { a := FromCode("\U0001f37a") b := FromCode("🍺") @@ -24,7 +23,6 @@ func TestLookup(t *testing.T) { assert.Equal(t, a, b) assert.Equal(t, b, c) assert.Equal(t, c, d) - assert.Equal(t, a, d) m := FromCode("\U0001f44d") n := FromAlias(":thumbsup:") @@ -32,7 +30,20 @@ func TestLookup(t *testing.T) { assert.Equal(t, m, n) assert.Equal(t, m, o) - assert.Equal(t, n, o) + + defer test.MockVariableValue(&setting.UI.EnabledEmojisSet, container.SetOf("thumbsup"))() + defer globalVarsStore.Store(nil) + globalVarsStore.Store(nil) + a = FromCode("\U0001f37a") + c = FromAlias(":beer:") + m = FromCode("\U0001f44d") + n = FromAlias(":thumbsup:") + o = FromAlias("+1") + assert.Nil(t, a) + assert.Nil(t, c) + assert.NotNil(t, m) + assert.NotNil(t, n) + assert.Nil(t, o) } func TestReplacers(t *testing.T) { diff --git a/modules/eventsource/manager_run.go b/modules/eventsource/manager_run.go index f66dc78c7e..4a42224dda 100644 --- a/modules/eventsource/manager_run.go +++ b/modules/eventsource/manager_run.go @@ -9,6 +9,7 @@ import ( activities_model "code.gitea.io/gitea/models/activities" issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" @@ -91,7 +92,13 @@ loop: } for _, userStopwatches := range usersStopwatches { - apiSWs, err := convert.ToStopWatches(ctx, userStopwatches.StopWatches) + u, err := user_model.GetUserByID(ctx, userStopwatches.UserID) + if err != nil { + log.Error("Unable to get user %d: %v", userStopwatches.UserID, err) + continue + } + + apiSWs, err := convert.ToStopWatches(ctx, u, userStopwatches.StopWatches) if err != nil { if !issues_model.IsErrIssueNotExist(err) { log.Error("Unable to APIFormat stopwatches: %v", err) diff --git a/modules/fileicon/material.go b/modules/fileicon/material.go index 5361592d8a..b8ee13cd8c 100644 --- a/modules/fileicon/material.go +++ b/modules/fileicon/material.go @@ -76,7 +76,7 @@ func (m *MaterialIconProvider) renderFileIconSVG(p *RenderedIconPool, name, svg, if p.IconSVGs[svgID] == "" { p.IconSVGs[svgID] = svgHTML } - return template.HTML(``) + return template.HTML(``) } func (m *MaterialIconProvider) EntryIconHTML(p *RenderedIconPool, entry *EntryInfo) template.HTML { diff --git a/modules/fileicon/render.go b/modules/fileicon/render.go index 8ed86b9ac0..5bf2a3a02e 100644 --- a/modules/fileicon/render.go +++ b/modules/fileicon/render.go @@ -25,7 +25,7 @@ func (p *RenderedIconPool) RenderToHTML() template.HTML { return "" } sb := &strings.Builder{} - sb.WriteString(`
`) + sb.WriteString(`
`) for _, icon := range p.IconSVGs { sb.WriteString(string(icon)) } @@ -34,7 +34,13 @@ func (p *RenderedIconPool) RenderToHTML() template.HTML { } func RenderEntryIconHTML(renderedIconPool *RenderedIconPool, entry *EntryInfo) template.HTML { - if setting.UI.FileIconTheme == "material" { + // Use folder theme for directories and symlinks to directories + theme := setting.UI.FileIconTheme + if entry.EntryMode.IsDir() || (entry.EntryMode.IsLink() && entry.SymlinkToMode.IsDir()) { + theme = setting.UI.FolderIconTheme + } + + if theme == "material" { return DefaultMaterialIconProvider().EntryIconHTML(renderedIconPool, entry) } return BasicEntryIconHTML(entry) diff --git a/modules/fileicon/render_test.go b/modules/fileicon/render_test.go new file mode 100644 index 0000000000..d9998f3f4c --- /dev/null +++ b/modules/fileicon/render_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package fileicon_test + +import ( + "testing" + + "code.gitea.io/gitea/modules/fileicon" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestRenderEntryIconHTML_WithDifferentThemes(t *testing.T) { + // Test that folder icons use the folder theme + t.Run("FolderUsesBasicTheme", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")() + defer test.MockVariableValue(&setting.UI.FolderIconTheme, "basic")() + + folderEntry := &fileicon.EntryInfo{ + BaseName: "testfolder", + EntryMode: git.EntryModeTree, + } + + html := fileicon.RenderEntryIconHTML(nil, folderEntry) + // Basic theme renders octicon classes + assert.Contains(t, string(html), "octicon-file-directory-fill") + }) + + t.Run("FileUsesMaterialTheme", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")() + defer test.MockVariableValue(&setting.UI.FolderIconTheme, "basic")() + + fileEntry := &fileicon.EntryInfo{ + BaseName: "test.js", + EntryMode: git.EntryModeBlob, + } + + html := fileicon.RenderEntryIconHTML(nil, fileEntry) + // Material theme for files renders material icons + assert.Contains(t, string(html), "svg-mfi-") + }) + + t.Run("SymlinkToFolderUsesBasicTheme", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")() + defer test.MockVariableValue(&setting.UI.FolderIconTheme, "basic")() + + symlinkEntry := &fileicon.EntryInfo{ + BaseName: "link", + EntryMode: git.EntryModeSymlink, + SymlinkToMode: git.EntryModeTree, + } + + html := fileicon.RenderEntryIconHTML(nil, symlinkEntry) + // Symlinks to folders should use folder theme + assert.Contains(t, string(html), "octicon-file-directory-symlink") + }) + + t.Run("BothMaterialTheme", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")() + defer test.MockVariableValue(&setting.UI.FolderIconTheme, "material")() + + folderEntry := &fileicon.EntryInfo{ + BaseName: "testfolder", + EntryMode: git.EntryModeTree, + } + + html := fileicon.RenderEntryIconHTML(nil, folderEntry) + // Material theme for folders renders material folder icons + assert.Contains(t, string(html), "svg-mfi-") + }) +} diff --git a/modules/git/attribute/attribute.go b/modules/git/attribute/attribute.go index 9c01cb339e..8719369df8 100644 --- a/modules/git/attribute/attribute.go +++ b/modules/git/attribute/attribute.go @@ -96,8 +96,8 @@ func (attrs *Attributes) GetGitlabLanguage() optional.Option[string] { // gitlab-language may have additional parameters after the language // ignore them and just use the main language // https://docs.gitlab.com/ee/user/project/highlighting.html#override-syntax-highlighting-for-a-file-type - if idx := strings.IndexByte(raw, '?'); idx >= 0 { - return optional.Some(raw[:idx]) + if before, _, ok := strings.Cut(raw, "?"); ok { + return optional.Some(before) } } return attrStr diff --git a/modules/git/attribute/batch.go b/modules/git/attribute/batch.go index 27befdfa25..b1e6387ade 100644 --- a/modules/git/attribute/batch.go +++ b/modules/git/attribute/batch.go @@ -7,7 +7,7 @@ import ( "bytes" "context" "fmt" - "os" + "io" "path/filepath" "time" @@ -20,7 +20,7 @@ import ( type BatchChecker struct { attributesNum int repo *git.Repository - stdinWriter *os.File + stdinWriter io.WriteCloser stdOut *nulSeparatedAttributeWriter ctx context.Context cancel context.CancelFunc @@ -60,10 +60,7 @@ func NewBatchChecker(repo *git.Repository, treeish string, attributes []string) }, } - stdinReader, stdinWriter, err := os.Pipe() - if err != nil { - return nil, err - } + stdinWriter, stdinWriterClose := cmd.MakeStdinPipe() checker.stdinWriter = stdinWriter lw := new(nulSeparatedAttributeWriter) @@ -71,23 +68,19 @@ func NewBatchChecker(repo *git.Repository, treeish string, attributes []string) lw.closed = make(chan struct{}) checker.stdOut = lw - go func() { - defer func() { - _ = stdinReader.Close() - _ = lw.Close() - }() - stdErr := new(bytes.Buffer) - err := cmd.WithEnv(envs). - WithDir(repo.Path). - WithStdin(stdinReader). - WithStdout(lw). - WithStderr(stdErr). - Run(ctx) + cmd.WithEnv(envs). + WithDir(repo.Path). + WithStdoutCopy(lw) - if err != nil && !git.IsErrCanceledOrKilled(err) { + go func() { + defer stdinWriterClose() + defer checker.cancel() + defer lw.Close() + + err := cmd.RunWithStderr(ctx) + if err != nil && !gitcmd.IsErrorCanceledOrKilled(err) { log.Error("Attribute checker for commit %s exits with error: %v", treeish, err) } - checker.cancel() }() return checker, nil diff --git a/modules/git/attribute/checker.go b/modules/git/attribute/checker.go index 49c0eb90ef..3eea31e813 100644 --- a/modules/git/attribute/checker.go +++ b/modules/git/attribute/checker.go @@ -68,18 +68,14 @@ func CheckAttributes(ctx context.Context, gitRepo *git.Repository, treeish strin } defer cancel() - stdOut := new(bytes.Buffer) - stdErr := new(bytes.Buffer) - - if err := cmd.WithEnv(append(os.Environ(), envs...)). + stdout, _, err := cmd.WithEnv(append(os.Environ(), envs...)). WithDir(gitRepo.Path). - WithStdout(stdOut). - WithStderr(stdErr). - Run(ctx); err != nil { - return nil, fmt.Errorf("failed to run check-attr: %w\n%s\n%s", err, stdOut.String(), stdErr.String()) + RunStdBytes(ctx) + if err != nil { + return nil, fmt.Errorf("failed to run check-attr: %w", err) } - fields := bytes.Split(stdOut.Bytes(), []byte{'\000'}) + fields := bytes.Split(stdout, []byte{'\000'}) if len(fields)%3 != 1 { return nil, errors.New("wrong number of fields in return from check-attr") } diff --git a/modules/git/batch.go b/modules/git/batch.go deleted file mode 100644 index f9e1748b54..0000000000 --- a/modules/git/batch.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package git - -import ( - "bufio" - "context" -) - -type Batch struct { - cancel context.CancelFunc - Reader *bufio.Reader - Writer WriteCloserError -} - -// NewBatch creates a new batch for the given repository, the Close must be invoked before release the batch -func NewBatch(ctx context.Context, repoPath string) (*Batch, error) { - // Now because of some insanity with git cat-file not immediately failing if not run in a valid git directory we need to run git rev-parse first! - if err := ensureValidGitRepository(ctx, repoPath); err != nil { - return nil, err - } - - var batch Batch - batch.Writer, batch.Reader, batch.cancel = catFileBatch(ctx, repoPath) - return &batch, nil -} - -func NewBatchCheck(ctx context.Context, repoPath string) (*Batch, error) { - // Now because of some insanity with git cat-file not immediately failing if not run in a valid git directory we need to run git rev-parse first! - if err := ensureValidGitRepository(ctx, repoPath); err != nil { - return nil, err - } - - var check Batch - check.Writer, check.Reader, check.cancel = catFileBatchCheck(ctx, repoPath) - return &check, nil -} - -func (b *Batch) Close() { - if b.cancel != nil { - b.cancel() - b.Reader = nil - b.Writer = nil - b.cancel = nil - } -} diff --git a/modules/git/batch_reader.go b/modules/git/batch_reader.go deleted file mode 100644 index b5cec130d5..0000000000 --- a/modules/git/batch_reader.go +++ /dev/null @@ -1,324 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package git - -import ( - "bufio" - "bytes" - "context" - "io" - "math" - "strconv" - "strings" - - "code.gitea.io/gitea/modules/git/gitcmd" - "code.gitea.io/gitea/modules/log" - - "github.com/djherbis/buffer" - "github.com/djherbis/nio/v3" -) - -// WriteCloserError wraps an io.WriteCloser with an additional CloseWithError function -type WriteCloserError interface { - io.WriteCloser - CloseWithError(err error) error -} - -// ensureValidGitRepository runs git rev-parse in the repository path - thus ensuring that the repository is a valid repository. -// Run before opening git cat-file. -// This is needed otherwise the git cat-file will hang for invalid repositories. -func ensureValidGitRepository(ctx context.Context, repoPath string) error { - stderr := strings.Builder{} - err := gitcmd.NewCommand("rev-parse"). - WithDir(repoPath). - WithStderr(&stderr). - Run(ctx) - if err != nil { - return gitcmd.ConcatenateError(err, (&stderr).String()) - } - return nil -} - -// catFileBatchCheck opens git cat-file --batch-check in the provided repo and returns a stdin pipe, a stdout reader and cancel function -func catFileBatchCheck(ctx context.Context, repoPath string) (WriteCloserError, *bufio.Reader, func()) { - batchStdinReader, batchStdinWriter := io.Pipe() - batchStdoutReader, batchStdoutWriter := io.Pipe() - ctx, ctxCancel := context.WithCancel(ctx) - closed := make(chan struct{}) - cancel := func() { - ctxCancel() - _ = batchStdoutReader.Close() - _ = batchStdinWriter.Close() - <-closed - } - - // Ensure cancel is called as soon as the provided context is cancelled - go func() { - <-ctx.Done() - cancel() - }() - - go func() { - stderr := strings.Builder{} - err := gitcmd.NewCommand("cat-file", "--batch-check"). - WithDir(repoPath). - WithStdin(batchStdinReader). - WithStdout(batchStdoutWriter). - WithStderr(&stderr). - WithUseContextTimeout(true). - Run(ctx) - if err != nil { - _ = batchStdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String())) - _ = batchStdinReader.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String())) - } else { - _ = batchStdoutWriter.Close() - _ = batchStdinReader.Close() - } - close(closed) - }() - - // For simplicities sake we'll use a buffered reader to read from the cat-file --batch-check - batchReader := bufio.NewReader(batchStdoutReader) - - return batchStdinWriter, batchReader, cancel -} - -// catFileBatch opens git cat-file --batch in the provided repo and returns a stdin pipe, a stdout reader and cancel function -func catFileBatch(ctx context.Context, repoPath string) (WriteCloserError, *bufio.Reader, func()) { - // We often want to feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary. - // so let's create a batch stdin and stdout - batchStdinReader, batchStdinWriter := io.Pipe() - batchStdoutReader, batchStdoutWriter := nio.Pipe(buffer.New(32 * 1024)) - ctx, ctxCancel := context.WithCancel(ctx) - closed := make(chan struct{}) - cancel := func() { - ctxCancel() - _ = batchStdinWriter.Close() - _ = batchStdoutReader.Close() - <-closed - } - - // Ensure cancel is called as soon as the provided context is cancelled - go func() { - <-ctx.Done() - cancel() - }() - - go func() { - stderr := strings.Builder{} - err := gitcmd.NewCommand("cat-file", "--batch"). - WithDir(repoPath). - WithStdin(batchStdinReader). - WithStdout(batchStdoutWriter). - WithStderr(&stderr). - WithUseContextTimeout(true). - Run(ctx) - if err != nil { - _ = batchStdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String())) - _ = batchStdinReader.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String())) - } else { - _ = batchStdoutWriter.Close() - _ = batchStdinReader.Close() - } - close(closed) - }() - - // For simplicities sake we'll us a buffered reader to read from the cat-file --batch - batchReader := bufio.NewReaderSize(batchStdoutReader, 32*1024) - - return batchStdinWriter, batchReader, cancel -} - -// ReadBatchLine reads the header line from cat-file --batch -// We expect: SP SP LF -// then leaving the rest of the stream " LF" to be read -func ReadBatchLine(rd *bufio.Reader) (sha []byte, typ string, size int64, err error) { - typ, err = rd.ReadString('\n') - if err != nil { - return sha, typ, size, err - } - if len(typ) == 1 { - typ, err = rd.ReadString('\n') - if err != nil { - return sha, typ, size, err - } - } - idx := strings.IndexByte(typ, ' ') - if idx < 0 { - log.Debug("missing space typ: %s", typ) - return sha, typ, size, ErrNotExist{ID: string(sha)} - } - sha = []byte(typ[:idx]) - typ = typ[idx+1:] - - idx = strings.IndexByte(typ, ' ') - if idx < 0 { - return sha, typ, size, ErrNotExist{ID: string(sha)} - } - - sizeStr := typ[idx+1 : len(typ)-1] - typ = typ[:idx] - - size, err = strconv.ParseInt(sizeStr, 10, 64) - return sha, typ, size, err -} - -// ReadTagObjectID reads a tag object ID hash from a cat-file --batch stream, throwing away the rest of the stream. -func ReadTagObjectID(rd *bufio.Reader, size int64) (string, error) { - var id string - var n int64 -headerLoop: - for { - line, err := rd.ReadBytes('\n') - if err != nil { - return "", err - } - n += int64(len(line)) - idx := bytes.Index(line, []byte{' '}) - if idx < 0 { - continue - } - - if string(line[:idx]) == "object" { - id = string(line[idx+1 : len(line)-1]) - break headerLoop - } - } - - // Discard the rest of the tag - return id, DiscardFull(rd, size-n+1) -} - -// ReadTreeID reads a tree ID from a cat-file --batch stream, throwing away the rest of the stream. -func ReadTreeID(rd *bufio.Reader, size int64) (string, error) { - var id string - var n int64 -headerLoop: - for { - line, err := rd.ReadBytes('\n') - if err != nil { - return "", err - } - n += int64(len(line)) - idx := bytes.Index(line, []byte{' '}) - if idx < 0 { - continue - } - - if string(line[:idx]) == "tree" { - id = string(line[idx+1 : len(line)-1]) - break headerLoop - } - } - - // Discard the rest of the commit - return id, DiscardFull(rd, size-n+1) -} - -// git tree files are a list: -// SP NUL -// -// Unfortunately this 20-byte notation is somewhat in conflict to all other git tools -// Therefore we need some method to convert these binary hashes to hex hashes - -// constant hextable to help quickly convert between binary and hex representation -const hextable = "0123456789abcdef" - -// BinToHexHeash converts a binary Hash into a hex encoded one. Input and output can be the -// same byte slice to support in place conversion without allocations. -// This is at least 100x quicker that hex.EncodeToString -func BinToHex(objectFormat ObjectFormat, sha, out []byte) []byte { - for i := objectFormat.FullLength()/2 - 1; i >= 0; i-- { - v := sha[i] - vhi, vlo := v>>4, v&0x0f - shi, slo := hextable[vhi], hextable[vlo] - out[i*2], out[i*2+1] = shi, slo - } - return out -} - -// ParseCatFileTreeLine reads an entry from a tree in a cat-file --batch stream -// This carefully avoids allocations - except where fnameBuf is too small. -// It is recommended therefore to pass in an fnameBuf large enough to avoid almost all allocations -// -// Each line is composed of: -// SP NUL -// -// We don't attempt to convert the raw HASH to save a lot of time -func ParseCatFileTreeLine(objectFormat ObjectFormat, rd *bufio.Reader, modeBuf, fnameBuf, shaBuf []byte) (mode, fname, sha []byte, n int, err error) { - var readBytes []byte - - // Read the Mode & fname - readBytes, err = rd.ReadSlice('\x00') - if err != nil { - return mode, fname, sha, n, err - } - idx := bytes.IndexByte(readBytes, ' ') - if idx < 0 { - log.Debug("missing space in readBytes ParseCatFileTreeLine: %s", readBytes) - return mode, fname, sha, n, &ErrNotExist{} - } - - n += idx + 1 - copy(modeBuf, readBytes[:idx]) - if len(modeBuf) >= idx { - modeBuf = modeBuf[:idx] - } else { - modeBuf = append(modeBuf, readBytes[len(modeBuf):idx]...) - } - mode = modeBuf - - readBytes = readBytes[idx+1:] - - // Deal with the fname - copy(fnameBuf, readBytes) - if len(fnameBuf) > len(readBytes) { - fnameBuf = fnameBuf[:len(readBytes)] - } else { - fnameBuf = append(fnameBuf, readBytes[len(fnameBuf):]...) - } - for err == bufio.ErrBufferFull { - readBytes, err = rd.ReadSlice('\x00') - fnameBuf = append(fnameBuf, readBytes...) - } - n += len(fnameBuf) - if err != nil { - return mode, fname, sha, n, err - } - fnameBuf = fnameBuf[:len(fnameBuf)-1] - fname = fnameBuf - - // Deal with the binary hash - idx = 0 - length := objectFormat.FullLength() / 2 - for idx < length { - var read int - read, err = rd.Read(shaBuf[idx:length]) - n += read - if err != nil { - return mode, fname, sha, n, err - } - idx += read - } - sha = shaBuf - return mode, fname, sha, n, err -} - -func DiscardFull(rd *bufio.Reader, discard int64) error { - if discard > math.MaxInt32 { - n, err := rd.Discard(math.MaxInt32) - discard -= int64(n) - if err != nil { - return err - } - } - for discard > 0 { - n, err := rd.Discard(int(discard)) - discard -= int64(n) - if err != nil { - return err - } - } - return nil -} diff --git a/modules/git/blame.go b/modules/git/blame.go deleted file mode 100644 index 601be96f05..0000000000 --- a/modules/git/blame.go +++ /dev/null @@ -1,218 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package git - -import ( - "bufio" - "bytes" - "context" - "io" - "os" - - "code.gitea.io/gitea/modules/git/gitcmd" - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/setting" -) - -// BlamePart represents block of blame - continuous lines with one sha -type BlamePart struct { - Sha string - Lines []string - PreviousSha string - PreviousPath string -} - -// BlameReader returns part of file blame one by one -type BlameReader struct { - output io.WriteCloser - reader io.ReadCloser - bufferedReader *bufio.Reader - done chan error - lastSha *string - ignoreRevsFile string - objectFormat ObjectFormat - cleanupFuncs []func() -} - -func (r *BlameReader) UsesIgnoreRevs() bool { - return r.ignoreRevsFile != "" -} - -// NextPart returns next part of blame (sequential code lines with the same commit) -func (r *BlameReader) NextPart() (*BlamePart, error) { - var blamePart *BlamePart - - if r.lastSha != nil { - blamePart = &BlamePart{ - Sha: *r.lastSha, - Lines: make([]string, 0), - } - } - - const previousHeader = "previous " - var lineBytes []byte - var isPrefix bool - var err error - - for err != io.EOF { - lineBytes, isPrefix, err = r.bufferedReader.ReadLine() - if err != nil && err != io.EOF { - return blamePart, err - } - - if len(lineBytes) == 0 { - // isPrefix will be false - continue - } - - var objectID string - objectFormatLength := r.objectFormat.FullLength() - - if len(lineBytes) > objectFormatLength && lineBytes[objectFormatLength] == ' ' && r.objectFormat.IsValid(string(lineBytes[0:objectFormatLength])) { - objectID = string(lineBytes[0:objectFormatLength]) - } - if len(objectID) > 0 { - if blamePart == nil { - blamePart = &BlamePart{ - Sha: objectID, - Lines: make([]string, 0), - } - } - - if blamePart.Sha != objectID { - r.lastSha = &objectID - // need to munch to end of line... - for isPrefix { - _, isPrefix, err = r.bufferedReader.ReadLine() - if err != nil && err != io.EOF { - return blamePart, err - } - } - return blamePart, nil - } - } else if lineBytes[0] == '\t' { - blamePart.Lines = append(blamePart.Lines, string(lineBytes[1:])) - } else if bytes.HasPrefix(lineBytes, []byte(previousHeader)) { - offset := len(previousHeader) // already includes a space - blamePart.PreviousSha = string(lineBytes[offset : offset+objectFormatLength]) - offset += objectFormatLength + 1 // +1 for space - blamePart.PreviousPath = string(lineBytes[offset:]) - } - - // need to munch to end of line... - for isPrefix { - _, isPrefix, err = r.bufferedReader.ReadLine() - if err != nil && err != io.EOF { - return blamePart, err - } - } - } - - r.lastSha = nil - - return blamePart, nil -} - -// Close BlameReader - don't run NextPart after invoking that -func (r *BlameReader) Close() error { - if r.bufferedReader == nil { - return nil - } - - err := <-r.done - r.bufferedReader = nil - _ = r.reader.Close() - _ = r.output.Close() - for _, cleanup := range r.cleanupFuncs { - if cleanup != nil { - cleanup() - } - } - return err -} - -// CreateBlameReader creates reader for given repository, commit and file -func CreateBlameReader(ctx context.Context, objectFormat ObjectFormat, repoPath string, commit *Commit, file string, bypassBlameIgnore bool) (rd *BlameReader, err error) { - var ignoreRevsFileName string - var ignoreRevsFileCleanup func() - defer func() { - if err != nil && ignoreRevsFileCleanup != nil { - ignoreRevsFileCleanup() - } - }() - - cmd := gitcmd.NewCommand("blame", "--porcelain") - - if DefaultFeatures().CheckVersionAtLeast("2.23") && !bypassBlameIgnore { - ignoreRevsFileName, ignoreRevsFileCleanup, err = tryCreateBlameIgnoreRevsFile(commit) - if err != nil && !IsErrNotExist(err) { - return nil, err - } - if ignoreRevsFileName != "" { - // Possible improvement: use --ignore-revs-file /dev/stdin on unix - // There is no equivalent on Windows. May be implemented if Gitea uses an external git backend. - cmd.AddOptionValues("--ignore-revs-file", ignoreRevsFileName) - } - } - - cmd.AddDynamicArguments(commit.ID.String()).AddDashesAndList(file) - - done := make(chan error, 1) - reader, stdout, err := os.Pipe() - if err != nil { - return nil, err - } - go func() { - stderr := bytes.Buffer{} - // TODO: it doesn't work for directories (the directories shouldn't be "blamed"), and the "err" should be returned by "Read" but not by "Close" - err := cmd.WithDir(repoPath). - WithUseContextTimeout(true). - WithStdout(stdout). - WithStderr(&stderr). - Run(ctx) - done <- err - _ = stdout.Close() - if err != nil { - log.Error("Error running git blame (dir: %v): %v, stderr: %v", repoPath, err, stderr.String()) - } - }() - - bufferedReader := bufio.NewReader(reader) - return &BlameReader{ - output: stdout, - reader: reader, - bufferedReader: bufferedReader, - done: done, - ignoreRevsFile: ignoreRevsFileName, - objectFormat: objectFormat, - cleanupFuncs: []func(){ignoreRevsFileCleanup}, - }, nil -} - -func tryCreateBlameIgnoreRevsFile(commit *Commit) (string, func(), error) { - entry, err := commit.GetTreeEntryByPath(".git-blame-ignore-revs") - if err != nil { - return "", nil, err - } - - r, err := entry.Blob().DataAsync() - if err != nil { - return "", nil, err - } - defer r.Close() - - f, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("git-blame-ignore-revs") - if err != nil { - return "", nil, err - } - filename := f.Name() - _, err = io.Copy(f, r) - _ = f.Close() - if err != nil { - cleanup() - return "", nil, err - } - - return filename, cleanup, nil -} diff --git a/modules/git/blob_gogit.go b/modules/git/blob_gogit.go index 8c79c067c1..7cf622d180 100644 --- a/modules/git/blob_gogit.go +++ b/modules/git/blob_gogit.go @@ -9,24 +9,38 @@ package git import ( "io" + "code.gitea.io/gitea/modules/log" + "github.com/go-git/go-git/v5/plumbing" ) // Blob represents a Git object. type Blob struct { - ID ObjectID + ID ObjectID + repo *Repository + name string +} - gogitEncodedObj plumbing.EncodedObject - name string +func (b *Blob) gogitEncodedObj() (plumbing.EncodedObject, error) { + return b.repo.gogitRepo.Storer.EncodedObject(plumbing.AnyObject, plumbing.Hash(b.ID.RawValue())) } // DataAsync gets a ReadCloser for the contents of a blob without reading it all. // Calling the Close function on the result will discard all unread output. func (b *Blob) DataAsync() (io.ReadCloser, error) { - return b.gogitEncodedObj.Reader() + obj, err := b.gogitEncodedObj() + if err != nil { + return nil, err + } + return obj.Reader() } // Size returns the uncompressed size of the blob func (b *Blob) Size() int64 { - return b.gogitEncodedObj.Size() + obj, err := b.gogitEncodedObj() + if err != nil { + log.Error("Error getting gogit encoded object for blob %s(%s): %v", b.name, b.ID.String(), err) + return 0 + } + return obj.Size() } diff --git a/modules/git/blob_nogogit.go b/modules/git/blob_nogogit.go index af3ce376d6..837b30fd88 100644 --- a/modules/git/blob_nogogit.go +++ b/modules/git/blob_nogogit.go @@ -6,8 +6,6 @@ package git import ( - "bufio" - "bytes" "io" "code.gitea.io/gitea/modules/log" @@ -25,38 +23,28 @@ type Blob struct { // DataAsync gets a ReadCloser for the contents of a blob without reading it all. // Calling the Close function on the result will discard all unread output. -func (b *Blob) DataAsync() (io.ReadCloser, error) { - wr, rd, cancel, err := b.repo.CatFileBatch(b.repo.Ctx) +func (b *Blob) DataAsync() (_ io.ReadCloser, retErr error) { + batch, cancel, err := b.repo.CatFileBatch(b.repo.Ctx) if err != nil { return nil, err } + defer func() { + // if there was an error, cancel the batch right away, + // otherwise let the caller close it + if retErr != nil { + cancel() + } + }() - _, err = wr.Write([]byte(b.ID.String() + "\n")) + info, contentReader, err := batch.QueryContent(b.ID.String()) if err != nil { - cancel() - return nil, err - } - _, _, size, err := ReadBatchLine(rd) - if err != nil { - cancel() return nil, err } b.gotSize = true - b.size = size - - if size < 4096 { - bs, err := io.ReadAll(io.LimitReader(rd, size)) - defer cancel() - if err != nil { - return nil, err - } - _, err = rd.Discard(1) - return io.NopCloser(bytes.NewReader(bs)), err - } - + b.size = info.Size return &blobReader{ - rd: rd, - n: size, + rd: contentReader, + n: info.Size, cancel: cancel, }, nil } @@ -67,30 +55,24 @@ func (b *Blob) Size() int64 { return b.size } - wr, rd, cancel, err := b.repo.CatFileBatchCheck(b.repo.Ctx) + batch, cancel, err := b.repo.CatFileBatch(b.repo.Ctx) if err != nil { log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err) return 0 } defer cancel() - _, err = wr.Write([]byte(b.ID.String() + "\n")) + info, err := batch.QueryInfo(b.ID.String()) if err != nil { log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err) return 0 } - _, _, b.size, err = ReadBatchLine(rd) - if err != nil { - log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err) - return 0 - } - b.gotSize = true - + b.size = info.Size return b.size } type blobReader struct { - rd *bufio.Reader + rd BufferedReader n int64 cancel func() } diff --git a/modules/git/catfile_batch.go b/modules/git/catfile_batch.go new file mode 100644 index 0000000000..d13179f3ec --- /dev/null +++ b/modules/git/catfile_batch.go @@ -0,0 +1,52 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "io" +) + +type BufferedReader interface { + io.Reader + Buffered() int + Peek(n int) ([]byte, error) + Discard(n int) (int, error) + ReadString(sep byte) (string, error) + ReadSlice(sep byte) ([]byte, error) + ReadBytes(sep byte) ([]byte, error) +} + +type CatFileObject struct { + ID string + Type string + Size int64 +} + +type CatFileBatch interface { + // QueryInfo queries the object info from the git repository by its object name using "git cat-file --batch" family commands. + // "git cat-file" accepts "" for the object name, it can be a ref name, object id, etc. https://git-scm.com/docs/gitrevisions + // In Gitea, we only use the simple ref name or object id, no other complex rev syntax like "suffix" or "git describe" although they are supported by git. + QueryInfo(obj string) (*CatFileObject, error) + + // QueryContent is similar to QueryInfo, it queries the object info and additionally returns a reader for its content. + // FIXME: this design still follows the old pattern: the returned BufferedReader is very fragile, + // callers should carefully maintain its lifecycle and discard all unread data. + // TODO: It needs to be refactored to a fully managed Reader stream in the future, don't let callers manually Close or Discard + QueryContent(obj string) (*CatFileObject, BufferedReader, error) +} + +type CatFileBatchCloser interface { + CatFileBatch + Close() +} + +// NewBatch creates a "batch object provider (CatFileBatch)" for the given repository path to retrieve object info and content efficiently. +// The CatFileBatch and the readers create by it should only be used in the same goroutine. +func NewBatch(ctx context.Context, repoPath string) (CatFileBatchCloser, error) { + if DefaultFeatures().SupportCatFileBatchCommand { + return newCatFileBatchCommand(ctx, repoPath) + } + return newCatFileBatchLegacy(ctx, repoPath) +} diff --git a/modules/git/catfile_batch_command.go b/modules/git/catfile_batch_command.go new file mode 100644 index 0000000000..710561f045 --- /dev/null +++ b/modules/git/catfile_batch_command.go @@ -0,0 +1,66 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "os" + "path/filepath" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/util" +) + +// catFileBatchCommand implements the CatFileBatch interface using the "cat-file --batch-command" command +// for git version >= 2.36 +// ref: https://git-scm.com/docs/git-cat-file#Documentation/git-cat-file.txt---batch-command +type catFileBatchCommand struct { + ctx context.Context + repoPath string + batch *catFileBatchCommunicator +} + +var _ CatFileBatch = (*catFileBatchCommand)(nil) + +func newCatFileBatchCommand(ctx context.Context, repoPath string) (*catFileBatchCommand, error) { + if _, err := os.Stat(repoPath); err != nil { + return nil, util.NewNotExistErrorf("repo %q doesn't exist", filepath.Base(repoPath)) + } + return &catFileBatchCommand{ctx: ctx, repoPath: repoPath}, nil +} + +func (b *catFileBatchCommand) getBatch() *catFileBatchCommunicator { + if b.batch != nil { + return b.batch + } + b.batch = newCatFileBatch(b.ctx, b.repoPath, gitcmd.NewCommand("cat-file", "--batch-command")) + return b.batch +} + +func (b *catFileBatchCommand) QueryContent(obj string) (*CatFileObject, BufferedReader, error) { + _, err := b.getBatch().reqWriter.Write([]byte("contents " + obj + "\n")) + if err != nil { + return nil, nil, err + } + info, err := catFileBatchParseInfoLine(b.getBatch().respReader) + if err != nil { + return nil, nil, err + } + return info, b.getBatch().respReader, nil +} + +func (b *catFileBatchCommand) QueryInfo(obj string) (*CatFileObject, error) { + _, err := b.getBatch().reqWriter.Write([]byte("info " + obj + "\n")) + if err != nil { + return nil, err + } + return catFileBatchParseInfoLine(b.getBatch().respReader) +} + +func (b *catFileBatchCommand) Close() { + if b.batch != nil { + b.batch.Close() + b.batch = nil + } +} diff --git a/modules/git/catfile_batch_legacy.go b/modules/git/catfile_batch_legacy.go new file mode 100644 index 0000000000..795fc4ce3d --- /dev/null +++ b/modules/git/catfile_batch_legacy.go @@ -0,0 +1,81 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "io" + "os" + "path/filepath" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/util" +) + +// catFileBatchLegacy implements the CatFileBatch interface using the "cat-file --batch" command and "cat-file --batch-check" command +// for git version < 2.36 +// to align with "--batch-command", it creates the two commands for querying object contents and object info separately +// ref: https://git-scm.com/docs/git-cat-file#Documentation/git-cat-file.txt---batch +type catFileBatchLegacy struct { + ctx context.Context + repoPath string + batchContent *catFileBatchCommunicator + batchCheck *catFileBatchCommunicator +} + +var _ CatFileBatchCloser = (*catFileBatchLegacy)(nil) + +func newCatFileBatchLegacy(ctx context.Context, repoPath string) (*catFileBatchLegacy, error) { + if _, err := os.Stat(repoPath); err != nil { + return nil, util.NewNotExistErrorf("repo %q doesn't exist", filepath.Base(repoPath)) + } + return &catFileBatchLegacy{ctx: ctx, repoPath: repoPath}, nil +} + +func (b *catFileBatchLegacy) getBatchContent() *catFileBatchCommunicator { + if b.batchContent != nil { + return b.batchContent + } + b.batchContent = newCatFileBatch(b.ctx, b.repoPath, gitcmd.NewCommand("cat-file", "--batch")) + return b.batchContent +} + +func (b *catFileBatchLegacy) getBatchCheck() *catFileBatchCommunicator { + if b.batchCheck != nil { + return b.batchCheck + } + b.batchCheck = newCatFileBatch(b.ctx, b.repoPath, gitcmd.NewCommand("cat-file", "--batch-check")) + return b.batchCheck +} + +func (b *catFileBatchLegacy) QueryContent(obj string) (*CatFileObject, BufferedReader, error) { + _, err := io.WriteString(b.getBatchContent().reqWriter, obj+"\n") + if err != nil { + return nil, nil, err + } + info, err := catFileBatchParseInfoLine(b.getBatchContent().respReader) + if err != nil { + return nil, nil, err + } + return info, b.getBatchContent().respReader, nil +} + +func (b *catFileBatchLegacy) QueryInfo(obj string) (*CatFileObject, error) { + _, err := io.WriteString(b.getBatchCheck().reqWriter, obj+"\n") + if err != nil { + return nil, err + } + return catFileBatchParseInfoLine(b.getBatchCheck().respReader) +} + +func (b *catFileBatchLegacy) Close() { + if b.batchContent != nil { + b.batchContent.Close() + b.batchContent = nil + } + if b.batchCheck != nil { + b.batchCheck.Close() + b.batchCheck = nil + } +} diff --git a/modules/git/catfile_batch_reader.go b/modules/git/catfile_batch_reader.go new file mode 100644 index 0000000000..8a0b342079 --- /dev/null +++ b/modules/git/catfile_batch_reader.go @@ -0,0 +1,254 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "context" + "errors" + "io" + "math" + "strconv" + "strings" + "sync/atomic" + "time" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/log" +) + +var catFileBatchDebugWaitClose atomic.Int64 + +type catFileBatchCommunicator struct { + cancel context.CancelFunc + reqWriter io.Writer + respReader *bufio.Reader + debugGitCmd *gitcmd.Command +} + +func (b *catFileBatchCommunicator) Close() { + if b.cancel != nil { + b.cancel() + b.cancel = nil + } +} + +// newCatFileBatch opens git cat-file --batch in the provided repo and returns a stdin pipe, a stdout reader and cancel function +func newCatFileBatch(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Command) (ret *catFileBatchCommunicator) { + ctx, ctxCancel := context.WithCancelCause(ctx) + + // We often want to feed the commits in order into cat-file --batch, followed by their trees and subtrees as necessary. + stdinWriter, stdoutReader, stdPipeClose := cmdCatFile.MakeStdinStdoutPipe() + pipeClose := func() { + if delay := catFileBatchDebugWaitClose.Load(); delay > 0 { + time.Sleep(time.Duration(delay)) // for testing purpose only + } + stdPipeClose() + } + + ret = &catFileBatchCommunicator{ + debugGitCmd: cmdCatFile, + cancel: func() { ctxCancel(nil) }, + reqWriter: stdinWriter, + respReader: bufio.NewReaderSize(stdoutReader, 32*1024), // use a buffered reader for rich operations + } + + err := cmdCatFile.WithDir(repoPath).StartWithStderr(ctx) + if err != nil { + log.Error("Unable to start git command %v: %v", cmdCatFile.LogString(), err) + // ideally here it should return the error, but it would require refactoring all callers + // so just return a dummy communicator that does nothing, almost the same behavior as before, not bad + ctxCancel(err) + pipeClose() + return ret + } + + go func() { + err := cmdCatFile.WaitWithStderr() + if err != nil && !errors.Is(err, context.Canceled) { + log.Error("cat-file --batch command failed in repo %s, error: %v", repoPath, err) + } + ctxCancel(err) + pipeClose() + }() + + return ret +} + +// catFileBatchParseInfoLine reads the header line from cat-file --batch +// We expect: SP SP LF +// then leaving the rest of the stream " LF" to be read +func catFileBatchParseInfoLine(rd BufferedReader) (*CatFileObject, error) { + typ, err := rd.ReadString('\n') + if err != nil { + return nil, err + } + if len(typ) == 1 { + typ, err = rd.ReadString('\n') + if err != nil { + return nil, err + } + } + idx := strings.IndexByte(typ, ' ') + if idx < 0 { + return nil, ErrNotExist{} + } + sha := typ[:idx] + typ = typ[idx+1:] + + idx = strings.IndexByte(typ, ' ') + if idx < 0 { + return nil, ErrNotExist{ID: sha} + } + + sizeStr := typ[idx+1 : len(typ)-1] + typ = typ[:idx] + + size, err := strconv.ParseInt(sizeStr, 10, 64) + return &CatFileObject{ID: sha, Type: typ, Size: size}, err +} + +// ReadTagObjectID reads a tag object ID hash from a cat-file --batch stream, throwing away the rest of the stream. +func ReadTagObjectID(rd BufferedReader, size int64) (string, error) { + var id string + var n int64 +headerLoop: + for { + line, err := rd.ReadBytes('\n') + if err != nil { + return "", err + } + n += int64(len(line)) + idx := bytes.Index(line, []byte{' '}) + if idx < 0 { + continue + } + + if string(line[:idx]) == "object" { + id = string(line[idx+1 : len(line)-1]) + break headerLoop + } + } + + // Discard the rest of the tag + return id, DiscardFull(rd, size-n+1) +} + +// ReadTreeID reads a tree ID from a cat-file --batch stream, throwing away the rest of the stream. +func ReadTreeID(rd BufferedReader, size int64) (string, error) { + var id string + var n int64 +headerLoop: + for { + line, err := rd.ReadBytes('\n') + if err != nil { + return "", err + } + n += int64(len(line)) + idx := bytes.Index(line, []byte{' '}) + if idx < 0 { + continue + } + + if string(line[:idx]) == "tree" { + id = string(line[idx+1 : len(line)-1]) + break headerLoop + } + } + + // Discard the rest of the commit + return id, DiscardFull(rd, size-n+1) +} + +// git tree files are a list: +// SP NUL +// +// Unfortunately this 20-byte notation is somewhat in conflict to all other git tools +// Therefore we need some method to convert these binary hashes to hex hashes + +// ParseCatFileTreeLine reads an entry from a tree in a cat-file --batch stream +// This carefully avoids allocations - except where fnameBuf is too small. +// It is recommended therefore to pass in an fnameBuf large enough to avoid almost all allocations +// +// Each line is composed of: +// SP NUL +// +// We don't attempt to convert the raw HASH to save a lot of time +func ParseCatFileTreeLine(objectFormat ObjectFormat, rd BufferedReader, modeBuf, fnameBuf, shaBuf []byte) (mode, fname, sha []byte, n int, err error) { + var readBytes []byte + + // Read the Mode & fname + readBytes, err = rd.ReadSlice('\x00') + if err != nil { + return mode, fname, sha, n, err + } + idx := bytes.IndexByte(readBytes, ' ') + if idx < 0 { + log.Debug("missing space in readBytes ParseCatFileTreeLine: %s", readBytes) + return mode, fname, sha, n, &ErrNotExist{} + } + + n += idx + 1 + copy(modeBuf, readBytes[:idx]) + if len(modeBuf) >= idx { + modeBuf = modeBuf[:idx] + } else { + modeBuf = append(modeBuf, readBytes[len(modeBuf):idx]...) + } + mode = modeBuf + + readBytes = readBytes[idx+1:] + + // Deal with the fname + copy(fnameBuf, readBytes) + if len(fnameBuf) > len(readBytes) { + fnameBuf = fnameBuf[:len(readBytes)] + } else { + fnameBuf = append(fnameBuf, readBytes[len(fnameBuf):]...) + } + for err == bufio.ErrBufferFull { + readBytes, err = rd.ReadSlice('\x00') + fnameBuf = append(fnameBuf, readBytes...) + } + n += len(fnameBuf) + if err != nil { + return mode, fname, sha, n, err + } + fnameBuf = fnameBuf[:len(fnameBuf)-1] + fname = fnameBuf + + // Deal with the binary hash + idx = 0 + length := objectFormat.FullLength() / 2 + for idx < length { + var read int + read, err = rd.Read(shaBuf[idx:length]) + n += read + if err != nil { + return mode, fname, sha, n, err + } + idx += read + } + sha = shaBuf + return mode, fname, sha, n, err +} + +func DiscardFull(rd BufferedReader, discard int64) error { + if discard > math.MaxInt32 { + n, err := rd.Discard(math.MaxInt32) + discard -= int64(n) + if err != nil { + return err + } + } + for discard > 0 { + n, err := rd.Discard(int(discard)) + discard -= int64(n) + if err != nil { + return err + } + } + return nil +} diff --git a/modules/git/catfile_batch_test.go b/modules/git/catfile_batch_test.go new file mode 100644 index 0000000000..69662ffc1a --- /dev/null +++ b/modules/git/catfile_batch_test.go @@ -0,0 +1,104 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "io" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCatFileBatch(t *testing.T) { + defer test.MockVariableValue(&DefaultFeatures().SupportCatFileBatchCommand)() + DefaultFeatures().SupportCatFileBatchCommand = false + t.Run("LegacyCheck", testCatFileBatch) + DefaultFeatures().SupportCatFileBatchCommand = true + t.Run("BatchCommand", testCatFileBatch) +} + +func testCatFileBatch(t *testing.T) { + t.Run("CorruptedGitRepo", func(t *testing.T) { + tmpDir := t.TempDir() + batch, err := NewBatch(t.Context(), tmpDir) + // as long as the directory exists, no error, because we can't really know whether the git repo is valid until we run commands + require.NoError(t, err) + defer batch.Close() + + _, err = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449") + require.Error(t, err) + _, err = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449") + require.Error(t, err) + }) + + simulateQueryTerminated := func(pipeCloseDelay, pipeReadDelay time.Duration) (errRead error) { + catFileBatchDebugWaitClose.Store(int64(pipeCloseDelay)) + defer catFileBatchDebugWaitClose.Store(0) + batch, err := NewBatch(t.Context(), filepath.Join(testReposDir, "repo1_bare")) + require.NoError(t, err) + defer batch.Close() + _, _ = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449") + var c *catFileBatchCommunicator + switch b := batch.(type) { + case *catFileBatchLegacy: + c = b.batchCheck + _, _ = c.reqWriter.Write([]byte("in-complete-line-")) + case *catFileBatchCommand: + c = b.batch + _, _ = c.reqWriter.Write([]byte("info")) + default: + t.FailNow() + } + + wg := sync.WaitGroup{} + wg.Go(func() { + time.Sleep(pipeReadDelay) + var n int + n, errRead = c.respReader.Read(make([]byte, 100)) + assert.Zero(t, n) + }) + time.Sleep(10 * time.Millisecond) + c.debugGitCmd.DebugKill() + wg.Wait() + return errRead + } + + t.Run("QueryTerminated", func(t *testing.T) { + err := simulateQueryTerminated(0, 20*time.Millisecond) + assert.ErrorIs(t, err, os.ErrClosed) // pipes are closed faster + err = simulateQueryTerminated(40*time.Millisecond, 20*time.Millisecond) + assert.ErrorIs(t, err, io.EOF) // reader is faster + }) + + batch, err := NewBatch(t.Context(), filepath.Join(testReposDir, "repo1_bare")) + require.NoError(t, err) + defer batch.Close() + + t.Run("QueryInfo", func(t *testing.T) { + info, err := batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449") + require.NoError(t, err) + assert.Equal(t, "e2129701f1a4d54dc44f03c93bca0a2aec7c5449", info.ID) + assert.Equal(t, "blob", info.Type) + assert.EqualValues(t, 6, info.Size) + }) + + t.Run("QueryContent", func(t *testing.T) { + info, rd, err := batch.QueryContent("e2129701f1a4d54dc44f03c93bca0a2aec7c5449") + require.NoError(t, err) + assert.Equal(t, "e2129701f1a4d54dc44f03c93bca0a2aec7c5449", info.ID) + assert.Equal(t, "blob", info.Type) + assert.EqualValues(t, 6, info.Size) + + content, err := io.ReadAll(io.LimitReader(rd, info.Size)) + require.NoError(t, err) + require.Equal(t, "file1\n", string(content)) + }) +} diff --git a/modules/git/commit.go b/modules/git/commit.go index 260b81b590..e66a33ef98 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -5,17 +5,13 @@ package git import ( - "bufio" - "bytes" "context" "errors" "io" "os/exec" - "strconv" "strings" "code.gitea.io/gitea/modules/git/gitcmd" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" ) @@ -124,71 +120,12 @@ func CommitChanges(ctx context.Context, repoPath string, opts CommitChangesOptio _, _, err := cmd.WithDir(repoPath).RunStdString(ctx) // No stderr but exit status 1 means nothing to commit. - if err != nil && err.Error() == "exit status 1" { + if gitcmd.IsErrorExitCode(err, 1) { return nil } return err } -// AllCommitsCount returns count of all commits in repository -func AllCommitsCount(ctx context.Context, repoPath string, hidePRRefs bool, files ...string) (int64, error) { - cmd := gitcmd.NewCommand("rev-list") - if hidePRRefs { - cmd.AddArguments("--exclude=" + PullPrefix + "*") - } - cmd.AddArguments("--all", "--count") - if len(files) > 0 { - cmd.AddDashesAndList(files...) - } - - stdout, _, err := cmd.WithDir(repoPath).RunStdString(ctx) - if err != nil { - return 0, err - } - - return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64) -} - -// CommitsCountOptions the options when counting commits -type CommitsCountOptions struct { - RepoPath string - Not string - Revision []string - RelPath []string - Since string - Until string -} - -// CommitsCount returns number of total commits of until given revision. -func CommitsCount(ctx context.Context, opts CommitsCountOptions) (int64, error) { - cmd := gitcmd.NewCommand("rev-list", "--count") - - cmd.AddDynamicArguments(opts.Revision...) - - if opts.Not != "" { - cmd.AddOptionValues("--not", opts.Not) - } - - if len(opts.RelPath) > 0 { - cmd.AddDashesAndList(opts.RelPath...) - } - - stdout, _, err := cmd.WithDir(opts.RepoPath).RunStdString(ctx) - if err != nil { - return 0, err - } - - return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64) -} - -// CommitsCount returns number of total commits of until current revision. -func (c *Commit) CommitsCount() (int64, error) { - return CommitsCount(c.repo.Ctx, CommitsCountOptions{ - RepoPath: c.repo.Path, - Revision: []string{c.ID.String()}, - }) -} - // CommitsByRange returns the specific page commits before current revision, every page's number default by CommitsRangeSize func (c *Commit) CommitsByRange(page, pageSize int, not, since, until string) ([]*Commit, error) { return c.repo.commitsByRangeWithTime(c.ID, page, pageSize, not, since, until) @@ -371,85 +308,6 @@ func (c *Commit) GetBranchName() (string, error) { return strings.SplitN(strings.TrimSpace(data), "~", 2)[0], nil } -// CommitFileStatus represents status of files in a commit. -type CommitFileStatus struct { - Added []string - Removed []string - Modified []string -} - -// NewCommitFileStatus creates a CommitFileStatus -func NewCommitFileStatus() *CommitFileStatus { - return &CommitFileStatus{ - []string{}, []string{}, []string{}, - } -} - -func parseCommitFileStatus(fileStatus *CommitFileStatus, stdout io.Reader) { - rd := bufio.NewReader(stdout) - peek, err := rd.Peek(1) - if err != nil { - if err != io.EOF { - log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) - } - return - } - if peek[0] == '\n' || peek[0] == '\x00' { - _, _ = rd.Discard(1) - } - for { - modifier, err := rd.ReadString('\x00') - if err != nil { - if err != io.EOF { - log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) - } - return - } - file, err := rd.ReadString('\x00') - if err != nil { - if err != io.EOF { - log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) - } - return - } - file = file[:len(file)-1] - switch modifier[0] { - case 'A': - fileStatus.Added = append(fileStatus.Added, file) - case 'D': - fileStatus.Removed = append(fileStatus.Removed, file) - case 'M': - fileStatus.Modified = append(fileStatus.Modified, file) - } - } -} - -// GetCommitFileStatus returns file status of commit in given repository. -func GetCommitFileStatus(ctx context.Context, repoPath, commitID string) (*CommitFileStatus, error) { - stdout, w := io.Pipe() - done := make(chan struct{}) - fileStatus := NewCommitFileStatus() - go func() { - parseCommitFileStatus(fileStatus, stdout) - close(done) - }() - - stderr := new(bytes.Buffer) - err := gitcmd.NewCommand("log", "--name-status", "-m", "--pretty=format:", "--first-parent", "--no-renames", "-z", "-1"). - AddDynamicArguments(commitID). - WithDir(repoPath). - WithStdout(w). - WithStderr(stderr). - Run(ctx) - w.Close() // Close writer to exit parsing goroutine - if err != nil { - return nil, gitcmd.ConcatenateError(err, stderr.String()) - } - - <-done - return fileStatus, nil -} - // GetFullCommitID returns full length (40) of commit ID by given short SHA in a repository. func GetFullCommitID(ctx context.Context, repoPath, shortID string) (string, error) { commitID, _, err := gitcmd.NewCommand("rev-parse"). @@ -457,7 +315,7 @@ func GetFullCommitID(ctx context.Context, repoPath, shortID string) (string, err WithDir(repoPath). RunStdString(ctx) if err != nil { - if strings.Contains(err.Error(), "exit status 128") { + if gitcmd.IsErrorExitCode(err, 128) { return "", ErrNotExist{shortID, ""} } return "", err @@ -465,14 +323,6 @@ func GetFullCommitID(ctx context.Context, repoPath, shortID string) (string, err return strings.TrimSpace(commitID), nil } -// GetRepositoryDefaultPublicGPGKey returns the default public key for this commit -func (c *Commit) GetRepositoryDefaultPublicGPGKey(forceUpdate bool) (*GPGSettings, error) { - if c.repo == nil { - return nil, nil - } - return c.repo.GetDefaultPublicGPGKey(forceUpdate) -} - func IsStringLikelyCommitID(objFmt ObjectFormat, s string, minLength ...int) bool { maxLen := 64 // sha256 if objFmt != nil { diff --git a/modules/git/commit_info_test.go b/modules/git/commit_info_test.go index 51e1551d2d..1e1697b006 100644 --- a/modules/git/commit_info_test.go +++ b/modules/git/commit_info_test.go @@ -30,28 +30,57 @@ func cloneRepo(tb testing.TB, url string) (string, error) { } func testGetCommitsInfo(t *testing.T, repo1 *Repository) { + type expectedEntryInfo struct { + CommitID string + Size int64 + } + // these test case are specific to the repo1 test repo testCases := []struct { CommitID string Path string - ExpectedIDs map[string]string + ExpectedIDs map[string]expectedEntryInfo ExpectedTreeCommit string }{ - {"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", "", map[string]string{ - "file1.txt": "95bb4d39648ee7e325106df01a621c530863a653", - "file2.txt": "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", + {"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", "", map[string]expectedEntryInfo{ + "file1.txt": { + CommitID: "95bb4d39648ee7e325106df01a621c530863a653", + Size: 6, + }, + "file2.txt": { + CommitID: "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", + Size: 6, + }, }, "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2"}, - {"2839944139e0de9737a044f78b0e4b40d989a9e3", "", map[string]string{ - "file1.txt": "2839944139e0de9737a044f78b0e4b40d989a9e3", - "branch1.txt": "9c9aef8dd84e02bc7ec12641deb4c930a7c30185", + {"2839944139e0de9737a044f78b0e4b40d989a9e3", "", map[string]expectedEntryInfo{ + "file1.txt": { + CommitID: "2839944139e0de9737a044f78b0e4b40d989a9e3", + Size: 15, + }, + "branch1.txt": { + CommitID: "9c9aef8dd84e02bc7ec12641deb4c930a7c30185", + Size: 8, + }, }, "2839944139e0de9737a044f78b0e4b40d989a9e3"}, - {"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", "branch2", map[string]string{ - "branch2.txt": "5c80b0245c1c6f8343fa418ec374b13b5d4ee658", + {"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", "branch2", map[string]expectedEntryInfo{ + "branch2.txt": { + CommitID: "5c80b0245c1c6f8343fa418ec374b13b5d4ee658", + Size: 8, + }, }, "5c80b0245c1c6f8343fa418ec374b13b5d4ee658"}, - {"feaf4ba6bc635fec442f46ddd4512416ec43c2c2", "", map[string]string{ - "file1.txt": "95bb4d39648ee7e325106df01a621c530863a653", - "file2.txt": "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", - "foo": "37991dec2c8e592043f47155ce4808d4580f9123", + {"feaf4ba6bc635fec442f46ddd4512416ec43c2c2", "", map[string]expectedEntryInfo{ + "file1.txt": { + CommitID: "95bb4d39648ee7e325106df01a621c530863a653", + Size: 6, + }, + "file2.txt": { + CommitID: "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", + Size: 6, + }, + "foo": { + CommitID: "37991dec2c8e592043f47155ce4808d4580f9123", + Size: 0, + }, }, "feaf4ba6bc635fec442f46ddd4512416ec43c2c2"}, } for _, testCase := range testCases { @@ -93,11 +122,12 @@ func testGetCommitsInfo(t *testing.T, repo1 *Repository) { for _, commitInfo := range commitsInfo { entry := commitInfo.Entry commit := commitInfo.Commit - expectedID, ok := testCase.ExpectedIDs[entry.Name()] + expectedInfo, ok := testCase.ExpectedIDs[entry.Name()] if !assert.True(t, ok) { continue } - assert.Equal(t, expectedID, commit.ID.String()) + assert.Equal(t, expectedInfo.CommitID, commit.ID.String()) + assert.Equal(t, expectedInfo.Size, entry.Size(), entry.Name()) } } } @@ -173,7 +203,6 @@ func BenchmarkEntries_GetCommitsInfo(b *testing.B) { } else if entries, err = commit.Tree.ListEntries(); err != nil { b.Fatal(err) } - entries.Sort() b.ResetTimer() b.Run(benchmark.name, func(b *testing.B) { for b.Loop() { diff --git a/modules/git/commit_sha256_test.go b/modules/git/commit_sha256_test.go index 772f5eedb2..0aefb30c95 100644 --- a/modules/git/commit_sha256_test.go +++ b/modules/git/commit_sha256_test.go @@ -14,33 +14,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestCommitsCountSha256(t *testing.T) { - bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256") - - commitsCount, err := CommitsCount(t.Context(), - CommitsCountOptions{ - RepoPath: bareRepo1Path, - Revision: []string{"f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc"}, - }) - - assert.NoError(t, err) - assert.Equal(t, int64(3), commitsCount) -} - -func TestCommitsCountWithoutBaseSha256(t *testing.T) { - bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256") - - commitsCount, err := CommitsCount(t.Context(), - CommitsCountOptions{ - RepoPath: bareRepo1Path, - Not: "main", - Revision: []string{"branch1"}, - }) - - assert.NoError(t, err) - assert.Equal(t, int64(2), commitsCount) -} - func TestGetFullCommitIDSha256(t *testing.T) { bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256") @@ -157,39 +130,3 @@ func TestHasPreviousCommitSha256(t *testing.T) { assert.NoError(t, err) assert.False(t, selfNot) } - -func TestGetCommitFileStatusMergesSha256(t *testing.T) { - bareRepo1Path := filepath.Join(testReposDir, "repo6_merge_sha256") - - commitFileStatus, err := GetCommitFileStatus(t.Context(), bareRepo1Path, "d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1") - assert.NoError(t, err) - - expected := CommitFileStatus{ - []string{ - "add_file.txt", - }, - []string{}, - []string{ - "to_modify.txt", - }, - } - - assert.Equal(t, expected.Added, commitFileStatus.Added) - assert.Equal(t, expected.Removed, commitFileStatus.Removed) - assert.Equal(t, expected.Modified, commitFileStatus.Modified) - - expected = CommitFileStatus{ - []string{}, - []string{ - "to_remove.txt", - }, - []string{}, - } - - commitFileStatus, err = GetCommitFileStatus(t.Context(), bareRepo1Path, "da1ded40dc8e5b7c564171f4bf2fc8370487decfb1cb6a99ef28f3ed73d09172") - assert.NoError(t, err) - - assert.Equal(t, expected.Added, commitFileStatus.Added) - assert.Equal(t, expected.Removed, commitFileStatus.Removed) - assert.Equal(t, expected.Modified, commitFileStatus.Modified) -} diff --git a/modules/git/commit_test.go b/modules/git/commit_test.go index 688b4e294f..de7b7455eb 100644 --- a/modules/git/commit_test.go +++ b/modules/git/commit_test.go @@ -13,33 +13,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestCommitsCount(t *testing.T) { - bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") - - commitsCount, err := CommitsCount(t.Context(), - CommitsCountOptions{ - RepoPath: bareRepo1Path, - Revision: []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"}, - }) - - assert.NoError(t, err) - assert.Equal(t, int64(3), commitsCount) -} - -func TestCommitsCountWithoutBase(t *testing.T) { - bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") - - commitsCount, err := CommitsCount(t.Context(), - CommitsCountOptions{ - RepoPath: bareRepo1Path, - Not: "master", - Revision: []string{"branch1"}, - }) - - assert.NoError(t, err) - assert.Equal(t, int64(2), commitsCount) -} - func TestGetFullCommitID(t *testing.T) { bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") @@ -212,134 +185,6 @@ func TestHasPreviousCommit(t *testing.T) { assert.False(t, selfNot) } -func TestParseCommitFileStatus(t *testing.T) { - type testcase struct { - output string - added []string - removed []string - modified []string - } - - kases := []testcase{ - { - // Merge commit - output: "MM\x00options/locale/locale_en-US.ini\x00", - modified: []string{ - "options/locale/locale_en-US.ini", - }, - added: []string{}, - removed: []string{}, - }, - { - // Spaces commit - output: "D\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00", - removed: []string{ - "b", - "b b/b", - }, - modified: []string{}, - added: []string{ - "b b/b b/b b/b", - "b b/b b/b b/b b/b", - }, - }, - { - // larger commit - output: "M\x00go.mod\x00M\x00go.sum\x00M\x00modules/ssh/ssh.go\x00M\x00vendor/github.com/gliderlabs/ssh/circle.yml\x00M\x00vendor/github.com/gliderlabs/ssh/context.go\x00A\x00vendor/github.com/gliderlabs/ssh/go.mod\x00A\x00vendor/github.com/gliderlabs/ssh/go.sum\x00M\x00vendor/github.com/gliderlabs/ssh/server.go\x00M\x00vendor/github.com/gliderlabs/ssh/session.go\x00M\x00vendor/github.com/gliderlabs/ssh/ssh.go\x00M\x00vendor/golang.org/x/sys/unix/mkerrors.sh\x00M\x00vendor/golang.org/x/sys/unix/syscall_darwin.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_linux.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go\x00M\x00vendor/modules.txt\x00", - modified: []string{ - "go.mod", - "go.sum", - "modules/ssh/ssh.go", - "vendor/github.com/gliderlabs/ssh/circle.yml", - "vendor/github.com/gliderlabs/ssh/context.go", - "vendor/github.com/gliderlabs/ssh/server.go", - "vendor/github.com/gliderlabs/ssh/session.go", - "vendor/github.com/gliderlabs/ssh/ssh.go", - "vendor/golang.org/x/sys/unix/mkerrors.sh", - "vendor/golang.org/x/sys/unix/syscall_darwin.go", - "vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go", - "vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go", - "vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go", - "vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go", - "vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go", - "vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go", - "vendor/golang.org/x/sys/unix/zerrors_linux.go", - "vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go", - "vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go", - "vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go", - "vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go", - "vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go", - "vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go", - "vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go", - "vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go", - "vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go", - "vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go", - "vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go", - "vendor/modules.txt", - }, - added: []string{ - "vendor/github.com/gliderlabs/ssh/go.mod", - "vendor/github.com/gliderlabs/ssh/go.sum", - }, - removed: []string{}, - }, - { - // git 1.7.2 adds an unnecessary \x00 on merge commit - output: "\x00MM\x00options/locale/locale_en-US.ini\x00", - modified: []string{ - "options/locale/locale_en-US.ini", - }, - added: []string{}, - removed: []string{}, - }, - { - // git 1.7.2 adds an unnecessary \n on normal commit - output: "\nD\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00", - removed: []string{ - "b", - "b b/b", - }, - modified: []string{}, - added: []string{ - "b b/b b/b b/b", - "b b/b b/b b/b b/b", - }, - }, - } - - for _, kase := range kases { - fileStatus := NewCommitFileStatus() - parseCommitFileStatus(fileStatus, strings.NewReader(kase.output)) - - assert.Equal(t, kase.added, fileStatus.Added) - assert.Equal(t, kase.removed, fileStatus.Removed) - assert.Equal(t, kase.modified, fileStatus.Modified) - } -} - -func TestGetCommitFileStatusMerges(t *testing.T) { - bareRepo1Path := filepath.Join(testReposDir, "repo6_merge") - - commitFileStatus, err := GetCommitFileStatus(t.Context(), bareRepo1Path, "022f4ce6214973e018f02bf363bf8a2e3691f699") - assert.NoError(t, err) - - expected := CommitFileStatus{ - []string{ - "add_file.txt", - }, - []string{ - "to_remove.txt", - }, - []string{ - "to_modify.txt", - }, - } - - assert.Equal(t, expected.Added, commitFileStatus.Added) - assert.Equal(t, expected.Removed, commitFileStatus.Removed) - assert.Equal(t, expected.Modified, commitFileStatus.Modified) -} - func Test_GetCommitBranchStart(t *testing.T) { bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") repo, err := OpenRepository(t.Context(), bareRepo1Path) diff --git a/modules/git/diff.go b/modules/git/diff.go index 437b26eb05..d7732eaa29 100644 --- a/modules/git/diff.go +++ b/modules/git/diff.go @@ -5,71 +5,83 @@ package git import ( "bufio" - "bytes" "context" "fmt" "io" - "os" "regexp" "strconv" "strings" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" ) -// RawDiffType type of a raw diff. +// RawDiffType output format: diff or patch type RawDiffType string -// RawDiffType possible values. const ( RawDiffNormal RawDiffType = "diff" RawDiffPatch RawDiffType = "patch" ) // GetRawDiff dumps diff results of repository in given commit ID to io.Writer. -func GetRawDiff(repo *Repository, commitID string, diffType RawDiffType, writer io.Writer) error { - return GetRepoRawDiffForFile(repo, "", commitID, diffType, "", writer) -} - -// GetReverseRawDiff dumps the reverse diff results of repository in given commit ID to io.Writer. -func GetReverseRawDiff(ctx context.Context, repoPath, commitID string, writer io.Writer) error { - stderr := new(bytes.Buffer) - if err := gitcmd.NewCommand("show", "--pretty=format:revert %H%n", "-R"). - AddDynamicArguments(commitID). - WithDir(repoPath). - WithStdout(writer). - WithStderr(stderr). - Run(ctx); err != nil { - return fmt.Errorf("Run: %w - %s", err, stderr) +func GetRawDiff(repo *Repository, commitID string, diffType RawDiffType, writer io.Writer) (retErr error) { + cmd, err := getRepoRawDiffForFileCmd(repo.Ctx, repo, "", commitID, diffType, "") + if err != nil { + return fmt.Errorf("getRepoRawDiffForFileCmd: %w", err) } - return nil + return cmd.WithStdoutCopy(writer).RunWithStderr(repo.Ctx) } -// GetRepoRawDiffForFile dumps diff results of file in given commit ID to io.Writer according given repository -func GetRepoRawDiffForFile(repo *Repository, startCommit, endCommit string, diffType RawDiffType, file string, writer io.Writer) error { +// GetFileDiffCutAroundLine cuts the old or new part of the diff of a file around a specific line number +func GetFileDiffCutAroundLine( + repo *Repository, startCommit, endCommit, treePath string, + line int64, old bool, numbersOfLine int, +) (ret string, retErr error) { + cmd, err := getRepoRawDiffForFileCmd(repo.Ctx, repo, startCommit, endCommit, RawDiffNormal, treePath) + if err != nil { + return "", fmt.Errorf("getRepoRawDiffForFileCmd: %w", err) + } + stdoutReader, stdoutClose := cmd.MakeStdoutPipe() + defer stdoutClose() + cmd.WithPipelineFunc(func(ctx gitcmd.Context) error { + ret, err = CutDiffAroundLine(stdoutReader, line, old, numbersOfLine) + return err + }) + return ret, cmd.RunWithStderr(repo.Ctx) +} + +// getRepoRawDiffForFile returns an io.Reader for the diff results of file in given commit ID +// and a "finish" function to wait for the git command and clean up resources after reading is done. +func getRepoRawDiffForFileCmd(_ context.Context, repo *Repository, startCommit, endCommit string, diffType RawDiffType, file string) (*gitcmd.Command, error) { commit, err := repo.GetCommit(endCommit) if err != nil { - return err + return nil, err } var files []string if len(file) > 0 { files = append(files, file) } - cmd := gitcmd.NewCommand() + cmd := gitcmd.NewCommand().WithDir(repo.Path) switch diffType { case RawDiffNormal: if len(startCommit) != 0 { - cmd.AddArguments("diff", "-M").AddDynamicArguments(startCommit, endCommit).AddDashesAndList(files...) + cmd.AddArguments("diff"). + AddOptionFormat("--find-renames=%s", setting.Git.DiffRenameSimilarityThreshold). + AddDynamicArguments(startCommit, endCommit).AddDashesAndList(files...) } else if commit.ParentCount() == 0 { cmd.AddArguments("show").AddDynamicArguments(endCommit).AddDashesAndList(files...) } else { c, err := commit.Parent(0) if err != nil { - return err + return nil, err } - cmd.AddArguments("diff", "-M").AddDynamicArguments(c.ID.String(), endCommit).AddDashesAndList(files...) + cmd.AddArguments("diff"). + AddOptionFormat("--find-renames=%s", setting.Git.DiffRenameSimilarityThreshold). + AddDynamicArguments(c.ID.String(), endCommit).AddDashesAndList(files...) } case RawDiffPatch: if len(startCommit) != 0 { @@ -80,23 +92,15 @@ func GetRepoRawDiffForFile(repo *Repository, startCommit, endCommit string, diff } else { c, err := commit.Parent(0) if err != nil { - return err + return nil, err } query := fmt.Sprintf("%s...%s", endCommit, c.ID.String()) cmd.AddArguments("format-patch", "--no-signature", "--stdout").AddDynamicArguments(query).AddDashesAndList(files...) } default: - return fmt.Errorf("invalid diffType: %s", diffType) + return nil, util.NewInvalidArgumentErrorf("invalid diff type: %s", diffType) } - - stderr := new(bytes.Buffer) - if err = cmd.WithDir(repo.Path). - WithStdout(writer). - WithStderr(stderr). - Run(repo.Ctx); err != nil { - return fmt.Errorf("Run: %w - %s", err, stderr) - } - return nil + return cmd, nil } // ParseDiffHunkString parse the diff hunk content and return @@ -233,7 +237,7 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi } } if err := scanner.Err(); err != nil { - return "", err + return "", fmt.Errorf("CutDiffAroundLine: scan: %w", err) } // No hunk found @@ -299,30 +303,15 @@ func GetAffectedFiles(repo *Repository, branchName, oldCommitID, newCommitID str } oldCommitID = startCommitID } - stdoutReader, stdoutWriter, err := os.Pipe() - if err != nil { - log.Error("Unable to create os.Pipe for %s", repo.Path) - return nil, err - } - defer func() { - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - }() affectedFiles := make([]string, 0, 32) // Run `git diff --name-only` to get the names of the changed files - err = gitcmd.NewCommand("diff", "--name-only").AddDynamicArguments(oldCommitID, newCommitID). - WithEnv(env). - WithDir(repo.Path). - WithStdout(stdoutWriter). - WithPipelineFunc(func(ctx context.Context, cancel context.CancelFunc) error { - // Close the writer end of the pipe to begin processing - _ = stdoutWriter.Close() - defer func() { - // Close the reader on return to terminate the git command if necessary - _ = stdoutReader.Close() - }() + cmd := gitcmd.NewCommand("diff", "--name-only").AddDynamicArguments(oldCommitID, newCommitID) + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + defer stdoutReaderClose() + err := cmd.WithEnv(env).WithDir(repo.Path). + WithPipelineFunc(func(ctx gitcmd.Context) error { // Now scan the output from the command scanner := bufio.NewScanner(stdoutReader) for scanner.Scan() { diff --git a/modules/git/error.go b/modules/git/error.go index d4b5412da9..1b7bdca043 100644 --- a/modules/git/error.go +++ b/modules/git/error.go @@ -4,8 +4,6 @@ package git import ( - "context" - "errors" "fmt" "strings" @@ -143,10 +141,3 @@ func IsErrMoreThanOne(err error) bool { func (err *ErrMoreThanOne) Error() string { return fmt.Sprintf("ErrMoreThanOne Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut) } - -func IsErrCanceledOrKilled(err error) bool { - // When "cancel()" a git command's context, the returned error of "Run()" could be one of them: - // - context.Canceled - // - *exec.ExitError: "signal: killed" - return err != nil && (errors.Is(err, context.Canceled) || err.Error() == "signal: killed") -} diff --git a/modules/git/foreachref/format.go b/modules/git/foreachref/format.go index d9573a55d6..d2f9998fe8 100644 --- a/modules/git/foreachref/format.go +++ b/modules/git/foreachref/format.go @@ -75,9 +75,9 @@ func (f Format) Parser(r io.Reader) *Parser { // hexEscaped produces hex-escpaed characters from a string. For example, "\n\0" // would turn into "%0a%00". func (f Format) hexEscaped(delim []byte) string { - escaped := "" + var escaped strings.Builder for i := range delim { - escaped += "%" + hex.EncodeToString([]byte{delim[i]}) + escaped.WriteString("%" + hex.EncodeToString([]byte{delim[i]})) } - return escaped + return escaped.String() } diff --git a/modules/git/foreachref/parser.go b/modules/git/foreachref/parser.go index ebdc7344d0..913431795f 100644 --- a/modules/git/foreachref/parser.go +++ b/modules/git/foreachref/parser.go @@ -30,9 +30,11 @@ type Parser struct { func NewParser(r io.Reader, format Format) *Parser { scanner := bufio.NewScanner(r) - // default MaxScanTokenSize = 64 kiB may be too small for some references, - // so allow the buffer to grow up to 4x if needed - scanner.Buffer(nil, 4*bufio.MaxScanTokenSize) + // default Scanner.MaxScanTokenSize = 64 kiB may be too small for some references, + // so allow the buffer to be large enough in case the ref has long content (e.g.: a tag with long message) + // as long as it doesn't exceed some reasonable limit (4 MiB here, or MAX_DISPLAY_FILE_SIZE=8MiB), it is OK + // there are still some choices: 1. add a config option for the limit; 2. don't use scanner and write our own parser to fully handle large contents + scanner.Buffer(nil, 4*1024*1024) // in addition to the reference delimiter we specified in the --format, // `git for-each-ref` will always add a newline after every reference. @@ -113,10 +115,10 @@ func (p *Parser) parseRef(refBlock string) (map[string]string, error) { var fieldKey string var fieldVal string - firstSpace := strings.Index(field, " ") - if firstSpace > 0 { - fieldKey = field[:firstSpace] - fieldVal = field[firstSpace+1:] + before, after, ok := strings.Cut(field, " ") + if ok { + fieldKey = before + fieldVal = after } else { // could be the case if the requested field had no value fieldKey = field diff --git a/modules/git/git.go b/modules/git/git.go index 6d2c643b33..932da1989b 100644 --- a/modules/git/git.go +++ b/modules/git/git.go @@ -12,25 +12,27 @@ import ( "path/filepath" "runtime" "strings" - "time" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/tempdir" "github.com/hashicorp/go-version" ) -const RequiredVersion = "2.0.0" // the minimum Git version required +const RequiredVersion = "2.6.0" // the minimum Git version required type Features struct { gitVersion *version.Version - UsingGogit bool - SupportProcReceive bool // >= 2.29 - SupportHashSha256 bool // >= 2.42, SHA-256 repositories no longer an ‘experimental curiosity’ - SupportedObjectFormats []ObjectFormat // sha1, sha256 - SupportCheckAttrOnBare bool // >= 2.40 + UsingGogit bool + SupportProcReceive bool // >= 2.29 + SupportHashSha256 bool // >= 2.42, SHA-256 repositories no longer an ‘experimental curiosity’ + SupportedObjectFormats []ObjectFormat // sha1, sha256 + SupportCheckAttrOnBare bool // >= 2.40 + SupportCatFileBatchCommand bool // >= 2.36, support `git cat-file --batch-command` + SupportGitMergeTree bool // >= 2.40 // we also need "--merge-base" } var defaultFeatures *Features @@ -75,6 +77,8 @@ func loadGitVersionFeatures() (*Features, error) { features.SupportedObjectFormats = append(features.SupportedObjectFormats, Sha256ObjectFormat) } features.SupportCheckAttrOnBare = features.CheckVersionAtLeast("2.40") + features.SupportCatFileBatchCommand = features.CheckVersionAtLeast("2.36") + features.SupportGitMergeTree = features.CheckVersionAtLeast("2.40") // we also need "--merge-base" return features, nil } @@ -84,12 +88,17 @@ func parseGitVersionLine(s string) (*version.Version, error) { return nil, fmt.Errorf("invalid git version: %q", s) } - // version string is like: "git version 2.29.3" or "git version 2.29.3.windows.1" + // version output is like: "git version {versionString}" + // versionString can be: + // * "2.5.3" + // * "2.29.3.windows.1" + // * "2.28.0.618.gf4bc123cb7": https://github.com/go-gitea/gitea/issues/12731 versionString := fields[2] - if pos := strings.Index(versionString, "windows"); pos >= 1 { - versionString = versionString[:pos-1] + versionFields := strings.Split(versionString, ".") + if len(versionFields) > 3 { + versionFields = versionFields[:3] } - return version.NewVersion(versionString) + return version.NewVersion(strings.Join(versionFields, ".")) } func checkGitVersionCompatibility(gitVer *version.Version) error { @@ -137,10 +146,6 @@ func InitSimple() error { log.Warn("git module has been initialized already, duplicate init may work but it's better to fix it") } - if setting.Git.Timeout.Default > 0 { - gitcmd.SetDefaultCommandExecutionTimeout(time.Duration(setting.Git.Timeout.Default) * time.Second) - } - if err := gitcmd.SetExecutablePath(setting.Git.Path); err != nil { return err } @@ -176,3 +181,25 @@ func InitFull() (err error) { return syncGitConfig(context.Background()) } + +// RunGitTests helps to init the git module and run tests. +// FIXME: GIT-PACKAGE-DEPENDENCY: the dependency is not right, setting.Git.HomePath is initialized in this package but used in gitcmd package +func RunGitTests(m interface{ Run() int }) { + fatalf := func(exitCode int, format string, args ...any) { + _, _ = fmt.Fprintf(os.Stderr, format, args...) + os.Exit(exitCode) + } + gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home") + if err != nil { + fatalf(1, "unable to create temp dir: %s", err.Error()) + } + defer cleanup() + + setting.Git.HomePath = gitHomePath + if err = InitFull(); err != nil { + fatalf(1, "failed to call Init: %s", err.Error()) + } + if exitCode := m.Run(); exitCode != 0 { + fatalf(exitCode, "run test failed, ExitCode=%d", exitCode) + } +} diff --git a/modules/git/git_test.go b/modules/git/git_test.go index 7a8ca74b01..e21cbe449a 100644 --- a/modules/git/git_test.go +++ b/modules/git/git_test.go @@ -4,42 +4,14 @@ package git import ( - "fmt" - "os" "testing" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/tempdir" - "github.com/hashicorp/go-version" "github.com/stretchr/testify/assert" ) -func testRun(m *testing.M) error { - gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home") - if err != nil { - return fmt.Errorf("unable to create temp dir: %w", err) - } - defer cleanup() - - setting.Git.HomePath = gitHomePath - - if err = InitFull(); err != nil { - return fmt.Errorf("failed to call Init: %w", err) - } - - exitCode := m.Run() - if exitCode != 0 { - return fmt.Errorf("run test failed, ExitCode=%d", exitCode) - } - return nil -} - func TestMain(m *testing.M) { - if err := testRun(m); err != nil { - _, _ = fmt.Fprintf(os.Stderr, "Test failed: %v", err) - os.Exit(1) - } + RunGitTests(m) } func TestParseGitVersion(t *testing.T) { @@ -51,6 +23,10 @@ func TestParseGitVersion(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "2.29.3", v.String()) + v, err = parseGitVersionLine("git version 2.28.0.618.gf4bc123cb7") + assert.NoError(t, err) + assert.Equal(t, "2.28.0", v.String()) + _, err = parseGitVersionLine("git version") assert.Error(t, err) diff --git a/modules/git/gitcmd/command.go b/modules/git/gitcmd/command.go index ff2827bd6c..e9b51802fe 100644 --- a/modules/git/gitcmd/command.go +++ b/modules/git/gitcmd/command.go @@ -13,7 +13,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "time" @@ -29,24 +28,32 @@ import ( // In most cases, it shouldn't be used. Use AddXxx function instead type TrustedCmdArgs []internal.CmdArg -// defaultCommandExecutionTimeout default command execution timeout duration -var defaultCommandExecutionTimeout = 360 * time.Second - -func SetDefaultCommandExecutionTimeout(timeout time.Duration) { - defaultCommandExecutionTimeout = timeout -} - -// DefaultLocale is the default LC_ALL to run git commands in. -const DefaultLocale = "C" - // Command represents a command with its subcommands or arguments. type Command struct { + callerInfo string prog string args []string - brokenArgs []string - cmd *exec.Cmd // for debug purpose only + preErrors []error configArgs []string opts runOpts + + cmd *exec.Cmd + + cmdCtx context.Context + cmdCancel process.CancelCauseFunc + cmdFinished process.FinishedFunc + cmdStartTime time.Time + + parentPipeFiles []*os.File + parentPipeReaders []*os.File + childrenPipeFiles []*os.File + + // only os.Pipe and in-memory buffers can work with Stdin safely, see https://github.com/golang/go/issues/77227 if the command would exit unexpectedly + cmdStdin io.Reader + cmdStdout io.Writer + cmdStderr io.Writer + + cmdManagedStderr *bytes.Buffer } func logArgSanitize(arg string) string { @@ -97,6 +104,10 @@ func NewCommand(args ...internal.CmdArg) *Command { } } +func (c *Command) handlePreErrorBrokenCommand(arg string) { + c.preErrors = append(c.preErrors, util.ErrorWrap(ErrBrokenCommand, `broken git command argument %q`, arg)) +} + // isSafeArgumentValue checks if the argument is safe to be used as a value (not an option) func isSafeArgumentValue(s string) bool { return s == "" || s[0] != '-' @@ -124,7 +135,7 @@ func (c *Command) AddArguments(args ...internal.CmdArg) *Command { // The values are treated as dynamic argument values. It equals to: AddArguments("--opt") then AddDynamicArguments(val). func (c *Command) AddOptionValues(opt internal.CmdArg, args ...string) *Command { if !isValidArgumentOption(string(opt)) { - c.brokenArgs = append(c.brokenArgs, string(opt)) + c.handlePreErrorBrokenCommand(string(opt)) return c } c.args = append(c.args, string(opt)) @@ -136,12 +147,12 @@ func (c *Command) AddOptionValues(opt internal.CmdArg, args ...string) *Command // For example: AddOptionFormat("--opt=%s %s", val1, val2) means 1 argument: {"--opt=val1 val2"}. func (c *Command) AddOptionFormat(opt string, args ...any) *Command { if !isValidArgumentOption(opt) { - c.brokenArgs = append(c.brokenArgs, opt) + c.handlePreErrorBrokenCommand(opt) return c } // a quick check to make sure the format string matches the number of arguments, to find low-level mistakes ASAP if strings.Count(strings.ReplaceAll(opt, "%%", ""), "%") != len(args) { - c.brokenArgs = append(c.brokenArgs, opt) + c.handlePreErrorBrokenCommand(opt) return c } s := fmt.Sprintf(opt, args...) @@ -155,10 +166,10 @@ func (c *Command) AddOptionFormat(opt string, args ...any) *Command { func (c *Command) AddDynamicArguments(args ...string) *Command { for _, arg := range args { if !isSafeArgumentValue(arg) { - c.brokenArgs = append(c.brokenArgs, arg) + c.handlePreErrorBrokenCommand(arg) } } - if len(c.brokenArgs) != 0 { + if len(c.preErrors) != 0 { return c } c.args = append(c.args, args...) @@ -178,7 +189,7 @@ func (c *Command) AddDashesAndList(list ...string) *Command { func (c *Command) AddConfig(key, value string) *Command { kv := key + "=" + value if !isSafeArgumentValue(kv) { - c.brokenArgs = append(c.brokenArgs, key) + c.handlePreErrorBrokenCommand(kv) } else { c.configArgs = append(c.configArgs, "-c", kv) } @@ -195,11 +206,9 @@ func ToTrustedCmdArgs(args []string) TrustedCmdArgs { return ret } -// runOpts represents parameters to run the command. If UseContextTimeout is specified, then Timeout is ignored. type runOpts struct { - Env []string - Timeout time.Duration - UseContextTimeout bool + Env []string + Timeout time.Duration // Dir is the working dir for the git command, however: // FIXME: this could be incorrect in many cases, for example: @@ -209,21 +218,7 @@ type runOpts struct { // The correct approach is to use `--git-dir" global argument Dir string - Stdout, Stderr io.Writer - - // Stdin is used for passing input to the command - // The caller must make sure the Stdin writer is closed properly to finish the Run function. - // Otherwise, the Run function may hang for long time or forever, especially when the Git's context deadline is not the same as the caller's. - // Some common mistakes: - // * `defer stdinWriter.Close()` then call `cmd.Run()`: the Run() would never return if the command is killed by timeout - // * `go { case <- parentContext.Done(): stdinWriter.Close() }` with `cmd.Run(DefaultTimeout)`: the command would have been killed by timeout but the Run doesn't return until stdinWriter.Close() - // * `go { if stdoutReader.Read() err != nil: stdinWriter.Close() }` with `cmd.Run()`: the stdoutReader may never return error if the command is killed by timeout - // In the future, ideally the git module itself should have full control of the stdin, to avoid such problems and make it easier to refactor to a better architecture. - Stdin io.Reader - - PipelineFunc func(context.Context, context.CancelFunc) error - - callerInfo string + PipelineFunc func(Context) error } func commonBaseEnvs() []string { @@ -254,7 +249,7 @@ func commonBaseEnvs() []string { // CommonGitCmdEnvs returns the common environment variables for a "git" command. func CommonGitCmdEnvs() []string { return append(commonBaseEnvs(), []string{ - "LC_ALL=" + DefaultLocale, + "LC_ALL=C", // ensure git output is in English, error messages are parsed in English "GIT_TERMINAL_PROMPT=0", // avoid prompting for credentials interactively, supported since git v2.3 }...) } @@ -281,42 +276,106 @@ func (c *Command) WithTimeout(timeout time.Duration) *Command { return c } -func (c *Command) WithStdout(stdout io.Writer) *Command { - c.opts.Stdout = stdout +func (c *Command) makeStdoutStderr(w *io.Writer) (PipeReader, func()) { + pr, pw, err := os.Pipe() + if err != nil { + c.preErrors = append(c.preErrors, err) + return &pipeNull{err}, func() {} + } + c.childrenPipeFiles = append(c.childrenPipeFiles, pw) + c.parentPipeFiles = append(c.parentPipeFiles, pr) + c.parentPipeReaders = append(c.parentPipeReaders, pr) + *w /* stdout, stderr */ = pw + return &pipeReader{f: pr}, func() { pr.Close() } +} + +// MakeStdinPipe creates a writer for the command's stdin. +// The returned closer function must be called by the caller to close the pipe. +func (c *Command) MakeStdinPipe() (writer PipeWriter, closer func()) { + pr, pw, err := os.Pipe() + if err != nil { + c.preErrors = append(c.preErrors, err) + return &pipeNull{err}, func() {} + } + c.childrenPipeFiles = append(c.childrenPipeFiles, pr) + c.parentPipeFiles = append(c.parentPipeFiles, pw) + c.cmdStdin = pr + return &pipeWriter{pw}, func() { pw.Close() } +} + +// MakeStdoutPipe creates a reader for the command's stdout. +// The returned closer function must be called by the caller to close the pipe. +// After the pipe reader is closed, the unread data will be discarded. +// +// If the process (git command) still tries to write after the pipe is closed, the Wait error will be "signal: broken pipe". +// WithPipelineFunc + Run won't return "broken pipe" error in this case if the callback returns no error. +// But if you are calling Start / Wait family functions, you should either drain the pipe before close it, or handle the Wait error correctly. +func (c *Command) MakeStdoutPipe() (reader PipeReader, closer func()) { + return c.makeStdoutStderr(&c.cmdStdout) +} + +// MakeStderrPipe is like MakeStdoutPipe, but for stderr. +func (c *Command) MakeStderrPipe() (reader PipeReader, closer func()) { + return c.makeStdoutStderr(&c.cmdStderr) +} + +func (c *Command) MakeStdinStdoutPipe() (stdin PipeWriter, stdout PipeReader, closer func()) { + stdin, stdinClose := c.MakeStdinPipe() + stdout, stdoutClose := c.MakeStdoutPipe() + return stdin, stdout, func() { + stdinClose() + stdoutClose() + } +} + +func (c *Command) WithStdinBytes(stdin []byte) *Command { + c.cmdStdin = bytes.NewReader(stdin) return c } -func (c *Command) WithStderr(stderr io.Writer) *Command { - c.opts.Stderr = stderr +func (c *Command) WithStdoutBuffer(w PipeBufferWriter) *Command { + c.cmdStdout = w return c } -func (c *Command) WithStdin(stdin io.Reader) *Command { - c.opts.Stdin = stdin +// WithStdinCopy and WithStdoutCopy are general functions that accept any io.Reader / io.Writer. +// In this case, Golang exec.Cmd will start new internal goroutines to do io.Copy between pipes and provided Reader/Writer. +// If the reader or writer is blocked and never returns, then the io.Copy won't finish, then exec.Cmd.Wait won't return, which may cause deadlocks. +// A typical deadlock example is: +// * `r,w:=io.Pipe(); cmd.Stdin=r; defer w.Close(); cmd.Run()`: the Run() will never return because stdin reader is blocked forever and w.Close() will never be called. +// If the reader/writer won't block forever (for example: read from a file or buffer), then these functions are safe to use. +func (c *Command) WithStdinCopy(w io.Reader) *Command { + c.cmdStdin = w return c } -func (c *Command) WithPipelineFunc(f func(context.Context, context.CancelFunc) error) *Command { +func (c *Command) WithStdoutCopy(w io.Writer) *Command { + c.cmdStdout = w + return c +} + +// WithPipelineFunc sets the pipeline function for the command. +// The pipeline function will be called in the Run / Wait function after the command is started successfully. +// The function can read/write from/to the command's stdio pipes (if any). +// The pipeline function can cancel (kill) the command by calling ctx.CancelPipeline before the command finishes. +// The returned error of Run / Wait can be joined errors from the pipeline function, context cause, and command exit error. +// Caller can get the pipeline function's error (if any) by UnwrapPipelineError. +func (c *Command) WithPipelineFunc(f func(ctx Context) error) *Command { c.opts.PipelineFunc = f return c } -func (c *Command) WithUseContextTimeout(useContextTimeout bool) *Command { - c.opts.UseContextTimeout = useContextTimeout - return c -} - // WithParentCallerInfo can be used to set the caller info (usually function name) of the parent function of the caller. // For most cases, "Run" family functions can get its caller info automatically // But if you need to call "Run" family functions in a wrapper function: "FeatureFunc -> GeneralWrapperFunc -> RunXxx", // then you can to call this function in GeneralWrapperFunc to set the caller info of FeatureFunc. // The caller info can only be set once. func (c *Command) WithParentCallerInfo(optInfo ...string) *Command { - if c.opts.callerInfo != "" { + if c.callerInfo != "" { return c } if len(optInfo) > 0 { - c.opts.callerInfo = optInfo[0] + c.callerInfo = optInfo[0] return c } skip := 1 /*parent "wrap/run" functions*/ + 1 /*this function*/ @@ -325,135 +384,174 @@ func (c *Command) WithParentCallerInfo(optInfo ...string) *Command { if pos := strings.LastIndex(callerInfo, "/"); pos >= 0 { callerInfo = callerInfo[pos+1:] } - c.opts.callerInfo = callerInfo + c.callerInfo = callerInfo return c } -// Run runs the command -func (c *Command) Run(ctx context.Context) error { - if len(c.brokenArgs) != 0 { - log.Error("git command is broken: %s, broken args: %s", c.LogString(), strings.Join(c.brokenArgs, " ")) - return ErrBrokenCommand +func (c *Command) Start(ctx context.Context) (retErr error) { + if c.cmd != nil { + // this is a programming error, it will cause serious deadlock problems, so it must be fixed. + panic("git command has already been started") } - // We must not change the provided options - timeout := c.opts.Timeout - if timeout <= 0 { - timeout = defaultCommandExecutionTimeout + defer func() { + c.closePipeFiles(c.childrenPipeFiles) + if retErr != nil { + // release the pipes to avoid resource leak since the command failed to start + c.closePipeFiles(c.parentPipeFiles) + // if error occurs, we must also finish the task, otherwise, cmdFinished will be called in "Wait" function + if c.cmdFinished != nil { + c.cmdFinished() + } + } + }() + + if len(c.preErrors) != 0 { + // In most cases, such error shouldn't happen. If it happens, log it as error level with more details + err := errors.Join(c.preErrors...) + log.Error("git command: %s, error: %s", c.LogString(), err) + return err } cmdLogString := c.LogString() - if c.opts.callerInfo == "" { + if c.callerInfo == "" { c.WithParentCallerInfo() } // these logs are for debugging purposes only, so no guarantee of correctness or stability - desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.opts.callerInfo, logArgSanitize(c.opts.Dir), cmdLogString) + desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.callerInfo, logArgSanitize(c.opts.Dir), cmdLogString) log.Debug("git.Command: %s", desc) _, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun) defer span.End() - span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.opts.callerInfo) + span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.callerInfo) span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString) - var cancel context.CancelFunc - var finished context.CancelFunc - - if c.opts.UseContextTimeout { - ctx, cancel, finished = process.GetManager().AddContext(ctx, desc) + if c.opts.Timeout <= 0 { + c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContext(ctx, desc) } else { - ctx, cancel, finished = process.GetManager().AddContextTimeout(ctx, timeout, desc) + c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContextTimeout(ctx, c.opts.Timeout, desc) } - defer finished() - startTime := time.Now() + c.cmdStartTime = time.Now() - cmd := exec.CommandContext(ctx, c.prog, append(c.configArgs, c.args...)...) - c.cmd = cmd // for debug purpose only + c.cmd = exec.CommandContext(c.cmdCtx, c.prog, append(c.configArgs, c.args...)...) if c.opts.Env == nil { - cmd.Env = os.Environ() + c.cmd.Env = os.Environ() } else { - cmd.Env = c.opts.Env + c.cmd.Env = c.opts.Env } - process.SetSysProcAttribute(cmd) - cmd.Env = append(cmd.Env, CommonGitCmdEnvs()...) - cmd.Dir = c.opts.Dir - cmd.Stdout = c.opts.Stdout - cmd.Stderr = c.opts.Stderr - cmd.Stdin = c.opts.Stdin - if err := cmd.Start(); err != nil { - return err + process.SetSysProcAttribute(c.cmd) + c.cmd.Env = append(c.cmd.Env, CommonGitCmdEnvs()...) + c.cmd.Dir = c.opts.Dir + c.cmd.Stdout = c.cmdStdout + c.cmd.Stdin = c.cmdStdin + c.cmd.Stderr = c.cmdStderr + return c.cmd.Start() +} + +func (c *Command) closePipeFiles(files []*os.File) { + for _, f := range files { + _ = f.Close() } +} + +func (c *Command) discardPipeReaders(files []*os.File) { + for _, f := range files { + _, _ = io.Copy(io.Discard, f) + } +} + +func (c *Command) Wait() error { + defer func() { + // The reader in another goroutine might be still reading the stdout, so we shouldn't close the pipes here + // MakeStdoutPipe returns a closer function to force callers to close the pipe correctly + // Here we only need to mark the command as finished + c.cmdFinished() + }() if c.opts.PipelineFunc != nil { - err := c.opts.PipelineFunc(ctx, cancel) - if err != nil { - cancel() - _ = cmd.Wait() - return err + errPipeline := c.opts.PipelineFunc(&cmdContext{Context: c.cmdCtx, cmd: c}) + + if context.Cause(c.cmdCtx) == nil { + // if the context is not canceled explicitly, we need to discard the unread data, + // and wait for the command to exit normally, and then get its exit code + c.discardPipeReaders(c.parentPipeReaders) + } // else: canceled command will be killed, and the exit code is caused by kill + + // after the pipeline function returns, we can safely close the pipes, then wait for the command to exit + c.closePipeFiles(c.parentPipeFiles) + errWait := c.cmd.Wait() + errCause := context.Cause(c.cmdCtx) // in case the cause is set during Wait(), get the final cancel cause + + if unwrapped, ok := UnwrapPipelineError(errCause); ok { + if unwrapped != errPipeline { + panic("unwrapped context pipeline error should be the same one returned by pipeline function") + } + if unwrapped == nil { + // the pipeline function declares that there is no error, and it cancels (kills) the command ahead, + // so we should ignore the errors from "wait" and "cause" + errWait, errCause = nil, nil + } } + + // some legacy code still need to access the error returned by pipeline function by "==" but not "errors.Is" + // so we need to make sure the original error is able to be unwrapped by UnwrapPipelineError + return errors.Join(wrapPipelineError(errPipeline), errCause, errWait) } - err := cmd.Wait() - elapsed := time.Since(startTime) + // there might be other goroutines using the context or pipes, so we just wait for the command to finish + errWait := c.cmd.Wait() + elapsed := time.Since(c.cmdStartTime) if elapsed > time.Second { - log.Debug("slow git.Command.Run: %s (%s)", c, elapsed) + log.Debug("slow git.Command.Run: %s (%s)", c, elapsed) // TODO: no need to log this for long-running commands } - // We need to check if the context is canceled by the program on Windows. - // This is because Windows does not have signal checking when terminating the process. - // It always returns exit code 1, unlike Linux, which has many exit codes for signals. - // `err.Error()` returns "exit status 1" when using the `git check-attr` command after the context is canceled. - if runtime.GOOS == "windows" && - err != nil && - (err.Error() == "" || err.Error() == "exit status 1") && - cmd.ProcessState.ExitCode() == 1 && - ctx.Err() == context.Canceled { - return ctx.Err() - } + // Here the logic is different from "PipelineFunc" case, + // because PipelineFunc can return error if it fails, it knows whether it succeeds or fails. + // But in normal case, the caller just runs the git command, the command's exit code is the source of truth. + // If the caller need to know whether the command error is caused by cancellation, it should check the "err" by itself. + errCause := context.Cause(c.cmdCtx) + return errors.Join(errCause, errWait) +} - if err != nil && ctx.Err() != context.DeadlineExceeded { +func (c *Command) StartWithStderr(ctx context.Context) RunStdError { + if c.cmdStderr != nil { + panic("caller-provided stderr receiver doesn't work with managed stderr buffer") + } + c.cmdManagedStderr = &bytes.Buffer{} + c.cmdStderr = c.cmdManagedStderr + err := c.Start(ctx) + if err != nil { + return &runStdError{err: err} + } + return nil +} + +func (c *Command) WaitWithStderr() RunStdError { + if c.cmdManagedStderr == nil { + panic("managed stderr buffer is not initialized") + } + errWait := c.Wait() + if errWait == nil { + // if no exec error but only stderr output, the stderr output is still saved in "c.cmdManagedStderr" and can be read later + return nil + } + return &runStdError{err: errWait, stderr: util.UnsafeBytesToString(c.cmdManagedStderr.Bytes())} +} + +func (c *Command) RunWithStderr(ctx context.Context) RunStdError { + if err := c.StartWithStderr(ctx); err != nil { + return &runStdError{err: err} + } + return c.WaitWithStderr() +} + +func (c *Command) Run(ctx context.Context) (err error) { + if err = c.Start(ctx); err != nil { return err } - - return ctx.Err() -} - -type RunStdError interface { - error - Unwrap() error - Stderr() string -} - -type runStdError struct { - err error - stderr string - errMsg string -} - -func (r *runStdError) Error() string { - // FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message - // But a lof of code only checks `strings.Contains(err.Error(), "git error")` - if r.errMsg == "" { - r.errMsg = ConcatenateError(r.err, r.stderr).Error() - } - return r.errMsg -} - -func (r *runStdError) Unwrap() error { - return r.err -} - -func (r *runStdError) Stderr() string { - return r.stderr -} - -func IsErrorExitCode(err error, code int) bool { - var exitError *exec.ExitError - if errors.As(err, &exitError) { - return exitError.ExitCode() == code - } - return false + return c.Wait() } // RunStdString runs the command and returns stdout/stderr as string. and store stderr to returned error (err combined with stderr). @@ -467,22 +565,16 @@ func (c *Command) RunStdBytes(ctx context.Context) (stdout, stderr []byte, runEr return c.WithParentCallerInfo().runStdBytes(ctx) } -func (c *Command) runStdBytes(ctx context.Context) ( /*stdout*/ []byte /*stderr*/, []byte /*runErr*/, RunStdError) { - if c.opts.Stdout != nil || c.opts.Stderr != nil { - // we must panic here, otherwise there would be bugs if developers set Stdin/Stderr by mistake, and it would be very difficult to debug +func (c *Command) runStdBytes(ctx context.Context) ([]byte, []byte, RunStdError) { + if c.cmdStdout != nil || c.cmdStderr != nil { + // it must panic here, otherwise there would be bugs if developers set other Stdin/Stderr by mistake, and it would be very difficult to debug panic("stdout and stderr field must be nil when using RunStdBytes") } stdoutBuf := &bytes.Buffer{} - stderrBuf := &bytes.Buffer{} - err := c.WithParentCallerInfo(). - WithStdout(stdoutBuf). - WithStderr(stderrBuf). - Run(ctx) - if err != nil { - // FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message - // But a lot of code depends on it, so we have to keep this behavior - return nil, stderrBuf.Bytes(), &runStdError{err: err, stderr: util.UnsafeBytesToString(stderrBuf.Bytes())} - } - // even if there is no err, there could still be some stderr output - return stdoutBuf.Bytes(), stderrBuf.Bytes(), nil + err := c.WithParentCallerInfo().WithStdoutBuffer(stdoutBuf).RunWithStderr(ctx) + return stdoutBuf.Bytes(), c.cmdManagedStderr.Bytes(), err +} + +func (c *Command) DebugKill() { + _ = c.cmd.Process.Kill() } diff --git a/modules/git/gitcmd/command_race_test.go b/modules/git/gitcmd/command_race_test.go deleted file mode 100644 index c2f0b124a2..0000000000 --- a/modules/git/gitcmd/command_race_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2017 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build race - -package gitcmd - -import ( - "context" - "testing" - "time" -) - -func TestRunWithContextNoTimeout(t *testing.T) { - maxLoops := 10 - - // 'git --version' does not block so it must be finished before the timeout triggered. - cmd := NewCommand("--version") - for i := 0; i < maxLoops; i++ { - if err := cmd.Run(t.Context()); err != nil { - t.Fatal(err) - } - } -} - -func TestRunWithContextTimeout(t *testing.T) { - maxLoops := 10 - - // 'git hash-object --stdin' blocks on stdin so we can have the timeout triggered. - cmd := NewCommand("hash-object", "--stdin") - for i := 0; i < maxLoops; i++ { - if err := cmd.WithTimeout(1 * time.Millisecond).Run(t.Context()); err != nil { - if err != context.DeadlineExceeded { - t.Fatalf("Testing %d/%d: %v", i, maxLoops, err) - } - } - } -} diff --git a/modules/git/gitcmd/command_test.go b/modules/git/gitcmd/command_test.go index 1ba8b2e3e4..86771f499f 100644 --- a/modules/git/gitcmd/command_test.go +++ b/modules/git/gitcmd/command_test.go @@ -4,17 +4,22 @@ package gitcmd import ( + "context" "fmt" "os" "testing" + "time" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/tempdir" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMain(m *testing.M) { + // FIXME: GIT-PACKAGE-DEPENDENCY: the dependency is not right. + // "setting.Git.HomePath" is initialized in "git" package but really used in "gitcmd" package gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home") if err != nil { _, _ = fmt.Fprintf(os.Stderr, "unable to create temp dir: %v", err) @@ -42,7 +47,7 @@ func TestRunWithContextStd(t *testing.T) { assert.Equal(t, stderr, err.Stderr()) assert.Equal(t, "fatal: Not a valid object name no-such\n", err.Stderr()) // FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message - assert.Equal(t, "exit status 128 - fatal: Not a valid object name no-such\n", err.Error()) + assert.Equal(t, "exit status 128 - fatal: Not a valid object name no-such", err.Error()) assert.Empty(t, stdout) } } @@ -54,7 +59,7 @@ func TestRunWithContextStd(t *testing.T) { assert.Equal(t, string(stderr), err.Stderr()) assert.Equal(t, "fatal: Not a valid object name no-such\n", err.Stderr()) // FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message - assert.Equal(t, "exit status 128 - fatal: Not a valid object name no-such\n", err.Error()) + assert.Equal(t, "exit status 128 - fatal: Not a valid object name no-such", err.Error()) assert.Empty(t, stdout) } } @@ -97,3 +102,29 @@ func TestCommandString(t *testing.T) { cmd = NewCommand("url: https://a:b@c/", "/root/dir-a/dir-b") assert.Equal(t, cmd.prog+` "url: https://sanitized-credential@c/" .../dir-a/dir-b`, cmd.LogString()) } + +func TestRunStdError(t *testing.T) { + e := &runStdError{stderr: "some error"} + var err RunStdError = e + + var asErr RunStdError + require.ErrorAs(t, err, &asErr) + require.Equal(t, "some error", asErr.Stderr()) + + require.ErrorAs(t, fmt.Errorf("wrapped %w", err), &asErr) +} + +func TestRunWithContextTimeout(t *testing.T) { + t.Run("NoTimeout", func(t *testing.T) { + // 'git --version' does not block so it must be finished before the timeout triggered. + err := NewCommand("--version").Run(t.Context()) + require.NoError(t, err) + }) + t.Run("WithTimeout", func(t *testing.T) { + cmd := NewCommand("hash-object", "--stdin") + _, _, pipeClose := cmd.MakeStdinStdoutPipe() + defer pipeClose() + err := cmd.WithTimeout(1 * time.Millisecond).Run(t.Context()) + require.ErrorIs(t, err, context.DeadlineExceeded) + }) +} diff --git a/modules/git/gitcmd/context.go b/modules/git/gitcmd/context.go new file mode 100644 index 0000000000..a32f92ff3a --- /dev/null +++ b/modules/git/gitcmd/context.go @@ -0,0 +1,32 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitcmd + +import ( + "context" +) + +type Context interface { + context.Context + + // CancelPipeline is a helper function to cancel the command context (kill the command) with a specific error cause, + // it returns the same error for convenience to break the PipelineFunc easily + CancelPipeline(err error) error + + // In the future, this interface will be extended to support stdio pipe readers/writers +} + +type cmdContext struct { + context.Context + cmd *Command +} + +func (c *cmdContext) CancelPipeline(err error) error { + // pipelineError is used to distinguish between: + // * context canceled by pipeline caller with/without error (normal cancellation) + // * context canceled by parent context (still context.Canceled error) + // * other causes + c.cmd.cmdCancel(pipelineError{err}) + return err +} diff --git a/modules/git/gitcmd/error.go b/modules/git/gitcmd/error.go new file mode 100644 index 0000000000..066b37f10d --- /dev/null +++ b/modules/git/gitcmd/error.go @@ -0,0 +1,101 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitcmd + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strings" +) + +type RunStdError interface { + error + Unwrap() error + Stderr() string +} + +type runStdError struct { + err error // usually the low-level error like `*exec.ExitError` + stderr string // git command's stderr output + errMsg string // the cached error message for Error() method +} + +func (r *runStdError) Error() string { + // FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message + // But a lot of code only checks `strings.Contains(err.Error(), "git error")` + if r.errMsg == "" { + r.errMsg = fmt.Sprintf("%s - %s", r.err.Error(), strings.TrimSpace(r.stderr)) + } + return r.errMsg +} + +func (r *runStdError) Unwrap() error { + return r.err +} + +func (r *runStdError) Stderr() string { + return r.stderr +} + +func ErrorAsStderr(err error) (string, bool) { + var runErr RunStdError + if errors.As(err, &runErr) { + return runErr.Stderr(), true + } + return "", false +} + +func StderrHasPrefix(err error, prefix string) bool { + stderr, ok := ErrorAsStderr(err) + if !ok { + return false + } + return strings.HasPrefix(stderr, prefix) +} + +func IsErrorExitCode(err error, code int) bool { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return exitError.ExitCode() == code + } + return false +} + +func IsErrorSignalKilled(err error) bool { + var exitError *exec.ExitError + return errors.As(err, &exitError) && exitError.String() == "signal: killed" +} + +func IsErrorCanceledOrKilled(err error) bool { + // When "cancel()" a git command's context, the returned error of "Run()" could be one of them: + // - context.Canceled + // - *exec.ExitError: "signal: killed" + // TODO: in the future, we need to use unified error type from gitcmd.Run to check whether it is manually canceled + return errors.Is(err, context.Canceled) || IsErrorSignalKilled(err) +} + +type pipelineError struct { + error +} + +func (e pipelineError) Unwrap() error { + return e.error +} + +func wrapPipelineError(err error) error { + if err == nil { + return nil + } + return pipelineError{err} +} + +func UnwrapPipelineError(err error) (error, bool) { //nolint:revive // this is for error unwrapping + var pe pipelineError + if errors.As(err, &pe) { + return pe.error, true + } + return nil, false +} diff --git a/modules/git/gitcmd/pipe.go b/modules/git/gitcmd/pipe.go new file mode 100644 index 0000000000..d0ce3e2dc6 --- /dev/null +++ b/modules/git/gitcmd/pipe.go @@ -0,0 +1,87 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitcmd + +import ( + "io" + "os" +) + +type PipeBufferReader interface { + // Read should be used in the same goroutine as command's Wait + // When Reader in one goroutine, command's Wait in another goroutine, then the command exits, the pipe will be closed: + // * If the Reader goroutine reads faster, it will read all remaining data and then get io.EOF + // * But this io.EOF doesn't mean the Reader has gotten complete data, the data might still be corrupted + // * If the Reader goroutine reads slower, it will get os.ErrClosed because the os.Pipe is closed ahead when the command exits + // + // When using 2 goroutines, no clear solution to distinguish these two cases or make Reader knows whether the data is complete + // It should avoid using Reader in a different goroutine than the command if the Read error needs to be handled. + Read(p []byte) (n int, err error) + Bytes() []byte +} + +type PipeBufferWriter interface { + Write(p []byte) (n int, err error) + Bytes() []byte +} + +type PipeReader interface { + io.ReadCloser + internalOnly() +} + +type pipeReader struct { + f *os.File +} + +func (r *pipeReader) internalOnly() {} + +func (r *pipeReader) Read(p []byte) (n int, err error) { + return r.f.Read(p) +} + +func (r *pipeReader) Close() error { + return r.f.Close() +} + +type PipeWriter interface { + io.WriteCloser + internalOnly() +} + +type pipeWriter struct { + f *os.File +} + +func (w *pipeWriter) internalOnly() {} + +func (w *pipeWriter) Close() error { + return w.f.Close() +} + +func (w *pipeWriter) Write(p []byte) (n int, err error) { + return w.f.Write(p) +} + +func (w *pipeWriter) DrainBeforeClose() error { + return nil +} + +type pipeNull struct { + err error +} + +func (p *pipeNull) internalOnly() {} + +func (p *pipeNull) Read([]byte) (n int, err error) { + return 0, p.err +} + +func (p *pipeNull) Write([]byte) (n int, err error) { + return 0, p.err +} + +func (p *pipeNull) Close() error { + return nil +} diff --git a/modules/git/gitcmd/utils.go b/modules/git/gitcmd/utils.go deleted file mode 100644 index ee24eb6a9a..0000000000 --- a/modules/git/gitcmd/utils.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package gitcmd - -import "fmt" - -// ConcatenateError concatenats an error with stderr string -func ConcatenateError(err error, stderr string) error { - if len(stderr) == 0 { - return err - } - return fmt.Errorf("%w - %s", err, stderr) -} diff --git a/modules/git/gpg.go b/modules/git/gpg.go new file mode 100644 index 0000000000..dbc5569309 --- /dev/null +++ b/modules/git/gpg.go @@ -0,0 +1,102 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/process" +) + +// GPGSettings represents the default GPG settings for this repository +type GPGSettings struct { + Sign bool + KeyID string + Email string + Name string + PublicKeyContent string + Format string +} + +// LoadPublicKeyContent will load the key from gpg +func (gpgSettings *GPGSettings) LoadPublicKeyContent() error { + if gpgSettings.PublicKeyContent != "" { + return nil + } + + if gpgSettings.Format == SigningKeyFormatSSH { + content, err := os.ReadFile(gpgSettings.KeyID) + if err != nil { + return fmt.Errorf("unable to read SSH public key file: %s, %w", gpgSettings.KeyID, err) + } + gpgSettings.PublicKeyContent = string(content) + return nil + } + content, stderr, err := process.GetManager().Exec( + "gpg -a --export", + "gpg", "-a", "--export", gpgSettings.KeyID) + if err != nil { + return fmt.Errorf("unable to get default signing key: %s, %s, %w", gpgSettings.KeyID, stderr, err) + } + gpgSettings.PublicKeyContent = content + return nil +} + +var ( + loadPublicGPGKeyMutex sync.RWMutex + globalGPGSettings *GPGSettings +) + +// GetDefaultPublicGPGKey will return and cache the default public GPG settings +func GetDefaultPublicGPGKey(ctx context.Context, forceUpdate bool) (*GPGSettings, error) { + if !forceUpdate { + loadPublicGPGKeyMutex.RLock() + if globalGPGSettings != nil { + defer loadPublicGPGKeyMutex.RUnlock() + return globalGPGSettings, nil + } + loadPublicGPGKeyMutex.RUnlock() + } + + loadPublicGPGKeyMutex.Lock() + defer loadPublicGPGKeyMutex.Unlock() + + if globalGPGSettings != nil && !forceUpdate { + return globalGPGSettings, nil + } + + globalGPGSettings = &GPGSettings{ + Sign: true, + } + + value, _, _ := gitcmd.NewCommand("config", "--global", "--get", "commit.gpgsign").RunStdString(ctx) + sign, valid := ParseBool(strings.TrimSpace(value)) + if !sign || !valid { + globalGPGSettings.Sign = false + return globalGPGSettings, nil + } + + signingKey, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.signingkey").RunStdString(ctx) + globalGPGSettings.KeyID = strings.TrimSpace(signingKey) + + format, _, _ := gitcmd.NewCommand("config", "--global", "--default", SigningKeyFormatOpenPGP, "--get", "gpg.format").RunStdString(ctx) + globalGPGSettings.Format = strings.TrimSpace(format) + + defaultEmail, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.email").RunStdString(ctx) + globalGPGSettings.Email = strings.TrimSpace(defaultEmail) + + defaultName, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.name").RunStdString(ctx) + globalGPGSettings.Name = strings.TrimSpace(defaultName) + + if err := globalGPGSettings.LoadPublicKeyContent(); err != nil { + return nil, err + } + return globalGPGSettings, nil +} diff --git a/modules/git/grep.go b/modules/git/grep.go index ed69a788a4..051a7a1d40 100644 --- a/modules/git/grep.go +++ b/modules/git/grep.go @@ -5,11 +5,9 @@ package git import ( "bufio" - "bytes" "context" "errors" "fmt" - "os" "slices" "strconv" "strings" @@ -42,15 +40,6 @@ type GrepOptions struct { } func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepOptions) ([]*GrepResult, error) { - stdoutReader, stdoutWriter, err := os.Pipe() - if err != nil { - return nil, fmt.Errorf("unable to create os pipe to grep: %w", err) - } - defer func() { - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - }() - /* The output is like this ( "^@" means \x00): @@ -83,14 +72,11 @@ func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepO cmd.AddDynamicArguments(util.IfZero(opts.RefName, "HEAD")) cmd.AddDashesAndList(opts.PathspecList...) opts.MaxResultLimit = util.IfZero(opts.MaxResultLimit, 50) - stderr := bytes.Buffer{} - err = cmd.WithDir(repo.Path). - WithStdout(stdoutWriter). - WithStderr(&stderr). - WithPipelineFunc(func(ctx context.Context, cancel context.CancelFunc) error { - _ = stdoutWriter.Close() - defer stdoutReader.Close() + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + defer stdoutReaderClose() + err := cmd.WithDir(repo.Path). + WithPipelineFunc(func(ctx gitcmd.Context) error { isInBlock := false rd := bufio.NewReaderSize(stdoutReader, util.IfZero(opts.MaxLineLength, 16*1024)) var res *GrepResult @@ -116,8 +102,7 @@ func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepO } if line == "" { if len(results) >= opts.MaxResultLimit { - cancel() - break + return ctx.CancelPipeline(nil) } isInBlock = false continue @@ -133,17 +118,17 @@ func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepO } return nil }). - Run(ctx) + RunWithStderr(ctx) // git grep exits by cancel (killed), usually it is caused by the limit of results - if gitcmd.IsErrorExitCode(err, -1) && stderr.Len() == 0 { + if gitcmd.IsErrorExitCode(err, -1) && err.Stderr() == "" { return results, nil } // git grep exits with 1 if no results are found - if gitcmd.IsErrorExitCode(err, 1) && stderr.Len() == 0 { + if gitcmd.IsErrorExitCode(err, 1) && err.Stderr() == "" { return nil, nil } if err != nil && !errors.Is(err, context.Canceled) { - return nil, fmt.Errorf("unable to run git grep: %w, stderr: %s", err, stderr.String()) + return nil, fmt.Errorf("unable to run git grep: %w", err) } return results, nil } diff --git a/modules/git/hook.go b/modules/git/hook.go index 361aa53100..0e19387d97 100644 --- a/modules/git/hook.go +++ b/modules/git/hook.go @@ -47,30 +47,16 @@ func GetHook(repoPath, name string) (*Hook, error) { name: name, path: filepath.Join(repoPath, filepath.Join("hooks", name+".d", name)), } - isFile, err := util.IsFile(h.path) - if err != nil { - return nil, err - } - if isFile { - data, err := os.ReadFile(h.path) - if err != nil { - return nil, err - } + if data, err := os.ReadFile(h.path); err == nil { h.IsActive = true h.Content = string(data) return h, nil + } else if !os.IsNotExist(err) { + return nil, err } samplePath := filepath.Join(repoPath, "hooks", name+".sample") - isFile, err = util.IsFile(samplePath) - if err != nil { - return nil, err - } - if isFile { - data, err := os.ReadFile(samplePath) - if err != nil { - return nil, err - } + if data, err := os.ReadFile(samplePath); err == nil { h.Sample = string(data) } return h, nil diff --git a/modules/git/key.go b/modules/git/key.go index 8c14742f34..9d51704595 100644 --- a/modules/git/key.go +++ b/modules/git/key.go @@ -3,7 +3,13 @@ package git -import "code.gitea.io/gitea/modules/setting" +import ( + "context" + "strings" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/setting" +) // Based on https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgformat const ( @@ -24,3 +30,48 @@ func (s *SigningKey) String() string { setting.PanicInDevOrTesting("don't call SigningKey.String() - it exposes the KeyID which might be a local file path") return "SigningKey:" + s.Format } + +// GetSigningKey returns the KeyID and git Signature for the repo +func GetSigningKey(ctx context.Context) (*SigningKey, *Signature) { + if setting.Repository.Signing.SigningKey == "none" { + return nil, nil + } + + if setting.Repository.Signing.SigningKey == "default" || setting.Repository.Signing.SigningKey == "" { + // Can ignore the error here as it means that commit.gpgsign is not set + value, _, _ := gitcmd.NewCommand("config", "--global", "--get", "commit.gpgsign").RunStdString(ctx) + sign, valid := ParseBool(strings.TrimSpace(value)) + if !sign || !valid { + return nil, nil + } + + format, _, _ := gitcmd.NewCommand("config", "--global", "--default", SigningKeyFormatOpenPGP, "--get", "gpg.format").RunStdString(ctx) + signingKey, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.signingkey").RunStdString(ctx) + signingName, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.name").RunStdString(ctx) + signingEmail, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.email").RunStdString(ctx) + + if strings.TrimSpace(signingKey) == "" { + return nil, nil + } + + return &SigningKey{ + KeyID: strings.TrimSpace(signingKey), + Format: strings.TrimSpace(format), + }, &Signature{ + Name: strings.TrimSpace(signingName), + Email: strings.TrimSpace(signingEmail), + } + } + + if setting.Repository.Signing.SigningKey == "" { + return nil, nil + } + + return &SigningKey{ + KeyID: setting.Repository.Signing.SigningKey, + Format: setting.Repository.Signing.SigningFormat, + }, &Signature{ + Name: setting.Repository.Signing.SigningName, + Email: setting.Repository.Signing.SigningEmail, + } +} diff --git a/modules/git/languagestats/language_stats_nogogit.go b/modules/git/languagestats/language_stats_nogogit.go index 94cf9fff8c..1dbf184af6 100644 --- a/modules/git/languagestats/language_stats_nogogit.go +++ b/modules/git/languagestats/language_stats_nogogit.go @@ -22,33 +22,28 @@ import ( func GetLanguageStats(repo *git.Repository, commitID string) (map[string]int64, error) { // We will feed the commit IDs in order into cat-file --batch, followed by blobs as necessary. // so let's create a batch stdin and stdout - batchStdinWriter, batchReader, cancel, err := repo.CatFileBatch(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return nil, err } defer cancel() - writeID := func(id string) error { - _, err := batchStdinWriter.Write([]byte(id + "\n")) - return err - } - - if err := writeID(commitID); err != nil { + commitInfo, batchReader, err := batch.QueryContent(commitID) + if err != nil { return nil, err } - shaBytes, typ, size, err := git.ReadBatchLine(batchReader) - if typ != "commit" { + if commitInfo.Type != "commit" { log.Debug("Unable to get commit for: %s. Err: %v", commitID, err) return nil, git.ErrNotExist{ID: commitID} } - sha, err := git.NewIDFromString(string(shaBytes)) + sha, err := git.NewIDFromString(commitInfo.ID) if err != nil { log.Debug("Unable to get commit for: %s. Err: %v", commitID, err) return nil, git.ErrNotExist{ID: commitID} } - commit, err := git.CommitFromReader(repo, sha, io.LimitReader(batchReader, size)) + commit, err := git.CommitFromReader(repo, sha, io.LimitReader(batchReader, commitInfo.Size)) if err != nil { log.Debug("Unable to get commit for: %s. Err: %v", commitID, err) return nil, err @@ -144,20 +139,16 @@ func GetLanguageStats(repo *git.Repository, commitID string) (map[string]int64, // If content can not be read or file is too big just do detection by filename if f.Size() <= bigFileSize { - if err := writeID(f.ID.String()); err != nil { - return nil, err - } - _, _, size, err := git.ReadBatchLine(batchReader) + info, _, err := batch.QueryContent(f.ID.String()) if err != nil { - log.Debug("Error reading blob: %s Err: %v", f.ID.String(), err) return nil, err } - sizeToRead := size + sizeToRead := info.Size discard := int64(1) - if size > fileSizeLimit { + if info.Size > fileSizeLimit { sizeToRead = fileSizeLimit - discard = size - fileSizeLimit + 1 + discard = info.Size - fileSizeLimit + 1 } _, err = contentBuf.ReadFrom(io.LimitReader(batchReader, sizeToRead)) diff --git a/modules/git/languagestats/main_test.go b/modules/git/languagestats/main_test.go index b8f9ded005..bf860f2a18 100644 --- a/modules/git/languagestats/main_test.go +++ b/modules/git/languagestats/main_test.go @@ -4,37 +4,11 @@ package languagestats import ( - "fmt" - "os" "testing" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" ) -func testRun(m *testing.M) error { - gitHomePath, err := os.MkdirTemp(os.TempDir(), "git-home") - if err != nil { - return fmt.Errorf("unable to create temp dir: %w", err) - } - defer util.RemoveAll(gitHomePath) - setting.Git.HomePath = gitHomePath - - if err = git.InitFull(); err != nil { - return fmt.Errorf("failed to call Init: %w", err) - } - - exitCode := m.Run() - if exitCode != 0 { - return fmt.Errorf("run test failed, ExitCode=%d", exitCode) - } - return nil -} - func TestMain(m *testing.M) { - if err := testRun(m); err != nil { - _, _ = fmt.Fprintf(os.Stderr, "Test failed: %v", err) - os.Exit(1) - } + git.RunGitTests(m) } diff --git a/modules/git/log_name_status.go b/modules/git/log_name_status.go index 72e513000b..8acfc96f26 100644 --- a/modules/git/log_name_status.go +++ b/modules/git/log_name_status.go @@ -15,25 +15,12 @@ import ( "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git/gitcmd" - - "github.com/djherbis/buffer" - "github.com/djherbis/nio/v3" + "code.gitea.io/gitea/modules/log" ) // LogNameStatusRepo opens git log --raw in the provided repo and returns a stdin pipe, a stdout reader and cancel function func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) (*bufio.Reader, func()) { - // We often want to feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary. - // so let's create a batch stdin and stdout - stdoutReader, stdoutWriter := nio.Pipe(buffer.New(32 * 1024)) - // Lets also create a context so that we can absolutely ensure that the command should die when we're done - ctx, ctxCancel := context.WithCancel(ctx) - - cancel := func() { - ctxCancel() - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - } cmd := gitcmd.NewCommand() cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head) @@ -63,24 +50,21 @@ func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, p } cmd.AddDashesAndList(files...) + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + ctx, ctxCancel := context.WithCancel(ctx) go func() { - stderr := strings.Builder{} - err := cmd.WithDir(repository). - WithStdout(stdoutWriter). - WithStderr(&stderr). - Run(ctx) - if err != nil { - _ = stdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String())) - return + err := cmd.WithDir(repository).RunWithStderr(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + log.Error("Unable to run git command %v: %v", cmd.LogString(), err) } - - _ = stdoutWriter.Close() }() - // For simplicities sake we'll us a buffered reader to read from the cat-file --batch bufReader := bufio.NewReaderSize(stdoutReader, 32*1024) - return bufReader, cancel + return bufReader, func() { + ctxCancel() + stdoutReaderClose() + } } // LogNameStatusRepoParser parses a git log raw output from LogRawRepo diff --git a/modules/git/notes_gogit.go b/modules/git/notes_gogit.go index f802443b00..340f4d5ccc 100644 --- a/modules/git/notes_gogit.go +++ b/modules/git/notes_gogit.go @@ -7,6 +7,7 @@ package git import ( "context" + "fmt" "io" "code.gitea.io/gitea/modules/log" @@ -30,7 +31,11 @@ func GetNote(ctx context.Context, repo *Repository, commitID string, note *Note) remainingCommitID := commitID path := "" - currentTree := notes.Tree.gogitTree + currentTree, err := notes.Tree.gogitTreeObject() + if err != nil { + return fmt.Errorf("unable to get tree object for notes commit %q: %w", notes.ID.String(), err) + } + log.Trace("Found tree with ID %q while searching for git note corresponding to the commit %q", currentTree.Entries[0].Name, commitID) var file *object.File for len(remainingCommitID) > 2 { diff --git a/modules/git/notes_test.go b/modules/git/notes_test.go index 7db2dbc0b9..5abb68b102 100644 --- a/modules/git/notes_test.go +++ b/modules/git/notes_test.go @@ -47,5 +47,5 @@ func TestGetNonExistentNotes(t *testing.T) { note := Note{} err = GetNote(t.Context(), bareRepo1, "non_existent_sha", ¬e) assert.Error(t, err) - assert.IsType(t, ErrNotExist{}, err) + assert.ErrorAs(t, err, &ErrNotExist{}) } diff --git a/modules/git/parse.go b/modules/git/parse.go index a7f5c58e89..94020e690d 100644 --- a/modules/git/parse.go +++ b/modules/git/parse.go @@ -27,15 +27,15 @@ func parseLsTreeLine(line []byte) (*LsTreeEntry, error) { // \t var err error - posTab := bytes.IndexByte(line, '\t') - if posTab == -1 { + before, after, ok := bytes.Cut(line, []byte{'\t'}) + if !ok { return nil, fmt.Errorf("invalid ls-tree output (no tab): %q", line) } entry := new(LsTreeEntry) - entryAttrs := line[:posTab] - entryName := line[posTab+1:] + entryAttrs := before + entryName := after entryMode, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace) _ /* entryType */, entryAttrs, _ = bytes.Cut(entryAttrs, sepSpace) // the type is not used, the mode is enough to determine the type @@ -46,8 +46,8 @@ func parseLsTreeLine(line []byte) (*LsTreeEntry, error) { entry.Size = optional.Some(size) } - entry.EntryMode, err = ParseEntryMode(string(entryMode)) - if err != nil || entry.EntryMode == EntryModeNoEntry { + entry.EntryMode = ParseEntryMode(string(entryMode)) + if entry.EntryMode == EntryModeNoEntry { return nil, fmt.Errorf("invalid ls-tree output (invalid mode): %q, err: %w", line, err) } diff --git a/modules/git/parse_gogit.go b/modules/git/parse_gogit.go deleted file mode 100644 index 74d258de8e..0000000000 --- a/modules/git/parse_gogit.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2018 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build gogit - -package git - -import ( - "bytes" - "fmt" - "strconv" - "strings" - - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/filemode" - "github.com/go-git/go-git/v5/plumbing/hash" - "github.com/go-git/go-git/v5/plumbing/object" -) - -// ParseTreeEntries parses the output of a `git ls-tree -l` command. -func ParseTreeEntries(data []byte) ([]*TreeEntry, error) { - return parseTreeEntries(data, nil) -} - -func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) { - entries := make([]*TreeEntry, 0, 10) - for pos := 0; pos < len(data); { - // expect line to be of the form " \t" - entry := new(TreeEntry) - entry.gogitTreeEntry = &object.TreeEntry{} - entry.ptree = ptree - if pos+6 > len(data) { - return nil, fmt.Errorf("Invalid ls-tree output: %s", string(data)) - } - switch string(data[pos : pos+6]) { - case "100644": - entry.gogitTreeEntry.Mode = filemode.Regular - pos += 12 // skip over "100644 blob " - case "100755": - entry.gogitTreeEntry.Mode = filemode.Executable - pos += 12 // skip over "100755 blob " - case "120000": - entry.gogitTreeEntry.Mode = filemode.Symlink - pos += 12 // skip over "120000 blob " - case "160000": - entry.gogitTreeEntry.Mode = filemode.Submodule - pos += 14 // skip over "160000 object " - case "040000": - entry.gogitTreeEntry.Mode = filemode.Dir - pos += 12 // skip over "040000 tree " - default: - return nil, fmt.Errorf("unknown type: %v", string(data[pos:pos+6])) - } - - // in hex format, not byte format .... - if pos+hash.Size*2 > len(data) { - return nil, fmt.Errorf("Invalid ls-tree output: %s", string(data)) - } - var err error - entry.ID, err = NewIDFromString(string(data[pos : pos+hash.Size*2])) - if err != nil { - return nil, fmt.Errorf("invalid ls-tree output: %w", err) - } - entry.gogitTreeEntry.Hash = plumbing.Hash(entry.ID.RawValue()) - pos += 41 // skip over sha and trailing space - - end := pos + bytes.IndexByte(data[pos:], '\t') - if end < pos { - return nil, fmt.Errorf("Invalid ls-tree -l output: %s", string(data)) - } - entry.size, _ = strconv.ParseInt(strings.TrimSpace(string(data[pos:end])), 10, 64) - entry.sized = true - - pos = end + 1 - - end = pos + bytes.IndexByte(data[pos:], '\n') - if end < pos { - return nil, fmt.Errorf("Invalid ls-tree output: %s", string(data)) - } - - // In case entry name is surrounded by double quotes(it happens only in git-shell). - if data[pos] == '"' { - var err error - entry.gogitTreeEntry.Name, err = strconv.Unquote(string(data[pos:end])) - if err != nil { - return nil, fmt.Errorf("Invalid ls-tree output: %w", err) - } - } else { - entry.gogitTreeEntry.Name = string(data[pos:end]) - } - - pos = end + 1 - entries = append(entries, entry) - } - return entries, nil -} diff --git a/modules/git/parse_gogit_test.go b/modules/git/parse_gogit_test.go deleted file mode 100644 index 3e171d7e56..0000000000 --- a/modules/git/parse_gogit_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2018 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build gogit - -package git - -import ( - "fmt" - "testing" - - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/filemode" - "github.com/go-git/go-git/v5/plumbing/object" - "github.com/stretchr/testify/assert" -) - -func TestParseTreeEntries(t *testing.T) { - testCases := []struct { - Input string - Expected []*TreeEntry - }{ - { - Input: "", - Expected: []*TreeEntry{}, - }, - { - Input: "100644 blob 61ab7345a1a3bbc590068ccae37b8515cfc5843c 1022\texample/file2.txt\n", - Expected: []*TreeEntry{ - { - ID: MustIDFromString("61ab7345a1a3bbc590068ccae37b8515cfc5843c"), - gogitTreeEntry: &object.TreeEntry{ - Hash: plumbing.Hash(MustIDFromString("61ab7345a1a3bbc590068ccae37b8515cfc5843c").RawValue()), - Name: "example/file2.txt", - Mode: filemode.Regular, - }, - size: 1022, - sized: true, - }, - }, - }, - { - Input: "120000 blob 61ab7345a1a3bbc590068ccae37b8515cfc5843c 234131\t\"example/\\n.txt\"\n" + - "040000 tree 1d01fb729fb0db5881daaa6030f9f2d3cd3d5ae8 -\texample\n", - Expected: []*TreeEntry{ - { - ID: MustIDFromString("61ab7345a1a3bbc590068ccae37b8515cfc5843c"), - gogitTreeEntry: &object.TreeEntry{ - Hash: plumbing.Hash(MustIDFromString("61ab7345a1a3bbc590068ccae37b8515cfc5843c").RawValue()), - Name: "example/\n.txt", - Mode: filemode.Symlink, - }, - size: 234131, - sized: true, - }, - { - ID: MustIDFromString("1d01fb729fb0db5881daaa6030f9f2d3cd3d5ae8"), - sized: true, - gogitTreeEntry: &object.TreeEntry{ - Hash: plumbing.Hash(MustIDFromString("1d01fb729fb0db5881daaa6030f9f2d3cd3d5ae8").RawValue()), - Name: "example", - Mode: filemode.Dir, - }, - }, - }, - }, - } - - for _, testCase := range testCases { - entries, err := ParseTreeEntries([]byte(testCase.Input)) - assert.NoError(t, err) - if len(entries) > 1 { - fmt.Println(testCase.Expected[0].ID) - fmt.Println(entries[0].ID) - } - assert.EqualValues(t, testCase.Expected, entries) - } -} diff --git a/modules/git/parse_nogogit.go b/modules/git/parse_treeentry.go similarity index 96% rename from modules/git/parse_nogogit.go rename to modules/git/parse_treeentry.go index 78a0162889..d46cd3344d 100644 --- a/modules/git/parse_nogogit.go +++ b/modules/git/parse_treeentry.go @@ -1,12 +1,9 @@ // Copyright 2018 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -//go:build !gogit - package git import ( - "bufio" "bytes" "fmt" "io" @@ -49,7 +46,7 @@ func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) { return entries, nil } -func catBatchParseTreeEntries(objectFormat ObjectFormat, ptree *Tree, rd *bufio.Reader, sz int64) ([]*TreeEntry, error) { +func catBatchParseTreeEntries(objectFormat ObjectFormat, ptree *Tree, rd BufferedReader, sz int64) ([]*TreeEntry, error) { fnameBuf := make([]byte, 4096) modeBuf := make([]byte, 40) shaBuf := make([]byte, objectFormat.FullLength()) diff --git a/modules/git/parse_nogogit_test.go b/modules/git/parse_treeentry_test.go similarity index 99% rename from modules/git/parse_nogogit_test.go rename to modules/git/parse_treeentry_test.go index 6594c84269..4223cbb3d7 100644 --- a/modules/git/parse_nogogit_test.go +++ b/modules/git/parse_treeentry_test.go @@ -1,8 +1,6 @@ // Copyright 2021 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -//go:build !gogit - package git import ( diff --git a/modules/git/pipeline/catfile.go b/modules/git/pipeline/catfile.go index a4d1ff64cf..3d005e28f1 100644 --- a/modules/git/pipeline/catfile.go +++ b/modules/git/pipeline/catfile.go @@ -5,81 +5,34 @@ package pipeline import ( "bufio" - "bytes" "context" - "fmt" "io" "strconv" "strings" - "sync" "code.gitea.io/gitea/modules/git/gitcmd" - "code.gitea.io/gitea/modules/log" ) // CatFileBatchCheck runs cat-file with --batch-check -func CatFileBatchCheck(ctx context.Context, shasToCheckReader *io.PipeReader, catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) { - defer wg.Done() - defer shasToCheckReader.Close() - defer catFileCheckWriter.Close() - - stderr := new(bytes.Buffer) - var errbuf strings.Builder - cmd := gitcmd.NewCommand("cat-file", "--batch-check") - if err := cmd.WithDir(tmpBasePath). - WithStdin(shasToCheckReader). - WithStdout(catFileCheckWriter). - WithStderr(stderr). - Run(ctx); err != nil { - _ = catFileCheckWriter.CloseWithError(fmt.Errorf("git cat-file --batch-check [%s]: %w - %s", tmpBasePath, err, errbuf.String())) - } +func CatFileBatchCheck(ctx context.Context, cmd *gitcmd.Command, tmpBasePath string) error { + cmd.AddArguments("cat-file", "--batch-check") + return cmd.WithDir(tmpBasePath).RunWithStderr(ctx) } // CatFileBatchCheckAllObjects runs cat-file with --batch-check --batch-all -func CatFileBatchCheckAllObjects(ctx context.Context, catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string, errChan chan<- error) { - defer wg.Done() - defer catFileCheckWriter.Close() - - stderr := new(bytes.Buffer) - var errbuf strings.Builder - cmd := gitcmd.NewCommand("cat-file", "--batch-check", "--batch-all-objects") - if err := cmd.WithDir(tmpBasePath). - WithStdout(catFileCheckWriter). - WithStderr(stderr). - Run(ctx); err != nil { - log.Error("git cat-file --batch-check --batch-all-object [%s]: %v - %s", tmpBasePath, err, errbuf.String()) - err = fmt.Errorf("git cat-file --batch-check --batch-all-object [%s]: %w - %s", tmpBasePath, err, errbuf.String()) - _ = catFileCheckWriter.CloseWithError(err) - errChan <- err - } +func CatFileBatchCheckAllObjects(ctx context.Context, cmd *gitcmd.Command, tmpBasePath string) error { + return cmd.AddArguments("cat-file", "--batch-check", "--batch-all-objects").WithDir(tmpBasePath).RunWithStderr(ctx) } // CatFileBatch runs cat-file --batch -func CatFileBatch(ctx context.Context, shasToBatchReader *io.PipeReader, catFileBatchWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) { - defer wg.Done() - defer shasToBatchReader.Close() - defer catFileBatchWriter.Close() - - stderr := new(bytes.Buffer) - var errbuf strings.Builder - if err := gitcmd.NewCommand("cat-file", "--batch"). - WithDir(tmpBasePath). - WithStdin(shasToBatchReader). - WithStdout(catFileBatchWriter). - WithStderr(stderr). - Run(ctx); err != nil { - _ = shasToBatchReader.CloseWithError(fmt.Errorf("git rev-list [%s]: %w - %s", tmpBasePath, err, errbuf.String())) - } +func CatFileBatch(ctx context.Context, cmd *gitcmd.Command, tmpBasePath string) error { + return cmd.AddArguments("cat-file", "--batch").WithDir(tmpBasePath).RunWithStderr(ctx) } // BlobsLessThan1024FromCatFileBatchCheck reads a pipeline from cat-file --batch-check and returns the blobs <1024 in size -func BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader *io.PipeReader, shasToBatchWriter *io.PipeWriter, wg *sync.WaitGroup) { - defer wg.Done() - defer catFileCheckReader.Close() - scanner := bufio.NewScanner(catFileCheckReader) - defer func() { - _ = shasToBatchWriter.CloseWithError(scanner.Err()) - }() +func BlobsLessThan1024FromCatFileBatchCheck(in io.ReadCloser, out io.WriteCloser) error { + defer out.Close() + scanner := bufio.NewScanner(in) for scanner.Scan() { line := scanner.Text() if len(line) == 0 { @@ -95,12 +48,12 @@ func BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader *io.PipeReader, s } toWrite := []byte(fields[0] + "\n") for len(toWrite) > 0 { - n, err := shasToBatchWriter.Write(toWrite) + n, err := out.Write(toWrite) if err != nil { - _ = catFileCheckReader.CloseWithError(err) - break + return err } toWrite = toWrite[n:] } } + return scanner.Err() } diff --git a/modules/git/pipeline/lfs_common.go b/modules/git/pipeline/lfs_common.go index 188e7d4d65..914aefbeaf 100644 --- a/modules/git/pipeline/lfs_common.go +++ b/modules/git/pipeline/lfs_common.go @@ -4,7 +4,6 @@ package pipeline import ( - "fmt" "time" "code.gitea.io/gitea/modules/git" @@ -26,7 +25,3 @@ type lfsResultSlice []*LFSResult func (a lfsResultSlice) Len() int { return len(a) } func (a lfsResultSlice) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a lfsResultSlice) Less(i, j int) bool { return a[j].When.After(a[i].When) } - -func lfsError(msg string, err error) error { - return fmt.Errorf("LFS error occurred, %s: err: %w", msg, err) -} diff --git a/modules/git/pipeline/lfs_gogit.go b/modules/git/pipeline/lfs_gogit.go index adcf8ed09c..c12397569c 100644 --- a/modules/git/pipeline/lfs_gogit.go +++ b/modules/git/pipeline/lfs_gogit.go @@ -6,11 +6,10 @@ package pipeline import ( - "bufio" + "fmt" "io" "sort" "strings" - "sync" "code.gitea.io/gitea/modules/git" @@ -24,7 +23,6 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err resultsMap := map[string]*LFSResult{} results := make([]*LFSResult, 0) - basePath := repo.Path gogitRepo := repo.GoGitRepo() commitsIter, err := gogitRepo.Log(&gogit.LogOptions{ @@ -32,7 +30,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err All: true, }) if err != nil { - return nil, lfsError("failed to get GoGit CommitsIter", err) + return nil, fmt.Errorf("LFS error occurred, failed to get GoGit CommitsIter: err: %w", err) } err = commitsIter.ForEach(func(gitCommit *object.Commit) error { @@ -66,7 +64,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err return nil }) if err != nil && err != io.EOF { - return nil, lfsError("failure in CommitIter.ForEach", err) + return nil, fmt.Errorf("LFS error occurred, failure in CommitIter.ForEach: %w", err) } for _, result := range resultsMap { @@ -82,65 +80,6 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err } sort.Sort(lfsResultSlice(results)) - - // Should really use a go-git function here but name-rev is not completed and recapitulating it is not simple - shasToNameReader, shasToNameWriter := io.Pipe() - nameRevStdinReader, nameRevStdinWriter := io.Pipe() - errChan := make(chan error, 1) - wg := sync.WaitGroup{} - wg.Add(3) - - go func() { - defer wg.Done() - scanner := bufio.NewScanner(nameRevStdinReader) - i := 0 - for scanner.Scan() { - line := scanner.Text() - if len(line) == 0 { - continue - } - result := results[i] - result.FullCommitName = line - result.BranchName = strings.Split(line, "~")[0] - i++ - } - }() - go NameRevStdin(repo.Ctx, shasToNameReader, nameRevStdinWriter, &wg, basePath) - go func() { - defer wg.Done() - defer shasToNameWriter.Close() - for _, result := range results { - i := 0 - if i < len(result.SHA) { - n, err := shasToNameWriter.Write([]byte(result.SHA)[i:]) - if err != nil { - errChan <- err - break - } - i += n - } - n := 0 - for n < 1 { - n, err = shasToNameWriter.Write([]byte{'\n'}) - if err != nil { - errChan <- err - break - } - - } - - } - }() - - wg.Wait() - - select { - case err, has := <-errChan: - if has { - return nil, lfsError("unable to obtain name for LFS files", err) - } - default: - } - - return results, nil + err = fillResultNameRev(repo.Ctx, repo.Path, results) + return results, err } diff --git a/modules/git/pipeline/lfs_nogogit.go b/modules/git/pipeline/lfs_nogogit.go index 4881a2be64..91bda0d0e5 100644 --- a/modules/git/pipeline/lfs_nogogit.go +++ b/modules/git/pipeline/lfs_nogogit.go @@ -8,46 +8,34 @@ package pipeline import ( "bufio" "bytes" + "encoding/hex" "io" "sort" "strings" - "sync" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" ) // FindLFSFile finds commits that contain a provided pointer file hash -func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, error) { +func FindLFSFile(repo *git.Repository, objectID git.ObjectID) (results []*LFSResult, _ error) { + cmd := gitcmd.NewCommand("rev-list", "--all") + revListReader, revListReaderClose := cmd.MakeStdoutPipe() + defer revListReaderClose() + err := cmd.WithDir(repo.Path). + WithPipelineFunc(func(context gitcmd.Context) (err error) { + results, err = findLFSFileFunc(repo, objectID, revListReader) + return err + }).RunWithStderr(repo.Ctx) + return results, err +} + +func findLFSFileFunc(repo *git.Repository, objectID git.ObjectID, revListReader io.Reader) ([]*LFSResult, error) { resultsMap := map[string]*LFSResult{} results := make([]*LFSResult, 0) - - basePath := repo.Path - - // Use rev-list to provide us with all commits in order - revListReader, revListWriter := io.Pipe() - defer func() { - _ = revListWriter.Close() - _ = revListReader.Close() - }() - - go func() { - stderr := strings.Builder{} - err := gitcmd.NewCommand("rev-list", "--all"). - WithDir(repo.Path). - WithStdout(revListWriter). - WithStderr(&stderr). - Run(repo.Ctx) - if err != nil { - _ = revListWriter.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String())) - } else { - _ = revListWriter.Close() - } - }() - // Next feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary. // so let's create a batch stdin and stdout - batchStdinWriter, batchReader, cancel, err := repo.CatFileBatch(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return nil, err } @@ -55,7 +43,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err // We'll use a scanner for the revList because it's simpler than a bufio.Reader scan := bufio.NewScanner(revListReader) - trees := [][]byte{} + trees := []string{} paths := []string{} fnameBuf := make([]byte, 4096) @@ -64,14 +52,10 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err for scan.Scan() { // Get the next commit ID - commitID := scan.Bytes() + commitID := scan.Text() // push the commit to the cat-file --batch process - _, err := batchStdinWriter.Write(commitID) - if err != nil { - return nil, err - } - _, err = batchStdinWriter.Write([]byte{'\n'}) + info, batchReader, err := batch.QueryContent(commitID) if err != nil { return nil, err } @@ -81,26 +65,20 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err commitReadingLoop: for { - _, typ, size, err := git.ReadBatchLine(batchReader) - if err != nil { - return nil, err - } - - switch typ { + switch info.Type { case "tag": // This shouldn't happen but if it does well just get the commit and try again - id, err := git.ReadTagObjectID(batchReader, size) + id, err := git.ReadTagObjectID(batchReader, info.Size) if err != nil { return nil, err } - _, err = batchStdinWriter.Write([]byte(id + "\n")) - if err != nil { + if info, batchReader, err = batch.QueryContent(id); err != nil { return nil, err } continue case "commit": // Read in the commit to get its tree and in case this is one of the last used commits - curCommit, err = git.CommitFromReader(repo, git.MustIDFromString(string(commitID)), io.LimitReader(batchReader, size)) + curCommit, err = git.CommitFromReader(repo, git.MustIDFromString(commitID), io.LimitReader(batchReader, info.Size)) if err != nil { return nil, err } @@ -108,13 +86,13 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err return nil, err } - if _, err := batchStdinWriter.Write([]byte(curCommit.Tree.ID.String() + "\n")); err != nil { + if info, _, err = batch.QueryContent(curCommit.Tree.ID.String()); err != nil { return nil, err } curPath = "" case "tree": var n int64 - for n < size { + for n < info.Size { mode, fname, binObjectID, count, err := git.ParseCatFileTreeLine(objectID.Type(), batchReader, modeBuf, fnameBuf, workingShaBuf) if err != nil { return nil, err @@ -130,9 +108,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err } resultsMap[curCommit.ID.String()+":"+curPath+string(fname)] = &result } else if string(mode) == git.EntryModeTree.String() { - hexObjectID := make([]byte, objectID.Type().FullLength()) - git.BinToHex(objectID.Type(), binObjectID, hexObjectID) - trees = append(trees, hexObjectID) + trees = append(trees, hex.EncodeToString(binObjectID)) paths = append(paths, curPath+string(fname)+"/") } } @@ -140,11 +116,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err return nil, err } if len(trees) > 0 { - _, err := batchStdinWriter.Write(trees[len(trees)-1]) - if err != nil { - return nil, err - } - _, err = batchStdinWriter.Write([]byte("\n")) + info, _, err = batch.QueryContent(trees[len(trees)-1]) if err != nil { return nil, err } @@ -155,7 +127,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err break commitReadingLoop } default: - if err := git.DiscardFull(batchReader, size+1); err != nil { + if err := git.DiscardFull(batchReader, info.Size+1); err != nil { return nil, err } } @@ -179,56 +151,6 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err } sort.Sort(lfsResultSlice(results)) - - // Should really use a go-git function here but name-rev is not completed and recapitulating it is not simple - shasToNameReader, shasToNameWriter := io.Pipe() - nameRevStdinReader, nameRevStdinWriter := io.Pipe() - errChan := make(chan error, 1) - wg := sync.WaitGroup{} - wg.Add(3) - - go func() { - defer wg.Done() - scanner := bufio.NewScanner(nameRevStdinReader) - i := 0 - for scanner.Scan() { - line := scanner.Text() - if len(line) == 0 { - continue - } - result := results[i] - result.FullCommitName = line - result.BranchName = strings.Split(line, "~")[0] - i++ - } - }() - go NameRevStdin(repo.Ctx, shasToNameReader, nameRevStdinWriter, &wg, basePath) - go func() { - defer wg.Done() - defer shasToNameWriter.Close() - for _, result := range results { - _, err := shasToNameWriter.Write([]byte(result.SHA)) - if err != nil { - errChan <- err - break - } - _, err = shasToNameWriter.Write([]byte{'\n'}) - if err != nil { - errChan <- err - break - } - } - }() - - wg.Wait() - - select { - case err, has := <-errChan: - if has { - return nil, lfsError("unable to obtain name for LFS files", err) - } - default: - } - - return results, nil + err = fillResultNameRev(repo.Ctx, repo.Path, results) + return results, err } diff --git a/modules/git/pipeline/lfs_test.go b/modules/git/pipeline/lfs_test.go new file mode 100644 index 0000000000..30fe2f93c2 --- /dev/null +++ b/modules/git/pipeline/lfs_test.go @@ -0,0 +1,38 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pipeline + +import ( + "testing" + "time" + + "code.gitea.io/gitea/modules/git" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindLFSFile(t *testing.T) { + repoPath := "../../../tests/gitea-repositories-meta/user2/lfs.git" + gitRepo, err := git.OpenRepository(t.Context(), repoPath) + require.NoError(t, err) + defer gitRepo.Close() + + objectID := git.MustIDFromString("2b6c6c4eaefa24b22f2092c3d54b263ff26feb58") + + stats, err := FindLFSFile(gitRepo, objectID) + require.NoError(t, err) + + tm, err := time.Parse(time.RFC3339, "2022-12-21T17:56:42-05:00") + require.NoError(t, err) + + assert.Len(t, stats, 1) + assert.Equal(t, "CONTRIBUTING.md", stats[0].Name) + assert.Equal(t, "73cf03db6ece34e12bf91e8853dc58f678f2f82d", stats[0].SHA) + assert.Equal(t, "Initial commit", stats[0].Summary) + assert.Equal(t, tm, stats[0].When) + assert.Empty(t, stats[0].ParentHashes) + assert.Equal(t, "master", stats[0].BranchName) + assert.Equal(t, "master", stats[0].FullCommitName) +} diff --git a/modules/git/pipeline/main_test.go b/modules/git/pipeline/main_test.go new file mode 100644 index 0000000000..fa5832b68c --- /dev/null +++ b/modules/git/pipeline/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pipeline + +import ( + "testing" + + "code.gitea.io/gitea/modules/git" +) + +func TestMain(m *testing.M) { + git.RunGitTests(m) +} diff --git a/modules/git/pipeline/namerev.go b/modules/git/pipeline/namerev.go index 782b5f0531..24de442940 100644 --- a/modules/git/pipeline/namerev.go +++ b/modules/git/pipeline/namerev.go @@ -4,30 +4,54 @@ package pipeline import ( - "bytes" + "bufio" "context" - "fmt" - "io" + "errors" "strings" - "sync" "code.gitea.io/gitea/modules/git/gitcmd" + + "golang.org/x/sync/errgroup" ) -// NameRevStdin runs name-rev --stdin -func NameRevStdin(ctx context.Context, shasToNameReader *io.PipeReader, nameRevStdinWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) { - defer wg.Done() - defer shasToNameReader.Close() - defer nameRevStdinWriter.Close() +func fillResultNameRev(ctx context.Context, basePath string, results []*LFSResult) error { + // Should really use a go-git function here but name-rev is not completed and recapitulating it is not simple + wg := errgroup.Group{} + cmd := gitcmd.NewCommand("name-rev", "--stdin", "--name-only", "--always").WithDir(basePath) + stdin, stdinClose := cmd.MakeStdinPipe() + stdout, stdoutClose := cmd.MakeStdoutPipe() + defer stdinClose() + defer stdoutClose() - stderr := new(bytes.Buffer) - var errbuf strings.Builder - if err := gitcmd.NewCommand("name-rev", "--stdin", "--name-only", "--always"). - WithDir(tmpBasePath). - WithStdin(shasToNameReader). - WithStdout(nameRevStdinWriter). - WithStderr(stderr). - Run(ctx); err != nil { - _ = shasToNameReader.CloseWithError(fmt.Errorf("git name-rev [%s]: %w - %s", tmpBasePath, err, errbuf.String())) - } + wg.Go(func() error { + scanner := bufio.NewScanner(stdout) + i := 0 + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 { + continue + } + result := results[i] + result.FullCommitName = line + result.BranchName = strings.Split(line, "~")[0] + i++ + } + return scanner.Err() + }) + wg.Go(func() error { + defer stdinClose() + for _, result := range results { + _, err := stdin.Write([]byte(result.SHA)) + if err != nil { + return err + } + _, err = stdin.Write([]byte{'\n'}) + if err != nil { + return err + } + } + return nil + }) + err := cmd.RunWithStderr(ctx) + return errors.Join(err, wg.Wait()) } diff --git a/modules/git/pipeline/revlist.go b/modules/git/pipeline/revlist.go index 755b165a65..28d4751bd8 100644 --- a/modules/git/pipeline/revlist.go +++ b/modules/git/pipeline/revlist.go @@ -5,63 +5,26 @@ package pipeline import ( "bufio" - "bytes" "context" - "fmt" "io" "strings" - "sync" "code.gitea.io/gitea/modules/git/gitcmd" - "code.gitea.io/gitea/modules/log" ) -// RevListAllObjects runs rev-list --objects --all and writes to a pipewriter -func RevListAllObjects(ctx context.Context, revListWriter *io.PipeWriter, wg *sync.WaitGroup, basePath string, errChan chan<- error) { - defer wg.Done() - defer revListWriter.Close() - - stderr := new(bytes.Buffer) - var errbuf strings.Builder - cmd := gitcmd.NewCommand("rev-list", "--objects", "--all") - if err := cmd.WithDir(basePath). - WithStdout(revListWriter). - WithStderr(stderr). - Run(ctx); err != nil { - log.Error("git rev-list --objects --all [%s]: %v - %s", basePath, err, errbuf.String()) - err = fmt.Errorf("git rev-list --objects --all [%s]: %w - %s", basePath, err, errbuf.String()) - _ = revListWriter.CloseWithError(err) - errChan <- err - } -} - // RevListObjects run rev-list --objects from headSHA to baseSHA -func RevListObjects(ctx context.Context, revListWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath, headSHA, baseSHA string, errChan chan<- error) { - defer wg.Done() - defer revListWriter.Close() - stderr := new(bytes.Buffer) - var errbuf strings.Builder - cmd := gitcmd.NewCommand("rev-list", "--objects").AddDynamicArguments(headSHA) +func RevListObjects(ctx context.Context, cmd *gitcmd.Command, tmpBasePath, headSHA, baseSHA string) error { + cmd.AddArguments("rev-list", "--objects").AddDynamicArguments(headSHA) if baseSHA != "" { cmd = cmd.AddArguments("--not").AddDynamicArguments(baseSHA) } - if err := cmd.WithDir(tmpBasePath). - WithStdout(revListWriter). - WithStderr(stderr). - Run(ctx); err != nil { - log.Error("git rev-list [%s]: %v - %s", tmpBasePath, err, errbuf.String()) - errChan <- fmt.Errorf("git rev-list [%s]: %w - %s", tmpBasePath, err, errbuf.String()) - } + return cmd.WithDir(tmpBasePath).RunWithStderr(ctx) } // BlobsFromRevListObjects reads a RevListAllObjects and only selects blobs -func BlobsFromRevListObjects(revListReader *io.PipeReader, shasToCheckWriter *io.PipeWriter, wg *sync.WaitGroup) { - defer wg.Done() - defer revListReader.Close() - scanner := bufio.NewScanner(revListReader) - defer func() { - _ = shasToCheckWriter.CloseWithError(scanner.Err()) - }() +func BlobsFromRevListObjects(in io.ReadCloser, out io.WriteCloser) error { + defer out.Close() + scanner := bufio.NewScanner(in) for scanner.Scan() { line := scanner.Text() if len(line) == 0 { @@ -73,12 +36,12 @@ func BlobsFromRevListObjects(revListReader *io.PipeReader, shasToCheckWriter *io } toWrite := []byte(fields[0] + "\n") for len(toWrite) > 0 { - n, err := shasToCheckWriter.Write(toWrite) + n, err := out.Write(toWrite) if err != nil { - _ = revListReader.CloseWithError(err) - break + return err } toWrite = toWrite[n:] } } + return scanner.Err() } diff --git a/modules/git/ref.go b/modules/git/ref.go index 56b2db858a..7b63d06b38 100644 --- a/modules/git/ref.go +++ b/modules/git/ref.go @@ -220,3 +220,14 @@ func (ref RefName) RefWebLinkPath() string { } return string(refType) + "/" + util.PathEscapeSegments(ref.ShortName()) } + +func ParseRefSuffix(ref string) (string, string) { + // Partially support https://git-scm.com/docs/gitrevisions + if idx := strings.Index(ref, "@{"); idx != -1 { + return ref[:idx], ref[idx:] + } + if idx := strings.Index(ref, "^"); idx != -1 { + return ref[:idx], ref[idx:] + } + return ref, "" +} diff --git a/modules/git/remote.go b/modules/git/remote.go index 1999ad4b94..ae56c5576a 100644 --- a/modules/git/remote.go +++ b/modules/git/remote.go @@ -74,9 +74,9 @@ func (err *ErrInvalidCloneAddr) Unwrap() error { func IsRemoteNotExistError(err error) bool { // see: https://github.com/go-gitea/gitea/issues/32889#issuecomment-2571848216 // Should not add space in the end, sometimes git will add a `:` - prefix1 := "exit status 128 - fatal: No such remote" // git < 2.30 - prefix2 := "exit status 2 - error: No such remote" // git >= 2.30 - return strings.HasPrefix(err.Error(), prefix1) || strings.HasPrefix(err.Error(), prefix2) + prefix1 := "fatal: No such remote" // git < 2.30, exit status 128 + prefix2 := "error: No such remote" // git >= 2.30. exit status 2 + return gitcmd.StderrHasPrefix(err, prefix1) || gitcmd.StderrHasPrefix(err, prefix2) } // ParseRemoteAddr checks if given remote address is valid, diff --git a/modules/git/repo.go b/modules/git/repo.go index 29e70d94c8..1e31eb1b80 100644 --- a/modules/git/repo.go +++ b/modules/git/repo.go @@ -8,7 +8,6 @@ import ( "bytes" "context" "fmt" - "io" "net/url" "os" "path" @@ -20,23 +19,8 @@ import ( "code.gitea.io/gitea/modules/proxy" ) -// GPGSettings represents the default GPG settings for this repository -type GPGSettings struct { - Sign bool - KeyID string - Email string - Name string - PublicKeyContent string - Format string -} - const prettyLogFormat = `--pretty=format:%H` -// GetAllCommitsCount returns count of all commits in repository -func (repo *Repository) GetAllCommitsCount() (int64, error) { - return AllCommitsCount(repo.Ctx, repo.Path, false) -} - func (repo *Repository) ShowPrettyFormatLogToList(ctx context.Context, revisionRange string) ([]*Commit, error) { // avoid: ambiguous argument 'refs/a...refs/b': unknown revision or path not in the working tree. Use '--': 'git [...] -- [...]' logs, _, err := gitcmd.NewCommand("log").AddArguments(prettyLogFormat). @@ -98,22 +82,19 @@ func InitRepository(ctx context.Context, repoPath string, bare bool, objectForma // IsEmpty Check if repository is empty. func (repo *Repository) IsEmpty() (bool, error) { - var errbuf, output strings.Builder - if err := gitcmd.NewCommand(). + stdout, _, err := gitcmd.NewCommand(). AddOptionFormat("--git-dir=%s", repo.Path). AddArguments("rev-list", "-n", "1", "--all"). WithDir(repo.Path). - WithStdout(&output). - WithStderr(&errbuf). - Run(repo.Ctx); err != nil { - if (err.Error() == "exit status 1" && strings.TrimSpace(errbuf.String()) == "") || err.Error() == "exit status 129" { + RunStdString(repo.Ctx) + if err != nil { + if (gitcmd.IsErrorExitCode(err, 1) && err.Stderr() == "") || gitcmd.IsErrorExitCode(err, 129) { // git 2.11 exits with 129 if the repo is empty return true, nil } - return true, fmt.Errorf("check empty: %w - %s", err, errbuf.String()) + return true, fmt.Errorf("check empty: %w", err) } - - return strings.TrimSpace(output.String()) == "", nil + return strings.TrimSpace(stdout) == "", nil } // CloneRepoOptions options when clone a repository @@ -128,6 +109,8 @@ type CloneRepoOptions struct { Depth int Filter string SkipTLSVerify bool + SingleBranch bool + Env []string } // Clone clones original repository to target path. @@ -162,6 +145,9 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error { if opts.Filter != "" { cmd.AddArguments("--filter").AddDynamicArguments(opts.Filter) } + if opts.SingleBranch { + cmd.AddArguments("--single-branch") + } if len(opts.Branch) > 0 { cmd.AddArguments("-b").AddDynamicArguments(opts.Branch) } @@ -172,37 +158,39 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error { } envs := os.Environ() - u, err := url.Parse(from) - if err == nil { - envs = proxy.EnvWithProxy(u) + if opts.Env != nil { + envs = opts.Env + } else { + u, err := url.Parse(from) + if err == nil { + envs = proxy.EnvWithProxy(u) + } } - stderr := new(bytes.Buffer) - if err = cmd. + return cmd. WithTimeout(opts.Timeout). WithEnv(envs). - WithStdout(io.Discard). - WithStderr(stderr). - Run(ctx); err != nil { - return gitcmd.ConcatenateError(err, stderr.String()) - } - return nil + RunWithStderr(ctx) } // PushOptions options when push to remote type PushOptions struct { - Remote string - Branch string - Force bool - Mirror bool - Env []string - Timeout time.Duration + Remote string + LocalRefName string + Branch string + Force bool + ForceWithLease string + Mirror bool + Env []string + Timeout time.Duration } // Push pushs local commits to given remote branch. func Push(ctx context.Context, repoPath string, opts PushOptions) error { cmd := gitcmd.NewCommand("push") - if opts.Force { + if opts.ForceWithLease != "" { + cmd.AddOptionFormat("--force-with-lease=%s", opts.ForceWithLease) + } else if opts.Force { cmd.AddArguments("-f") } if opts.Mirror { @@ -210,7 +198,13 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error { } remoteBranchArgs := []string{opts.Remote} if len(opts.Branch) > 0 { - remoteBranchArgs = append(remoteBranchArgs, opts.Branch) + var refspec string + if opts.LocalRefName != "" { + refspec = fmt.Sprintf("%s:%s", opts.LocalRefName, opts.Branch) + } else { + refspec = opts.Branch + } + remoteBranchArgs = append(remoteBranchArgs, refspec) } cmd.AddDashesAndList(remoteBranchArgs...) @@ -230,14 +224,3 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error { return nil } - -// GetLatestCommitTime returns time for latest commit in repository (across all branches) -func GetLatestCommitTime(ctx context.Context, repoPath string) (time.Time, error) { - cmd := gitcmd.NewCommand("for-each-ref", "--sort=-committerdate", BranchPrefix, "--count", "1", "--format=%(committerdate)") - stdout, _, err := cmd.WithDir(repoPath).RunStdString(ctx) - if err != nil { - return time.Time{}, err - } - commitTime := strings.TrimSpace(stdout) - return time.Parse("Mon Jan _2 15:04:05 2006 -0700", commitTime) -} diff --git a/modules/git/repo_archive.go b/modules/git/repo_archive.go deleted file mode 100644 index 8a9eec9e6a..0000000000 --- a/modules/git/repo_archive.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2015 The Gogs Authors. All rights reserved. -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package git - -import ( - "context" - "fmt" - "io" - "path/filepath" - "strings" - - "code.gitea.io/gitea/modules/git/gitcmd" -) - -// ArchiveType archive types -type ArchiveType int - -const ( - ArchiveUnknown ArchiveType = iota - ArchiveZip // 1 - ArchiveTarGz // 2 - ArchiveBundle // 3 -) - -// String converts an ArchiveType to string: the extension of the archive file without prefix dot -func (a ArchiveType) String() string { - switch a { - case ArchiveZip: - return "zip" - case ArchiveTarGz: - return "tar.gz" - case ArchiveBundle: - return "bundle" - } - return "unknown" -} - -func SplitArchiveNameType(s string) (string, ArchiveType) { - switch { - case strings.HasSuffix(s, ".zip"): - return strings.TrimSuffix(s, ".zip"), ArchiveZip - case strings.HasSuffix(s, ".tar.gz"): - return strings.TrimSuffix(s, ".tar.gz"), ArchiveTarGz - case strings.HasSuffix(s, ".bundle"): - return strings.TrimSuffix(s, ".bundle"), ArchiveBundle - } - return s, ArchiveUnknown -} - -// CreateArchive create archive content to the target path -func (repo *Repository) CreateArchive(ctx context.Context, format ArchiveType, target io.Writer, usePrefix bool, commitID string) error { - if format.String() == "unknown" { - return fmt.Errorf("unknown format: %v", format) - } - - cmd := gitcmd.NewCommand("archive") - if usePrefix { - cmd.AddOptionFormat("--prefix=%s", filepath.Base(strings.TrimSuffix(repo.Path, ".git"))+"/") - } - cmd.AddOptionFormat("--format=%s", format.String()) - cmd.AddDynamicArguments(commitID) - - var stderr strings.Builder - err := cmd.WithDir(repo.Path). - WithStdout(target). - WithStderr(&stderr). - Run(ctx) - if err != nil { - return gitcmd.ConcatenateError(err, stderr.String()) - } - return nil -} diff --git a/modules/git/repo_base_gogit.go b/modules/git/repo_base_gogit.go index e0d0b45372..986264fd93 100644 --- a/modules/git/repo_base_gogit.go +++ b/modules/git/repo_base_gogit.go @@ -32,7 +32,6 @@ type Repository struct { gogitRepo *gogit.Repository gogitStorage *filesystem.Storage - gpgSettings *GPGSettings Ctx context.Context LastCommitCache *LastCommitCache diff --git a/modules/git/repo_base_nogogit.go b/modules/git/repo_base_nogogit.go index 4091e70846..775bbd4a09 100644 --- a/modules/git/repo_base_nogogit.go +++ b/modules/git/repo_base_nogogit.go @@ -7,9 +7,9 @@ package git import ( - "bufio" "context" "path/filepath" + "sync" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" @@ -23,13 +23,9 @@ type Repository struct { tagCache *ObjectCache[*Tag] - gpgSettings *GPGSettings - - batchInUse bool - batch *Batch - - checkInUse bool - check *Batch + mu sync.Mutex + catFileBatchCloser CatFileBatchCloser + catFileBatchInUse bool Ctx context.Context LastCommitCache *LastCommitCache @@ -58,69 +54,47 @@ func OpenRepository(ctx context.Context, repoPath string) (*Repository, error) { }, nil } -// CatFileBatch obtains a CatFileBatch for this repository -func (repo *Repository) CatFileBatch(ctx context.Context) (WriteCloserError, *bufio.Reader, func(), error) { - if repo.batch == nil { - var err error - repo.batch, err = NewBatch(ctx, repo.Path) +// CatFileBatch obtains a "batch object provider" for this repository. +// It reuses an existing one if available, otherwise creates a new one. +func (repo *Repository) CatFileBatch(ctx context.Context) (_ CatFileBatch, closeFunc func(), err error) { + repo.mu.Lock() + defer repo.mu.Unlock() + + if repo.catFileBatchCloser == nil { + repo.catFileBatchCloser, err = NewBatch(ctx, repo.Path) if err != nil { - return nil, nil, nil, err + repo.catFileBatchCloser = nil // otherwise it is "interface(nil)" and will cause wrong logic + return nil, nil, err } } - if !repo.batchInUse { - repo.batchInUse = true - return repo.batch.Writer, repo.batch.Reader, func() { - repo.batchInUse = false + if !repo.catFileBatchInUse { + repo.catFileBatchInUse = true + return CatFileBatch(repo.catFileBatchCloser), func() { + repo.mu.Lock() + defer repo.mu.Unlock() + repo.catFileBatchInUse = false }, nil } log.Debug("Opening temporary cat file batch for: %s", repo.Path) tempBatch, err := NewBatch(ctx, repo.Path) if err != nil { - return nil, nil, nil, err + return nil, nil, err } - return tempBatch.Writer, tempBatch.Reader, tempBatch.Close, nil -} - -// CatFileBatchCheck obtains a CatFileBatchCheck for this repository -func (repo *Repository) CatFileBatchCheck(ctx context.Context) (WriteCloserError, *bufio.Reader, func(), error) { - if repo.check == nil { - var err error - repo.check, err = NewBatchCheck(ctx, repo.Path) - if err != nil { - return nil, nil, nil, err - } - } - - if !repo.checkInUse { - repo.checkInUse = true - return repo.check.Writer, repo.check.Reader, func() { - repo.checkInUse = false - }, nil - } - - log.Debug("Opening temporary cat file batch-check for: %s", repo.Path) - tempBatchCheck, err := NewBatchCheck(ctx, repo.Path) - if err != nil { - return nil, nil, nil, err - } - return tempBatchCheck.Writer, tempBatchCheck.Reader, tempBatchCheck.Close, nil + return tempBatch, tempBatch.Close, nil } func (repo *Repository) Close() error { if repo == nil { return nil } - if repo.batch != nil { - repo.batch.Close() - repo.batch = nil - repo.batchInUse = false - } - if repo.check != nil { - repo.check.Close() - repo.check = nil - repo.checkInUse = false + repo.mu.Lock() + defer repo.mu.Unlock() + if repo.catFileBatchCloser != nil { + repo.catFileBatchCloser.Close() + repo.catFileBatchCloser = nil + repo.catFileBatchInUse = false } repo.LastCommitCache = nil repo.tagCache = nil diff --git a/modules/git/repo_base_nogogit_test.go b/modules/git/repo_base_nogogit_test.go new file mode 100644 index 0000000000..a12bbb73c2 --- /dev/null +++ b/modules/git/repo_base_nogogit_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !gogit + +package git + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRepoCatFileBatch(t *testing.T) { + t.Run("MissingRepoAndClose", func(t *testing.T) { + repo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare")) + require.NoError(t, err) + repo.Path = "/no-such" // when the repo is missing (it usually occurs during testing because the fixtures are synced frequently) + _, _, err = repo.CatFileBatch(t.Context()) + require.Error(t, err) + require.NoError(t, repo.Close()) // shouldn't panic + }) + + // TODO: test more methods and concurrency queries +} diff --git a/modules/git/repo_blob.go b/modules/git/repo_blob.go index 698b6c7074..ff930a3432 100644 --- a/modules/git/repo_blob.go +++ b/modules/git/repo_blob.go @@ -9,5 +9,11 @@ func (repo *Repository) GetBlob(idStr string) (*Blob, error) { if err != nil { return nil, err } - return repo.getBlob(id) + if id.IsZero() { + return nil, ErrNotExist{id.String(), ""} + } + return &Blob{ + ID: id, + repo: repo, + }, nil } diff --git a/modules/git/repo_blob_gogit.go b/modules/git/repo_blob_gogit.go deleted file mode 100644 index 66c8c2775c..0000000000 --- a/modules/git/repo_blob_gogit.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2018 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build gogit - -package git - -import ( - "github.com/go-git/go-git/v5/plumbing" -) - -func (repo *Repository) getBlob(id ObjectID) (*Blob, error) { - encodedObj, err := repo.gogitRepo.Storer.EncodedObject(plumbing.AnyObject, plumbing.Hash(id.RawValue())) - if err != nil { - return nil, ErrNotExist{id.String(), ""} - } - - return &Blob{ - ID: id, - gogitEncodedObj: encodedObj, - }, nil -} diff --git a/modules/git/repo_blob_nogogit.go b/modules/git/repo_blob_nogogit.go deleted file mode 100644 index 04b0fb00ff..0000000000 --- a/modules/git/repo_blob_nogogit.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build !gogit - -package git - -func (repo *Repository) getBlob(id ObjectID) (*Blob, error) { - if id.IsZero() { - return nil, ErrNotExist{id.String(), ""} - } - return &Blob{ - ID: id, - repo: repo, - }, nil -} diff --git a/modules/git/repo_branch_nogogit.go b/modules/git/repo_branch_nogogit.go index f1b26b06ab..f925aab3e4 100644 --- a/modules/git/repo_branch_nogogit.go +++ b/modules/git/repo_branch_nogogit.go @@ -8,7 +8,6 @@ package git import ( "bufio" - "bytes" "context" "io" "strings" @@ -18,24 +17,24 @@ import ( ) // IsObjectExist returns true if the given object exists in the repository. +// FIXME: this function doesn't seem right, it is only used by GarbageCollectLFSMetaObjectsForRepo func (repo *Repository) IsObjectExist(name string) bool { if name == "" { return false } - wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { - log.Debug("Error writing to CatFileBatchCheck %v", err) + log.Debug("Error opening CatFileBatch %v", err) return false } defer cancel() - _, err = wr.Write([]byte(name + "\n")) + info, err := batch.QueryInfo(name) if err != nil { - log.Debug("Error writing to CatFileBatchCheck %v", err) + log.Debug("Error checking object info %v", err) return false } - sha, _, _, err := ReadBatchLine(rd) - return err == nil && bytes.HasPrefix(sha, []byte(strings.TrimSpace(name))) + return strings.HasPrefix(info.ID, name) // FIXME: this logic doesn't seem right, why "HasPrefix" } // IsReferenceExist returns true if given reference exists in the repository. @@ -44,18 +43,13 @@ func (repo *Repository) IsReferenceExist(name string) bool { return false } - wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { - log.Debug("Error writing to CatFileBatchCheck %v", err) + log.Error("Error opening CatFileBatch %v", err) return false } defer cancel() - _, err = wr.Write([]byte(name + "\n")) - if err != nil { - log.Debug("Error writing to CatFileBatchCheck %v", err) - return false - } - _, _, _, err = ReadBatchLine(rd) + _, err = batch.QueryInfo(name) return err == nil } @@ -100,94 +94,81 @@ func callShowRef(ctx context.Context, repoPath, trimPrefix string, extraArgs git } func WalkShowRef(ctx context.Context, repoPath string, extraArgs gitcmd.TrustedCmdArgs, skip, limit int, walkfn func(sha1, refname string) error) (countAll int, err error) { - stdoutReader, stdoutWriter := io.Pipe() - defer func() { - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - }() - - go func() { - stderrBuilder := &strings.Builder{} - args := gitcmd.TrustedCmdArgs{"for-each-ref", "--format=%(objectname) %(refname)"} - args = append(args, extraArgs...) - err := gitcmd.NewCommand(args...). - WithDir(repoPath). - WithStdout(stdoutWriter). - WithStderr(stderrBuilder). - Run(ctx) - if err != nil { - if stderrBuilder.Len() == 0 { - _ = stdoutWriter.Close() - return - } - _ = stdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, stderrBuilder.String())) - } else { - _ = stdoutWriter.Close() - } - }() - i := 0 - bufReader := bufio.NewReader(stdoutReader) - for i < skip { - _, isPrefix, err := bufReader.ReadLine() - if err == io.EOF { - return i, nil - } - if err != nil { - return 0, err - } - if !isPrefix { - i++ - } + args := gitcmd.TrustedCmdArgs{"for-each-ref", "--format=%(objectname) %(refname)"} + args = append(args, extraArgs...) + cmd := gitcmd.NewCommand(args...) + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + defer stdoutReaderClose() + cmd.WithDir(repoPath). + WithPipelineFunc(func(gitcmd.Context) error { + bufReader := bufio.NewReader(stdoutReader) + for i < skip { + _, isPrefix, err := bufReader.ReadLine() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + if !isPrefix { + i++ + } + } + for limit == 0 || i < skip+limit { + // The output of show-ref is simply a list: + // SP LF + sha, err := bufReader.ReadString(' ') + if err == io.EOF { + return nil + } + if err != nil { + return err + } + + branchName, err := bufReader.ReadString('\n') + if err == io.EOF { + // This shouldn't happen... but we'll tolerate it for the sake of peace + return nil + } + if err != nil { + return err + } + + if len(branchName) > 0 { + branchName = branchName[:len(branchName)-1] + } + + if len(sha) > 0 { + sha = sha[:len(sha)-1] + } + + err = walkfn(sha, branchName) + if err != nil { + return err + } + i++ + } + // count all refs + for limit != 0 { + _, isPrefix, err := bufReader.ReadLine() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + if !isPrefix { + i++ + } + } + return nil + }) + err = cmd.RunWithStderr(ctx) + if errPipeline, ok := gitcmd.UnwrapPipelineError(err); ok { + return i, errPipeline // keep the old behavior: return pipeline error directly } - for limit == 0 || i < skip+limit { - // The output of show-ref is simply a list: - // SP LF - sha, err := bufReader.ReadString(' ') - if err == io.EOF { - return i, nil - } - if err != nil { - return 0, err - } - - branchName, err := bufReader.ReadString('\n') - if err == io.EOF { - // This shouldn't happen... but we'll tolerate it for the sake of peace - return i, nil - } - if err != nil { - return i, err - } - - if len(branchName) > 0 { - branchName = branchName[:len(branchName)-1] - } - - if len(sha) > 0 { - sha = sha[:len(sha)-1] - } - - err = walkfn(sha, branchName) - if err != nil { - return i, err - } - i++ - } - // count all refs - for limit != 0 { - _, isPrefix, err := bufReader.ReadLine() - if err == io.EOF { - return i, nil - } - if err != nil { - return 0, err - } - if !isPrefix { - i++ - } - } - return i, nil + return i, err } // GetRefsBySha returns all references filtered with prefix that belong to a sha commit hash diff --git a/modules/git/repo_commit.go b/modules/git/repo_commit.go index 5f4487ce7e..c10f73690c 100644 --- a/modules/git/repo_commit.go +++ b/modules/git/repo_commit.go @@ -11,7 +11,6 @@ import ( "strconv" "strings" - "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/setting" ) @@ -216,16 +215,6 @@ func (repo *Repository) FileChangedBetweenCommits(filename, id1, id2 string) (bo return len(strings.TrimSpace(string(stdout))) > 0, nil } -// FileCommitsCount return the number of files at a revision -func (repo *Repository) FileCommitsCount(revision, file string) (int64, error) { - return CommitsCount(repo.Ctx, - CommitsCountOptions{ - RepoPath: repo.Path, - Revision: []string{revision}, - RelPath: []string{file}, - }) -} - type CommitsByFileAndRangeOptions struct { Revision string File string @@ -237,66 +226,55 @@ type CommitsByFileAndRangeOptions struct { // CommitsByFileAndRange return the commits according revision file and the page func (repo *Repository) CommitsByFileAndRange(opts CommitsByFileAndRangeOptions) ([]*Commit, error) { - stdoutReader, stdoutWriter := io.Pipe() - defer func() { - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - }() - go func() { - stderr := strings.Builder{} - gitCmd := gitcmd.NewCommand("rev-list"). - AddOptionFormat("--max-count=%d", setting.Git.CommitsRangeSize). - AddOptionFormat("--skip=%d", (opts.Page-1)*setting.Git.CommitsRangeSize) - gitCmd.AddDynamicArguments(opts.Revision) + gitCmd := gitcmd.NewCommand("rev-list"). + AddOptionFormat("--max-count=%d", setting.Git.CommitsRangeSize). + AddOptionFormat("--skip=%d", (opts.Page-1)*setting.Git.CommitsRangeSize) + gitCmd.AddDynamicArguments(opts.Revision) - if opts.Not != "" { - gitCmd.AddOptionValues("--not", opts.Not) - } - if opts.Since != "" { - gitCmd.AddOptionFormat("--since=%s", opts.Since) - } - if opts.Until != "" { - gitCmd.AddOptionFormat("--until=%s", opts.Until) - } - - gitCmd.AddDashesAndList(opts.File) - err := gitCmd.WithDir(repo.Path). - WithStdout(stdoutWriter). - WithStderr(&stderr). - Run(repo.Ctx) - if err != nil { - _ = stdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String())) - } else { - _ = stdoutWriter.Close() - } - }() - - objectFormat, err := repo.GetObjectFormat() - if err != nil { - return nil, err + if opts.Not != "" { + gitCmd.AddOptionValues("--not", opts.Not) } + if opts.Since != "" { + gitCmd.AddOptionFormat("--since=%s", opts.Since) + } + if opts.Until != "" { + gitCmd.AddOptionFormat("--until=%s", opts.Until) + } + gitCmd.AddDashesAndList(opts.File) - length := objectFormat.FullLength() - commits := []*Commit{} - shaline := make([]byte, length+1) - for { - n, err := io.ReadFull(stdoutReader, shaline) - if err != nil || n < length { - if err == io.EOF { - err = nil + var commits []*Commit + stdoutReader, stdoutReaderClose := gitCmd.MakeStdoutPipe() + defer stdoutReaderClose() + err := gitCmd.WithDir(repo.Path). + WithPipelineFunc(func(context gitcmd.Context) error { + objectFormat, err := repo.GetObjectFormat() + if err != nil { + return err } - return commits, err - } - objectID, err := NewIDFromString(string(shaline[0:length])) - if err != nil { - return nil, err - } - commit, err := repo.getCommit(objectID) - if err != nil { - return nil, err - } - commits = append(commits, commit) - } + + length := objectFormat.FullLength() + shaline := make([]byte, length+1) + for { + n, err := io.ReadFull(stdoutReader, shaline) + if err != nil || n < length { + if err == io.EOF { + err = nil + } + return err + } + objectID, err := NewIDFromString(string(shaline[0:length])) + if err != nil { + return err + } + commit, err := repo.getCommit(objectID) + if err != nil { + return err + } + commits = append(commits, commit) + } + }). + RunWithStderr(repo.Ctx) + return commits, err } // FilesCountBetween return the number of files changed between two commits @@ -433,25 +411,6 @@ func (repo *Repository) CommitsBetweenIDs(last, before string) ([]*Commit, error return repo.CommitsBetween(lastCommit, beforeCommit) } -// CommitsCountBetween return numbers of commits between two commits -func (repo *Repository) CommitsCountBetween(start, end string) (int64, error) { - count, err := CommitsCount(repo.Ctx, CommitsCountOptions{ - RepoPath: repo.Path, - Revision: []string{start + ".." + end}, - }) - - if err != nil && strings.Contains(err.Error(), "no merge base") { - // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated. - // previously it would return the results of git rev-list before last so let's try that... - return CommitsCount(repo.Ctx, CommitsCountOptions{ - RepoPath: repo.Path, - Revision: []string{start, end}, - }) - } - - return count, err -} - // commitsBefore the limit is depth, not total number of returned commits. func (repo *Repository) commitsBefore(id ObjectID, limit int) ([]*Commit, error) { cmd := gitcmd.NewCommand("log", prettyLogFormat) @@ -564,23 +523,6 @@ func (repo *Repository) IsCommitInBranch(commitID, branch string) (r bool, err e return len(stdout) > 0, err } -func (repo *Repository) AddLastCommitCache(cacheKey, fullName, sha string) error { - if repo.LastCommitCache == nil { - commitsCount, err := cache.GetInt64(cacheKey, func() (int64, error) { - commit, err := repo.GetCommit(sha) - if err != nil { - return 0, err - } - return commit.CommitsCount() - }) - if err != nil { - return err - } - repo.LastCommitCache = NewLastCommitCache(commitsCount, fullName, repo, cache.GetCache()) - } - return nil -} - // GetCommitBranchStart returns the commit where the branch diverged func (repo *Repository) GetCommitBranchStart(env []string, branch, endCommitID string) (string, error) { cmd := gitcmd.NewCommand("log", prettyLogFormat) diff --git a/modules/git/repo_commit_gogit.go b/modules/git/repo_commit_gogit.go index 896d656039..550d153722 100644 --- a/modules/git/repo_commit_gogit.go +++ b/modules/git/repo_commit_gogit.go @@ -67,16 +67,6 @@ func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) { return NewIDFromString(actualCommitID) } -// IsCommitExist returns true if given commit exists in current repository. -func (repo *Repository) IsCommitExist(name string) bool { - hash, err := repo.ConvertToGitID(name) - if err != nil { - return false - } - _, err = repo.gogitRepo.CommitObject(plumbing.Hash(hash.RawValue())) - return err == nil -} - func (repo *Repository) getCommit(id ObjectID) (*Commit, error) { var tagObject *object.Tag @@ -107,7 +97,7 @@ func (repo *Repository) getCommit(id ObjectID) (*Commit, error) { } commit.Tree.ID = ParseGogitHash(tree.Hash) - commit.Tree.gogitTree = tree + commit.Tree.resolvedGogitTreeObject = tree return commit, nil } diff --git a/modules/git/repo_commit_nogogit.go b/modules/git/repo_commit_nogogit.go index 3f27833fa6..2ddb527502 100644 --- a/modules/git/repo_commit_nogogit.go +++ b/modules/git/repo_commit_nogogit.go @@ -6,7 +6,6 @@ package git import ( - "bufio" "errors" "io" "strings" @@ -37,50 +36,31 @@ func (repo *Repository) ResolveReference(name string) (string, error) { // GetRefCommitID returns the last commit ID string of given reference (branch or tag). func (repo *Repository) GetRefCommitID(name string) (string, error) { - wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return "", err } defer cancel() - _, err = wr.Write([]byte(name + "\n")) - if err != nil { - return "", err - } - shaBs, _, _, err := ReadBatchLine(rd) + info, err := batch.QueryInfo(name) if IsErrNotExist(err) { return "", ErrNotExist{name, ""} + } else if err != nil { + return "", err } - - return string(shaBs), nil -} - -// IsCommitExist returns true if given commit exists in current repository. -func (repo *Repository) IsCommitExist(name string) bool { - if err := ensureValidGitRepository(repo.Ctx, repo.Path); err != nil { - log.Error("IsCommitExist: %v", err) - return false - } - _, _, err := gitcmd.NewCommand("cat-file", "-e"). - AddDynamicArguments(name). - WithDir(repo.Path). - RunStdString(repo.Ctx) - return err == nil + return info.ID, nil } func (repo *Repository) getCommit(id ObjectID) (*Commit, error) { - wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return nil, err } defer cancel() - - _, _ = wr.Write([]byte(id.String() + "\n")) - - return repo.getCommitFromBatchReader(wr, rd, id) + return repo.getCommitWithBatch(batch, id) } -func (repo *Repository) getCommitFromBatchReader(wr WriteCloserError, rd *bufio.Reader, id ObjectID) (*Commit, error) { - _, typ, size, err := ReadBatchLine(rd) +func (repo *Repository) getCommitWithBatch(batch CatFileBatch, id ObjectID) (*Commit, error) { + info, rd, err := batch.QueryContent(id.String()) if err != nil { if errors.Is(err, io.EOF) || IsErrNotExist(err) { return nil, ErrNotExist{ID: id.String()} @@ -88,13 +68,13 @@ func (repo *Repository) getCommitFromBatchReader(wr WriteCloserError, rd *bufio. return nil, err } - switch typ { + switch info.Type { case "missing": return nil, ErrNotExist{ID: id.String()} case "tag": // then we need to parse the tag // and load the commit - data, err := io.ReadAll(io.LimitReader(rd, size)) + data, err := io.ReadAll(io.LimitReader(rd, info.Size)) if err != nil { return nil, err } @@ -106,19 +86,9 @@ func (repo *Repository) getCommitFromBatchReader(wr WriteCloserError, rd *bufio. if err != nil { return nil, err } - - if _, err := wr.Write([]byte(tag.Object.String() + "\n")); err != nil { - return nil, err - } - - commit, err := repo.getCommitFromBatchReader(wr, rd, tag.Object) - if err != nil { - return nil, err - } - - return commit, nil + return repo.getCommitWithBatch(batch, tag.Object) case "commit": - commit, err := CommitFromReader(repo, id, io.LimitReader(rd, size)) + commit, err := CommitFromReader(repo, id, io.LimitReader(rd, info.Size)) if err != nil { return nil, err } @@ -129,8 +99,8 @@ func (repo *Repository) getCommitFromBatchReader(wr WriteCloserError, rd *bufio. return commit, nil default: - log.Debug("Unknown typ: %s", typ) - if err := DiscardFull(rd, size+1); err != nil { + log.Debug("Unknown cat-file object type: %s", info.Type) + if err := DiscardFull(rd, info.Size+1); err != nil { return nil, err } return nil, ErrNotExist{ @@ -152,16 +122,12 @@ func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) { } } - wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return nil, err } defer cancel() - _, err = wr.Write([]byte(commitID + "\n")) - if err != nil { - return nil, err - } - sha, _, _, err := ReadBatchLine(rd) + info, err := batch.QueryInfo(commitID) if err != nil { if IsErrNotExist(err) { return nil, ErrNotExist{commitID, ""} @@ -169,5 +135,5 @@ func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) { return nil, err } - return MustIDFromString(string(sha)), nil + return MustIDFromString(info.ID), nil } diff --git a/modules/git/repo_compare.go b/modules/git/repo_compare.go index f60696a763..aa25e2ec20 100644 --- a/modules/git/repo_compare.go +++ b/modules/git/repo_compare.go @@ -18,32 +18,6 @@ import ( "code.gitea.io/gitea/modules/git/gitcmd" ) -// GetMergeBase checks and returns merge base of two branches and the reference used as base. -func (repo *Repository) GetMergeBase(tmpRemote, base, head string) (string, string, error) { - if tmpRemote == "" { - tmpRemote = "origin" - } - - if tmpRemote != "origin" { - tmpBaseName := RemotePrefix + tmpRemote + "/tmp_" + base - // Fetch commit into a temporary branch in order to be able to handle commits and tags - _, _, err := gitcmd.NewCommand("fetch", "--no-tags"). - AddDynamicArguments(tmpRemote). - AddDashesAndList(base + ":" + tmpBaseName). - WithDir(repo.Path). - RunStdString(repo.Ctx) - if err == nil { - base = tmpBaseName - } - } - - stdout, _, err := gitcmd.NewCommand("merge-base"). - AddDashesAndList(base, head). - WithDir(repo.Path). - RunStdString(repo.Ctx) - return strings.TrimSpace(stdout), base, err -} - type lineCountWriter struct { numLines int } @@ -60,7 +34,6 @@ func (l *lineCountWriter) Write(p []byte) (n int, err error) { func (repo *Repository) GetDiffNumChangedFiles(base, head string, directComparison bool) (int, error) { // Now there is git diff --shortstat but this appears to be slower than simply iterating with --nameonly w := &lineCountWriter{} - stderr := new(bytes.Buffer) separator := "..." if directComparison { @@ -72,25 +45,22 @@ func (repo *Repository) GetDiffNumChangedFiles(base, head string, directComparis AddDynamicArguments(base + separator + head). AddArguments("--"). WithDir(repo.Path). - WithStdout(w). - WithStderr(stderr). - Run(repo.Ctx); err != nil { - if strings.Contains(stderr.String(), "no merge base") { + WithStdoutCopy(w). + RunWithStderr(repo.Ctx); err != nil { + if strings.Contains(err.Stderr(), "no merge base") { // git >= 2.28 now returns an error if base and head have become unrelated. // previously it would return the results of git diff -z --name-only base head so let's try that... w = &lineCountWriter{} - stderr.Reset() if err = gitcmd.NewCommand("diff", "-z", "--name-only"). AddDynamicArguments(base, head). AddArguments("--"). WithDir(repo.Path). - WithStdout(w). - WithStderr(stderr). - Run(repo.Ctx); err == nil { + WithStdoutCopy(w). + RunWithStderr(repo.Ctx); err == nil { return w.numLines, nil } } - return 0, fmt.Errorf("%w: Stderr: %s", err, stderr) + return 0, err } return w.numLines, nil } @@ -99,11 +69,9 @@ var patchCommits = regexp.MustCompile(`^From\s(\w+)\s`) // GetDiff generates and returns patch data between given revisions, optimized for human readability func (repo *Repository) GetDiff(compareArg string, w io.Writer) error { - stderr := new(bytes.Buffer) return gitcmd.NewCommand("diff", "-p").AddDynamicArguments(compareArg). WithDir(repo.Path). - WithStdout(w). - WithStderr(stderr). + WithStdoutCopy(w). Run(repo.Ctx) } @@ -112,17 +80,15 @@ func (repo *Repository) GetDiffBinary(compareArg string, w io.Writer) error { return gitcmd.NewCommand("diff", "-p", "--binary", "--histogram"). AddDynamicArguments(compareArg). WithDir(repo.Path). - WithStdout(w). + WithStdoutCopy(w). Run(repo.Ctx) } // GetPatch generates and returns format-patch data between given revisions, able to be used with `git apply` func (repo *Repository) GetPatch(compareArg string, w io.Writer) error { - stderr := new(bytes.Buffer) return gitcmd.NewCommand("format-patch", "--binary", "--stdout").AddDynamicArguments(compareArg). WithDir(repo.Path). - WithStdout(w). - WithStderr(stderr). + WithStdoutCopy(w). Run(repo.Ctx) } diff --git a/modules/git/repo_gpg.go b/modules/git/repo_gpg.go deleted file mode 100644 index eb1e71e30a..0000000000 --- a/modules/git/repo_gpg.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2015 The Gogs Authors. All rights reserved. -// Copyright 2017 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package git - -import ( - "fmt" - "os" - "strings" - - "code.gitea.io/gitea/modules/git/gitcmd" - "code.gitea.io/gitea/modules/process" -) - -// LoadPublicKeyContent will load the key from gpg -func (gpgSettings *GPGSettings) LoadPublicKeyContent() error { - if gpgSettings.Format == SigningKeyFormatSSH { - content, err := os.ReadFile(gpgSettings.KeyID) - if err != nil { - return fmt.Errorf("unable to read SSH public key file: %s, %w", gpgSettings.KeyID, err) - } - gpgSettings.PublicKeyContent = string(content) - return nil - } - content, stderr, err := process.GetManager().Exec( - "gpg -a --export", - "gpg", "-a", "--export", gpgSettings.KeyID) - if err != nil { - return fmt.Errorf("unable to get default signing key: %s, %s, %w", gpgSettings.KeyID, stderr, err) - } - gpgSettings.PublicKeyContent = content - return nil -} - -// GetDefaultPublicGPGKey will return and cache the default public GPG settings for this repository -func (repo *Repository) GetDefaultPublicGPGKey(forceUpdate bool) (*GPGSettings, error) { - if repo.gpgSettings != nil && !forceUpdate { - return repo.gpgSettings, nil - } - - gpgSettings := &GPGSettings{ - Sign: true, - } - - value, _, _ := gitcmd.NewCommand("config", "--get", "commit.gpgsign").WithDir(repo.Path).RunStdString(repo.Ctx) - sign, valid := ParseBool(strings.TrimSpace(value)) - if !sign || !valid { - gpgSettings.Sign = false - repo.gpgSettings = gpgSettings - return gpgSettings, nil - } - - signingKey, _, _ := gitcmd.NewCommand("config", "--get", "user.signingkey").WithDir(repo.Path).RunStdString(repo.Ctx) - gpgSettings.KeyID = strings.TrimSpace(signingKey) - - format, _, _ := gitcmd.NewCommand("config", "--default", SigningKeyFormatOpenPGP, "--get", "gpg.format").WithDir(repo.Path).RunStdString(repo.Ctx) - gpgSettings.Format = strings.TrimSpace(format) - - defaultEmail, _, _ := gitcmd.NewCommand("config", "--get", "user.email").WithDir(repo.Path).RunStdString(repo.Ctx) - gpgSettings.Email = strings.TrimSpace(defaultEmail) - - defaultName, _, _ := gitcmd.NewCommand("config", "--get", "user.name").WithDir(repo.Path).RunStdString(repo.Ctx) - gpgSettings.Name = strings.TrimSpace(defaultName) - - if err := gpgSettings.LoadPublicKeyContent(); err != nil { - return nil, err - } - repo.gpgSettings = gpgSettings - return repo.gpgSettings, nil -} diff --git a/modules/git/repo_index.go b/modules/git/repo_index.go index 4068f86bb2..1d040d5e0a 100644 --- a/modules/git/repo_index.go +++ b/modules/git/repo_index.go @@ -101,21 +101,17 @@ func (repo *Repository) RemoveFilesFromIndex(filenames ...string) error { return err } cmd := gitcmd.NewCommand("update-index", "--remove", "-z", "--index-info") - stdout := new(bytes.Buffer) - stderr := new(bytes.Buffer) - buffer := new(bytes.Buffer) + input := new(bytes.Buffer) for _, file := range filenames { if file != "" { // using format: mode SP type SP sha1 TAB path - buffer.WriteString("0 blob " + objectFormat.EmptyObjectID().String() + "\t" + file + "\000") + input.WriteString("0 blob " + objectFormat.EmptyObjectID().String() + "\t" + file + "\000") } } return cmd. WithDir(repo.Path). - WithStdin(bytes.NewReader(buffer.Bytes())). - WithStdout(stdout). - WithStderr(stderr). - Run(repo.Ctx) + WithStdinBytes(input.Bytes()). + RunWithStderr(repo.Ctx) } type IndexObjectInfo struct { @@ -127,19 +123,15 @@ type IndexObjectInfo struct { // AddObjectsToIndex adds the provided object hashes to the index at the provided filenames func (repo *Repository) AddObjectsToIndex(objects ...IndexObjectInfo) error { cmd := gitcmd.NewCommand("update-index", "--add", "--replace", "-z", "--index-info") - stdout := new(bytes.Buffer) - stderr := new(bytes.Buffer) - buffer := new(bytes.Buffer) + input := new(bytes.Buffer) for _, object := range objects { // using format: mode SP type SP sha1 TAB path - buffer.WriteString(object.Mode + " blob " + object.Object.String() + "\t" + object.Filename + "\000") + input.WriteString(object.Mode + " blob " + object.Object.String() + "\t" + object.Filename + "\000") } return cmd. WithDir(repo.Path). - WithStdin(bytes.NewReader(buffer.Bytes())). - WithStdout(stdout). - WithStderr(stderr). - Run(repo.Ctx) + WithStdinBytes(input.Bytes()). + RunWithStderr(repo.Ctx) } // AddObjectToIndex adds the provided object hash to the index at the provided filename diff --git a/modules/git/repo_object.go b/modules/git/repo_object.go index 2a39a3c4d8..38e16b4646 100644 --- a/modules/git/repo_object.go +++ b/modules/git/repo_object.go @@ -5,8 +5,6 @@ package git import ( - "bytes" - "io" "strings" "code.gitea.io/gitea/modules/git/gitcmd" @@ -33,18 +31,12 @@ func (o ObjectType) Bytes() []byte { return []byte(o) } -type EmptyReader struct{} - -func (EmptyReader) Read(p []byte) (int, error) { - return 0, io.EOF -} - func (repo *Repository) GetObjectFormat() (ObjectFormat, error) { if repo != nil && repo.objectFormat != nil { return repo.objectFormat, nil } - str, err := repo.hashObject(EmptyReader{}, false) + str, err := repo.hashObjectBytes(nil, false) if err != nil { return nil, err } @@ -58,32 +50,28 @@ func (repo *Repository) GetObjectFormat() (ObjectFormat, error) { return repo.objectFormat, nil } -// HashObject takes a reader and returns hash for that reader -func (repo *Repository) HashObject(reader io.Reader) (ObjectID, error) { - idStr, err := repo.hashObject(reader, true) +// HashObjectBytes returns hash for the content +func (repo *Repository) HashObjectBytes(buf []byte) (ObjectID, error) { + idStr, err := repo.hashObjectBytes(buf, true) if err != nil { return nil, err } return NewIDFromString(idStr) } -func (repo *Repository) hashObject(reader io.Reader, save bool) (string, error) { +func (repo *Repository) hashObjectBytes(buf []byte, save bool) (string, error) { var cmd *gitcmd.Command if save { cmd = gitcmd.NewCommand("hash-object", "-w", "--stdin") } else { cmd = gitcmd.NewCommand("hash-object", "--stdin") } - stdout := new(bytes.Buffer) - stderr := new(bytes.Buffer) - err := cmd. + stdout, _, err := cmd. WithDir(repo.Path). - WithStdin(reader). - WithStdout(stdout). - WithStderr(stderr). - Run(repo.Ctx) + WithStdinBytes(buf). + RunStdString(repo.Ctx) if err != nil { return "", err } - return strings.TrimSpace(stdout.String()), nil + return strings.TrimSpace(stdout), nil } diff --git a/modules/git/repo_ref_nogogit.go b/modules/git/repo_ref_nogogit.go index 09bb0df7b8..c58992fa9d 100644 --- a/modules/git/repo_ref_nogogit.go +++ b/modules/git/repo_ref_nogogit.go @@ -15,75 +15,61 @@ import ( // GetRefsFiltered returns all references of the repository that matches patterm exactly or starting with. func (repo *Repository) GetRefsFiltered(pattern string) ([]*Reference, error) { - stdoutReader, stdoutWriter := io.Pipe() - defer func() { - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - }() - - go func() { - stderrBuilder := &strings.Builder{} - err := gitcmd.NewCommand("for-each-ref"). - WithDir(repo.Path). - WithStdout(stdoutWriter). - WithStderr(stderrBuilder). - Run(repo.Ctx) - if err != nil { - _ = stdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, stderrBuilder.String())) - } else { - _ = stdoutWriter.Close() - } - }() - refs := make([]*Reference, 0) - bufReader := bufio.NewReader(stdoutReader) - for { - // The output of for-each-ref is simply a list: - // SP TAB LF - sha, err := bufReader.ReadString(' ') - if err == io.EOF { - break - } - if err != nil { - return nil, err - } - sha = sha[:len(sha)-1] + cmd := gitcmd.NewCommand("for-each-ref") + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + defer stdoutReaderClose() + err := cmd.WithDir(repo.Path). + WithPipelineFunc(func(context gitcmd.Context) error { + bufReader := bufio.NewReader(stdoutReader) + for { + // The output of for-each-ref is simply a list: + // SP TAB LF + sha, err := bufReader.ReadString(' ') + if err == io.EOF { + break + } + if err != nil { + return err + } + sha = sha[:len(sha)-1] - typ, err := bufReader.ReadString('\t') - if err == io.EOF { - // This should not happen, but we'll tolerate it - break - } - if err != nil { - return nil, err - } - typ = typ[:len(typ)-1] + typ, err := bufReader.ReadString('\t') + if err == io.EOF { + // This should not happen, but we'll tolerate it + break + } + if err != nil { + return err + } + typ = typ[:len(typ)-1] - refName, err := bufReader.ReadString('\n') - if err == io.EOF { - // This should not happen, but we'll tolerate it - break - } - if err != nil { - return nil, err - } - refName = refName[:len(refName)-1] + refName, err := bufReader.ReadString('\n') + if err == io.EOF { + // This should not happen, but we'll tolerate it + break + } + if err != nil { + return err + } + refName = refName[:len(refName)-1] - // refName cannot be HEAD but can be remotes or stash - if strings.HasPrefix(refName, RemotePrefix) || refName == "/refs/stash" { - continue - } + // refName cannot be HEAD but can be remotes or stash + if strings.HasPrefix(refName, RemotePrefix) || refName == "/refs/stash" { + continue + } - if pattern == "" || strings.HasPrefix(refName, pattern) { - r := &Reference{ - Name: refName, - Object: MustIDFromString(sha), - Type: typ, - repo: repo, + if pattern == "" || strings.HasPrefix(refName, pattern) { + r := &Reference{ + Name: refName, + Object: MustIDFromString(sha), + Type: typ, + repo: repo, + } + refs = append(refs, r) + } } - refs = append(refs, r) - } - } - - return refs, nil + return nil + }).RunWithStderr(repo.Ctx) + return refs, err } diff --git a/modules/git/repo_stats.go b/modules/git/repo_stats.go index cfb35288fe..1dd77f05d4 100644 --- a/modules/git/repo_stats.go +++ b/modules/git/repo_stats.go @@ -5,9 +5,7 @@ package git import ( "bufio" - "context" "fmt" - "os" "sort" "strconv" "strings" @@ -55,15 +53,6 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string) } stats.CommitCountInAllBranches = c - stdoutReader, stdoutWriter, err := os.Pipe() - if err != nil { - return nil, err - } - defer func() { - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - }() - gitCmd := gitcmd.NewCommand("log", "--numstat", "--no-merges", "--pretty=format:---%n%h%n%aN%n%aE%n", "--date=iso"). AddOptionFormat("--since=%s", since) if len(branch) == 0 { @@ -72,13 +61,11 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string) gitCmd.AddArguments("--first-parent").AddDynamicArguments(branch) } - stderr := new(strings.Builder) + stdoutReader, stdoutReaderClose := gitCmd.MakeStdoutPipe() + defer stdoutReaderClose() err = gitCmd. WithDir(repo.Path). - WithStdout(stdoutWriter). - WithStderr(stderr). - WithPipelineFunc(func(ctx context.Context, cancel context.CancelFunc) error { - _ = stdoutWriter.Close() + WithPipelineFunc(func(ctx gitcmd.Context) error { scanner := bufio.NewScanner(stdoutReader) scanner.Split(bufio.ScanLines) stats.CommitCount = 0 @@ -129,7 +116,6 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string) } } if err = scanner.Err(); err != nil { - _ = stdoutReader.Close() return fmt.Errorf("GetCodeActivityStats scan: %w", err) } a := make([]*CodeActivityAuthor, 0, len(authors)) @@ -143,12 +129,11 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string) stats.AuthorCount = int64(len(authors)) stats.ChangedFiles = int64(len(files)) stats.Authors = a - _ = stdoutReader.Close() return nil }). - Run(repo.Ctx) + RunWithStderr(repo.Ctx) if err != nil { - return nil, fmt.Errorf("Failed to get GetCodeActivityStats for repository.\nError: %w\nStderr: %s", err, stderr) + return nil, fmt.Errorf("GetCodeActivityStats: %w", err) } return stats, nil diff --git a/modules/git/repo_tag.go b/modules/git/repo_tag.go index 4ad0c6e5ab..2599236ae0 100644 --- a/modules/git/repo_tag.go +++ b/modules/git/repo_tag.go @@ -6,7 +6,6 @@ package git import ( "fmt" - "io" "strings" "code.gitea.io/gitea/modules/git/foreachref" @@ -115,51 +114,42 @@ func (repo *Repository) GetTagInfos(page, pageSize int) ([]*Tag, int, error) { // https://git-scm.com/docs/git-for-each-ref#Documentation/git-for-each-ref.txt-refname forEachRefFmt := foreachref.NewFormat("objecttype", "refname:lstrip=2", "object", "objectname", "creator", "contents", "contents:signature") - stdoutReader, stdoutWriter := io.Pipe() - defer stdoutReader.Close() - defer stdoutWriter.Close() - stderr := strings.Builder{} - - go func() { - err := gitcmd.NewCommand("for-each-ref"). - AddOptionFormat("--format=%s", forEachRefFmt.Flag()). - AddArguments("--sort", "-*creatordate", "refs/tags"). - WithDir(repo.Path). - WithStdout(stdoutWriter). - WithStderr(&stderr). - Run(repo.Ctx) - if err != nil { - _ = stdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, stderr.String())) - } else { - _ = stdoutWriter.Close() - } - }() - var tags []*Tag - parser := forEachRefFmt.Parser(stdoutReader) - for { - ref := parser.Next() - if ref == nil { - break - } + var tagsTotal int + cmd := gitcmd.NewCommand("for-each-ref") + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + defer stdoutReaderClose() + err := cmd.AddOptionFormat("--format=%s", forEachRefFmt.Flag()). + AddArguments("--sort", "-*creatordate", "refs/tags"). + WithDir(repo.Path). + WithPipelineFunc(func(context gitcmd.Context) error { + parser := forEachRefFmt.Parser(stdoutReader) + for { + ref := parser.Next() + if ref == nil { + break + } - tag, err := parseTagRef(ref) - if err != nil { - return nil, 0, fmt.Errorf("GetTagInfos: parse tag: %w", err) - } - tags = append(tags, tag) - } - if err := parser.Err(); err != nil { - return nil, 0, fmt.Errorf("GetTagInfos: parse output: %w", err) - } + tag, err := parseTagRef(ref) + if err != nil { + return fmt.Errorf("GetTagInfos: parse tag: %w", err) + } + tags = append(tags, tag) + } + if err := parser.Err(); err != nil { + return fmt.Errorf("GetTagInfos: parse output: %w", err) + } - sortTagsByTime(tags) - tagsTotal := len(tags) - if page != 0 { - tags = util.PaginateSlice(tags, page, pageSize).([]*Tag) - } + sortTagsByTime(tags) + tagsTotal = len(tags) + if page != 0 { + tags = util.PaginateSlice(tags, page, pageSize).([]*Tag) + } + return nil + }). + RunWithStderr(repo.Ctx) - return tags, tagsTotal, nil + return tags, tagsTotal, err } // parseTagRef parses a tag from a 'git for-each-ref'-produced reference. diff --git a/modules/git/repo_tag_nogogit.go b/modules/git/repo_tag_nogogit.go index 5f79b68a9a..a9ac040821 100644 --- a/modules/git/repo_tag_nogogit.go +++ b/modules/git/repo_tag_nogogit.go @@ -24,23 +24,19 @@ func (repo *Repository) IsTagExist(name string) bool { // GetTagType gets the type of the tag, either commit (simple) or tag (annotated) func (repo *Repository) GetTagType(id ObjectID) (string, error) { - wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return "", err } defer cancel() - _, err = wr.Write([]byte(id.String() + "\n")) - if err != nil { - return "", err - } - _, typ, _, err := ReadBatchLine(rd) + info, err := batch.QueryInfo(id.String()) if err != nil { if IsErrNotExist(err) { return "", ErrNotExist{ID: id.String()} } return "", err } - return typ, nil + return info.Type, nil } func (repo *Repository) getTag(tagID ObjectID, name string) (*Tag, error) { @@ -88,22 +84,20 @@ func (repo *Repository) getTag(tagID ObjectID, name string) (*Tag, error) { } // The tag is an annotated tag with a message. - wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return nil, err } defer cancel() - if _, err := wr.Write([]byte(tagID.String() + "\n")); err != nil { - return nil, err - } - _, typ, size, err := ReadBatchLine(rd) + info, rd, err := batch.QueryContent(tagID.String()) if err != nil { if errors.Is(err, io.EOF) || IsErrNotExist(err) { return nil, ErrNotExist{ID: tagID.String()} } return nil, err } + typ, size := info.Type, info.Size if typ != "tag" { if err := DiscardFull(rd, size+1); err != nil { return nil, err diff --git a/modules/git/repo_test.go b/modules/git/repo_test.go index 26ee3a091a..776c297a34 100644 --- a/modules/git/repo_test.go +++ b/modules/git/repo_test.go @@ -10,16 +10,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGetLatestCommitTime(t *testing.T) { - bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") - lct, err := GetLatestCommitTime(t.Context(), bareRepo1Path) - assert.NoError(t, err) - // Time is Sun Nov 13 16:40:14 2022 +0100 - // which is the time of commit - // ce064814f4a0d337b333e646ece456cd39fab612 (refs/heads/master) - assert.EqualValues(t, 1668354014, lct.Unix()) -} - func TestRepoIsEmpty(t *testing.T) { emptyRepo2Path := filepath.Join(testReposDir, "repo2_empty") repo, err := OpenRepository(t.Context(), emptyRepo2Path) diff --git a/modules/git/repo_tree.go b/modules/git/repo_tree.go index 964342ba00..e65e2441ed 100644 --- a/modules/git/repo_tree.go +++ b/modules/git/repo_tree.go @@ -58,16 +58,12 @@ func (repo *Repository) CommitTree(author, committer *Signature, tree *Tree, opt cmd.AddArguments("--no-gpg-sign") } - stdout := new(bytes.Buffer) - stderr := new(bytes.Buffer) - err := cmd.WithEnv(env). + stdout, _, err := cmd.WithEnv(env). WithDir(repo.Path). - WithStdin(messageBytes). - WithStdout(stdout). - WithStderr(stderr). - Run(repo.Ctx) + WithStdinBytes(messageBytes.Bytes()). + RunStdString(repo.Ctx) if err != nil { - return nil, gitcmd.ConcatenateError(err, stderr.String()) + return nil, err } - return NewIDFromString(strings.TrimSpace(stdout.String())) + return NewIDFromString(strings.TrimSpace(stdout)) } diff --git a/modules/git/repo_tree_gogit.go b/modules/git/repo_tree_gogit.go index e15663a32a..89d34e87da 100644 --- a/modules/git/repo_tree_gogit.go +++ b/modules/git/repo_tree_gogit.go @@ -26,7 +26,7 @@ func (repo *Repository) getTree(id ObjectID) (*Tree, error) { } tree := NewTree(repo, id) - tree.gogitTree = gogitTree + tree.resolvedGogitTreeObject = gogitTree return tree, nil } diff --git a/modules/git/repo_tree_nogogit.go b/modules/git/repo_tree_nogogit.go index 1954f85162..82a61072c9 100644 --- a/modules/git/repo_tree_nogogit.go +++ b/modules/git/repo_tree_nogogit.go @@ -10,24 +10,21 @@ import ( ) func (repo *Repository) getTree(id ObjectID) (*Tree, error) { - wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return nil, err } defer cancel() - _, _ = wr.Write([]byte(id.String() + "\n")) - - // ignore the SHA - _, typ, size, err := ReadBatchLine(rd) + info, rd, err := batch.QueryContent(id.String()) if err != nil { return nil, err } - switch typ { + switch info.Type { case "tag": resolvedID := id - data, err := io.ReadAll(io.LimitReader(rd, size)) + data, err := io.ReadAll(io.LimitReader(rd, info.Size)) if err != nil { return nil, err } @@ -36,17 +33,14 @@ func (repo *Repository) getTree(id ObjectID) (*Tree, error) { return nil, err } - if _, err := wr.Write([]byte(tag.Object.String() + "\n")); err != nil { - return nil, err - } - commit, err := repo.getCommitFromBatchReader(wr, rd, tag.Object) + commit, err := repo.getCommitWithBatch(batch, tag.Object) if err != nil { return nil, err } commit.Tree.ResolvedID = resolvedID return &commit.Tree, nil case "commit": - commit, err := CommitFromReader(repo, id, io.LimitReader(rd, size)) + commit, err := CommitFromReader(repo, id, io.LimitReader(rd, info.Size)) if err != nil { return nil, err } @@ -62,14 +56,14 @@ func (repo *Repository) getTree(id ObjectID) (*Tree, error) { if err != nil { return nil, err } - tree.entries, err = catBatchParseTreeEntries(objectFormat, tree, rd, size) + tree.entries, err = catBatchParseTreeEntries(objectFormat, tree, rd, info.Size) if err != nil { return nil, err } tree.entriesParsed = true return tree, nil default: - if err := DiscardFull(rd, size+1); err != nil { + if err := DiscardFull(rd, info.Size+1); err != nil { return nil, err } return nil, ErrNotExist{ diff --git a/modules/git/submodule.go b/modules/git/submodule.go index 45059eae77..ed69cbe55d 100644 --- a/modules/git/submodule.go +++ b/modules/git/submodule.go @@ -7,7 +7,6 @@ import ( "bufio" "context" "fmt" - "os" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/log" @@ -21,23 +20,15 @@ type TemplateSubmoduleCommit struct { // GetTemplateSubmoduleCommits returns a list of submodules paths and their commits from a repository // This function is only for generating new repos based on existing template, the template couldn't be too large. func GetTemplateSubmoduleCommits(ctx context.Context, repoPath string) (submoduleCommits []TemplateSubmoduleCommit, _ error) { - stdoutReader, stdoutWriter, err := os.Pipe() - if err != nil { - return nil, err - } - - err = gitcmd.NewCommand("ls-tree", "-r", "--", "HEAD"). - WithDir(repoPath). - WithStdout(stdoutWriter). - WithPipelineFunc(func(ctx context.Context, cancel context.CancelFunc) error { - _ = stdoutWriter.Close() - defer stdoutReader.Close() - + cmd := gitcmd.NewCommand("ls-tree", "-r", "--", "HEAD") + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + defer stdoutReaderClose() + err := cmd.WithDir(repoPath). + WithPipelineFunc(func(ctx gitcmd.Context) error { scanner := bufio.NewScanner(stdoutReader) for scanner.Scan() { entry, err := parseLsTreeLine(scanner.Bytes()) if err != nil { - cancel() return err } if entry.EntryMode == EntryModeCommit { diff --git a/modules/git/tree.go b/modules/git/tree.go index 9c73aec735..c1898b20cb 100644 --- a/modules/git/tree.go +++ b/modules/git/tree.go @@ -11,11 +11,21 @@ import ( "code.gitea.io/gitea/modules/git/gitcmd" ) +type TreeCommon struct { + ID ObjectID + ResolvedID ObjectID + + repo *Repository + ptree *Tree // parent tree +} + // NewTree create a new tree according the repository and tree id func NewTree(repo *Repository, id ObjectID) *Tree { return &Tree{ - ID: id, - repo: repo, + TreeCommon: TreeCommon{ + ID: id, + repo: repo, + }, } } diff --git a/modules/git/tree_blob_gogit.go b/modules/git/tree_blob_gogit.go index f29e8f8b9e..2c0ff0e1b0 100644 --- a/modules/git/tree_blob_gogit.go +++ b/modules/git/tree_blob_gogit.go @@ -11,22 +11,16 @@ import ( "strings" "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/filemode" - "github.com/go-git/go-git/v5/plumbing/object" ) // GetTreeEntryByPath get the tree entries according the sub dir func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) { if len(relpath) == 0 { return &TreeEntry{ - ID: t.ID, - // Type: ObjectTree, - ptree: t, - gogitTreeEntry: &object.TreeEntry{ - Name: "", - Mode: filemode.Dir, - Hash: plumbing.Hash(t.ID.RawValue()), - }, + ID: t.ID, + ptree: t, + name: "", + entryMode: EntryModeTree, }, nil } diff --git a/modules/git/tree_entry.go b/modules/git/tree_entry.go index 5099d8ee79..e7e4ea2d5b 100644 --- a/modules/git/tree_entry.go +++ b/modules/git/tree_entry.go @@ -6,12 +6,60 @@ package git import ( "path" - "sort" + "slices" "strings" "code.gitea.io/gitea/modules/util" ) +// TreeEntry the leaf in the git tree +type TreeEntry struct { + ID ObjectID + + name string + ptree *Tree + + entryMode EntryMode + + size int64 + sized bool +} + +// Name returns the name of the entry (base name) +func (te *TreeEntry) Name() string { + return te.name +} + +// Mode returns the mode of the entry +func (te *TreeEntry) Mode() EntryMode { + return te.entryMode +} + +// IsSubModule if the entry is a submodule +func (te *TreeEntry) IsSubModule() bool { + return te.entryMode.IsSubModule() +} + +// IsDir if the entry is a sub dir +func (te *TreeEntry) IsDir() bool { + return te.entryMode.IsDir() +} + +// IsLink if the entry is a symlink +func (te *TreeEntry) IsLink() bool { + return te.entryMode.IsLink() +} + +// IsRegular if the entry is a regular file +func (te *TreeEntry) IsRegular() bool { + return te.entryMode.IsRegular() +} + +// IsExecutable if the entry is an executable file (not necessarily binary) +func (te *TreeEntry) IsExecutable() bool { + return te.entryMode.IsExecutable() +} + // Type returns the type of the entry (commit, tree, blob) func (te *TreeEntry) Type() string { switch te.Mode() { @@ -109,49 +157,16 @@ func (te *TreeEntry) GetSubJumpablePathName() string { // Entries a list of entry type Entries []*TreeEntry -type customSortableEntries struct { - Comparer func(s1, s2 string) bool - Entries -} - -var sorter = []func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool{ - func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool { - return (t1.IsDir() || t1.IsSubModule()) && !t2.IsDir() && !t2.IsSubModule() - }, - func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool { - return cmp(t1.Name(), t2.Name()) - }, -} - -func (ctes customSortableEntries) Len() int { return len(ctes.Entries) } - -func (ctes customSortableEntries) Swap(i, j int) { - ctes.Entries[i], ctes.Entries[j] = ctes.Entries[j], ctes.Entries[i] -} - -func (ctes customSortableEntries) Less(i, j int) bool { - t1, t2 := ctes.Entries[i], ctes.Entries[j] - var k int - for k = 0; k < len(sorter)-1; k++ { - s := sorter[k] - switch { - case s(t1, t2, ctes.Comparer): - return true - case s(t2, t1, ctes.Comparer): - return false - } - } - return sorter[k](t1, t2, ctes.Comparer) -} - -// Sort sort the list of entry -func (tes Entries) Sort() { - sort.Sort(customSortableEntries{func(s1, s2 string) bool { - return s1 < s2 - }, tes}) -} - // CustomSort customizable string comparing sort entry list -func (tes Entries) CustomSort(cmp func(s1, s2 string) bool) { - sort.Sort(customSortableEntries{cmp, tes}) +func (tes Entries) CustomSort(cmp func(s1, s2 string) int) { + slices.SortFunc(tes, func(a, b *TreeEntry) int { + s1Dir, s2Dir := a.IsDir() || a.IsSubModule(), b.IsDir() || b.IsSubModule() + if s1Dir != s2Dir { + if s1Dir { + return -1 + } + return 1 + } + return cmp(a.Name(), b.Name()) + }) } diff --git a/modules/git/tree_entry_gogit.go b/modules/git/tree_entry_gogit.go index e6845f1c77..f092e70636 100644 --- a/modules/git/tree_entry_gogit.go +++ b/modules/git/tree_entry_gogit.go @@ -12,25 +12,21 @@ import ( "github.com/go-git/go-git/v5/plumbing/object" ) -// TreeEntry the leaf in the git tree -type TreeEntry struct { - ID ObjectID - - gogitTreeEntry *object.TreeEntry - ptree *Tree - - size int64 - sized bool +// gogitFileModeToEntryMode converts go-git filemode to EntryMode +func gogitFileModeToEntryMode(mode filemode.FileMode) EntryMode { + return EntryMode(mode) } -// Name returns the name of the entry -func (te *TreeEntry) Name() string { - return te.gogitTreeEntry.Name +func entryModeToGogitFileMode(mode EntryMode) filemode.FileMode { + return filemode.FileMode(mode) } -// Mode returns the mode of the entry -func (te *TreeEntry) Mode() EntryMode { - return EntryMode(te.gogitTreeEntry.Mode) +func (te *TreeEntry) toGogitTreeEntry() *object.TreeEntry { + return &object.TreeEntry{ + Name: te.name, + Mode: entryModeToGogitFileMode(te.entryMode), + Hash: plumbing.Hash(te.ID.RawValue()), + } } // Size returns the size of the entry @@ -41,7 +37,11 @@ func (te *TreeEntry) Size() int64 { return te.size } - file, err := te.ptree.gogitTree.TreeEntryFile(te.gogitTreeEntry) + ptreeGogitTree, err := te.ptree.gogitTreeObject() + if err != nil { + return 0 + } + file, err := ptreeGogitTree.TreeEntryFile(te.toGogitTreeEntry()) if err != nil { return 0 } @@ -51,41 +51,11 @@ func (te *TreeEntry) Size() int64 { return te.size } -// IsSubModule if the entry is a submodule -func (te *TreeEntry) IsSubModule() bool { - return te.gogitTreeEntry.Mode == filemode.Submodule -} - -// IsDir if the entry is a sub dir -func (te *TreeEntry) IsDir() bool { - return te.gogitTreeEntry.Mode == filemode.Dir -} - -// IsLink if the entry is a symlink -func (te *TreeEntry) IsLink() bool { - return te.gogitTreeEntry.Mode == filemode.Symlink -} - -// IsRegular if the entry is a regular file -func (te *TreeEntry) IsRegular() bool { - return te.gogitTreeEntry.Mode == filemode.Regular -} - -// IsExecutable if the entry is an executable file (not necessarily binary) -func (te *TreeEntry) IsExecutable() bool { - return te.gogitTreeEntry.Mode == filemode.Executable -} - // Blob returns the blob object the entry func (te *TreeEntry) Blob() *Blob { - encodedObj, err := te.ptree.repo.gogitRepo.Storer.EncodedObject(plumbing.AnyObject, te.gogitTreeEntry.Hash) - if err != nil { - return nil - } - return &Blob{ - ID: ParseGogitHash(te.gogitTreeEntry.Hash), - gogitEncodedObj: encodedObj, - name: te.Name(), + ID: te.ID, + repo: te.ptree.repo, + name: te.Name(), } } diff --git a/modules/git/tree_entry_gogit_test.go b/modules/git/tree_entry_gogit_test.go new file mode 100644 index 0000000000..ed14b45e9e --- /dev/null +++ b/modules/git/tree_entry_gogit_test.go @@ -0,0 +1,27 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build gogit + +package git + +import ( + "testing" + + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/stretchr/testify/assert" +) + +func TestEntryGogit(t *testing.T) { + cases := map[EntryMode]filemode.FileMode{ + EntryModeBlob: filemode.Regular, + EntryModeCommit: filemode.Submodule, + EntryModeExec: filemode.Executable, + EntryModeSymlink: filemode.Symlink, + EntryModeTree: filemode.Dir, + } + for emode, fmode := range cases { + assert.EqualValues(t, fmode, entryModeToGogitFileMode(emode)) + assert.EqualValues(t, emode, gogitFileModeToEntryMode(fmode)) + } +} diff --git a/modules/git/tree_entry_mode.go b/modules/git/tree_entry_mode.go index f36c07bc2a..2ceba11374 100644 --- a/modules/git/tree_entry_mode.go +++ b/modules/git/tree_entry_mode.go @@ -4,7 +4,6 @@ package git import ( - "fmt" "strconv" ) @@ -55,21 +54,38 @@ func (e EntryMode) IsExecutable() bool { return e == EntryModeExec } -func ParseEntryMode(mode string) (EntryMode, error) { +func ParseEntryMode(mode string) EntryMode { switch mode { case "000000": - return EntryModeNoEntry, nil + return EntryModeNoEntry case "100644": - return EntryModeBlob, nil + return EntryModeBlob case "100755": - return EntryModeExec, nil + return EntryModeExec case "120000": - return EntryModeSymlink, nil + return EntryModeSymlink case "160000": - return EntryModeCommit, nil - case "040000", "040755": // git uses 040000 for tree object, but some users may get 040755 for unknown reasons - return EntryModeTree, nil + return EntryModeCommit + case "040000": + return EntryModeTree default: - return 0, fmt.Errorf("unparsable entry mode: %s", mode) + // git uses 040000 for tree object, but some users may get 040755 from non-standard git implementations + m, _ := strconv.ParseInt(mode, 8, 32) + modeInt := EntryMode(m) + switch modeInt & 0o770000 { + case 0o040000: + return EntryModeTree + case 0o160000: + return EntryModeCommit + case 0o120000: + return EntryModeSymlink + case 0o100000: + if modeInt&0o777 == 0o755 { + return EntryModeExec + } + return EntryModeBlob + default: + return EntryModeNoEntry + } } } diff --git a/modules/git/tree_entry_nogogit.go b/modules/git/tree_entry_nogogit.go index 8fad96cdf8..0a19b38d3e 100644 --- a/modules/git/tree_entry_nogogit.go +++ b/modules/git/tree_entry_nogogit.go @@ -7,27 +7,6 @@ package git import "code.gitea.io/gitea/modules/log" -// TreeEntry the leaf in the git tree -type TreeEntry struct { - ID ObjectID - ptree *Tree - - entryMode EntryMode - name string - size int64 - sized bool -} - -// Name returns the name of the entry (base name) -func (te *TreeEntry) Name() string { - return te.name -} - -// Mode returns the mode of the entry -func (te *TreeEntry) Mode() EntryMode { - return te.entryMode -} - // Size returns the size of the entry func (te *TreeEntry) Size() int64 { if te.IsDir() { @@ -36,52 +15,23 @@ func (te *TreeEntry) Size() int64 { return te.size } - wr, rd, cancel, err := te.ptree.repo.CatFileBatchCheck(te.ptree.repo.Ctx) + batch, cancel, err := te.ptree.repo.CatFileBatch(te.ptree.repo.Ctx) if err != nil { log.Debug("error whilst reading size for %s in %s. Error: %v", te.ID.String(), te.ptree.repo.Path, err) return 0 } defer cancel() - _, err = wr.Write([]byte(te.ID.String() + "\n")) - if err != nil { - log.Debug("error whilst reading size for %s in %s. Error: %v", te.ID.String(), te.ptree.repo.Path, err) - return 0 - } - _, _, te.size, err = ReadBatchLine(rd) + info, err := batch.QueryInfo(te.ID.String()) if err != nil { log.Debug("error whilst reading size for %s in %s. Error: %v", te.ID.String(), te.ptree.repo.Path, err) return 0 } + te.size = info.Size te.sized = true return te.size } -// IsSubModule if the entry is a submodule -func (te *TreeEntry) IsSubModule() bool { - return te.entryMode.IsSubModule() -} - -// IsDir if the entry is a sub dir -func (te *TreeEntry) IsDir() bool { - return te.entryMode.IsDir() -} - -// IsLink if the entry is a symlink -func (te *TreeEntry) IsLink() bool { - return te.entryMode.IsLink() -} - -// IsRegular if the entry is a regular file -func (te *TreeEntry) IsRegular() bool { - return te.entryMode.IsRegular() -} - -// IsExecutable if the entry is an executable file (not necessarily binary) -func (te *TreeEntry) IsExecutable() bool { - return te.entryMode.IsExecutable() -} - // Blob returns the blob object the entry func (te *TreeEntry) Blob() *Blob { return &Blob{ diff --git a/modules/git/tree_entry_test.go b/modules/git/tree_entry_test.go index 9ca82675e0..3df6eeab68 100644 --- a/modules/git/tree_entry_test.go +++ b/modules/git/tree_entry_test.go @@ -1,55 +1,57 @@ // Copyright 2017 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -//go:build gogit - package git import ( + "math/rand/v2" + "slices" + "strings" "testing" - "github.com/go-git/go-git/v5/plumbing/filemode" - "github.com/go-git/go-git/v5/plumbing/object" "github.com/stretchr/testify/assert" ) -func getTestEntries() Entries { - return Entries{ - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v1.0", Mode: filemode.Dir}}, - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v2.0", Mode: filemode.Dir}}, - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v2.1", Mode: filemode.Dir}}, - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v2.12", Mode: filemode.Dir}}, - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v2.2", Mode: filemode.Dir}}, - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v12.0", Mode: filemode.Dir}}, - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "abc", Mode: filemode.Regular}}, - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "bcd", Mode: filemode.Regular}}, +func TestEntriesCustomSort(t *testing.T) { + entries := Entries{ + &TreeEntry{name: "a-dir", entryMode: EntryModeTree}, + &TreeEntry{name: "a-submodule", entryMode: EntryModeCommit}, + &TreeEntry{name: "b-dir", entryMode: EntryModeTree}, + &TreeEntry{name: "b-submodule", entryMode: EntryModeCommit}, + &TreeEntry{name: "a-file", entryMode: EntryModeBlob}, + &TreeEntry{name: "b-file", entryMode: EntryModeBlob}, + } + expected := slices.Clone(entries) + for slices.Equal(expected, entries) { + rand.Shuffle(len(entries), func(i, j int) { entries[i], entries[j] = entries[j], entries[i] }) + } + entries.CustomSort(strings.Compare) + assert.Equal(t, expected, entries) +} + +func TestParseEntryMode(t *testing.T) { + tests := []struct { + modeStr string + expectMod EntryMode + }{ + {"000000", EntryModeNoEntry}, + {"000755", EntryModeNoEntry}, + + {"100644", EntryModeBlob}, + {"100755", EntryModeExec}, + + {"120000", EntryModeSymlink}, + {"120755", EntryModeSymlink}, + {"160000", EntryModeCommit}, + {"160755", EntryModeCommit}, + + {"040000", EntryModeTree}, + {"040755", EntryModeTree}, + + {"777777", EntryModeNoEntry}, // invalid mode + } + for _, test := range tests { + mod := ParseEntryMode(test.modeStr) + assert.Equal(t, test.expectMod, mod, "modeStr: %s", test.modeStr) } } - -func TestEntriesSort(t *testing.T) { - entries := getTestEntries() - entries.Sort() - assert.Equal(t, "v1.0", entries[0].Name()) - assert.Equal(t, "v12.0", entries[1].Name()) - assert.Equal(t, "v2.0", entries[2].Name()) - assert.Equal(t, "v2.1", entries[3].Name()) - assert.Equal(t, "v2.12", entries[4].Name()) - assert.Equal(t, "v2.2", entries[5].Name()) - assert.Equal(t, "abc", entries[6].Name()) - assert.Equal(t, "bcd", entries[7].Name()) -} - -func TestEntriesCustomSort(t *testing.T) { - entries := getTestEntries() - entries.CustomSort(func(s1, s2 string) bool { - return s1 > s2 - }) - assert.Equal(t, "v2.2", entries[0].Name()) - assert.Equal(t, "v2.12", entries[1].Name()) - assert.Equal(t, "v2.1", entries[2].Name()) - assert.Equal(t, "v2.0", entries[3].Name()) - assert.Equal(t, "v12.0", entries[4].Name()) - assert.Equal(t, "v1.0", entries[5].Name()) - assert.Equal(t, "bcd", entries[6].Name()) - assert.Equal(t, "abc", entries[7].Name()) -} diff --git a/modules/git/tree_gogit.go b/modules/git/tree_gogit.go index 272b018ffd..fec6e2704e 100644 --- a/modules/git/tree_gogit.go +++ b/modules/git/tree_gogit.go @@ -15,41 +15,34 @@ import ( // Tree represents a flat directory listing. type Tree struct { - ID ObjectID - ResolvedID ObjectID - repo *Repository + TreeCommon - gogitTree *object.Tree - - // parent tree - ptree *Tree + resolvedGogitTreeObject *object.Tree } -func (t *Tree) loadTreeObject() error { - gogitTree, err := t.repo.gogitRepo.TreeObject(plumbing.Hash(t.ID.RawValue())) - if err != nil { - return err - } - - t.gogitTree = gogitTree - return nil -} - -// ListEntries returns all entries of current tree. -func (t *Tree) ListEntries() (Entries, error) { - if t.gogitTree == nil { - err := t.loadTreeObject() +func (t *Tree) gogitTreeObject() (_ *object.Tree, err error) { + if t.resolvedGogitTreeObject == nil { + t.resolvedGogitTreeObject, err = t.repo.gogitRepo.TreeObject(plumbing.Hash(t.ID.RawValue())) if err != nil { return nil, err } } + return t.resolvedGogitTreeObject, nil +} - entries := make([]*TreeEntry, len(t.gogitTree.Entries)) - for i, entry := range t.gogitTree.Entries { +// ListEntries returns all entries of current tree. +func (t *Tree) ListEntries() (Entries, error) { + gogitTree, err := t.gogitTreeObject() + if err != nil { + return nil, err + } + entries := make([]*TreeEntry, len(gogitTree.Entries)) + for i, gogitTreeEntry := range gogitTree.Entries { entries[i] = &TreeEntry{ - ID: ParseGogitHash(entry.Hash), - gogitTreeEntry: &t.gogitTree.Entries[i], - ptree: t, + ID: ParseGogitHash(gogitTreeEntry.Hash), + ptree: t, + name: gogitTreeEntry.Name, + entryMode: gogitFileModeToEntryMode(gogitTreeEntry.Mode), } } @@ -57,37 +50,28 @@ func (t *Tree) ListEntries() (Entries, error) { } // ListEntriesRecursiveWithSize returns all entries of current tree recursively including all subtrees -func (t *Tree) ListEntriesRecursiveWithSize() (Entries, error) { - if t.gogitTree == nil { - err := t.loadTreeObject() - if err != nil { - return nil, err - } +func (t *Tree) ListEntriesRecursiveWithSize() (entries Entries, _ error) { + gogitTree, err := t.gogitTreeObject() + if err != nil { + return nil, err } - var entries []*TreeEntry - seen := map[plumbing.Hash]bool{} - walker := object.NewTreeWalker(t.gogitTree, true, seen) + walker := object.NewTreeWalker(gogitTree, true, nil) for { - _, entry, err := walker.Next() + fullName, gogitTreeEntry, err := walker.Next() if err == io.EOF { break - } - if err != nil { + } else if err != nil { return nil, err } - if seen[entry.Hash] { - continue - } - convertedEntry := &TreeEntry{ - ID: ParseGogitHash(entry.Hash), - gogitTreeEntry: &entry, - ptree: t, + ID: ParseGogitHash(gogitTreeEntry.Hash), + name: fullName, // FIXME: the "name" field is abused, here it is a full path + ptree: t, // FIXME: this ptree is not right, fortunately it isn't really used + entryMode: gogitFileModeToEntryMode(gogitTreeEntry.Mode), } entries = append(entries, convertedEntry) } - return entries, nil } diff --git a/modules/git/tree_nogogit.go b/modules/git/tree_nogogit.go index 956a5938f0..d50c1ad629 100644 --- a/modules/git/tree_nogogit.go +++ b/modules/git/tree_nogogit.go @@ -14,18 +14,10 @@ import ( // Tree represents a flat directory listing. type Tree struct { - ID ObjectID - ResolvedID ObjectID - repo *Repository - - // parent tree - ptree *Tree + TreeCommon entries Entries entriesParsed bool - - entriesRecursive Entries - entriesRecursiveParsed bool } // ListEntries returns all entries of current tree. @@ -35,30 +27,29 @@ func (t *Tree) ListEntries() (Entries, error) { } if t.repo != nil { - wr, rd, cancel, err := t.repo.CatFileBatch(t.repo.Ctx) + batch, cancel, err := t.repo.CatFileBatch(t.repo.Ctx) if err != nil { return nil, err } defer cancel() - _, _ = wr.Write([]byte(t.ID.String() + "\n")) - _, typ, sz, err := ReadBatchLine(rd) + info, rd, err := batch.QueryContent(t.ID.String()) if err != nil { return nil, err } - if typ == "commit" { - treeID, err := ReadTreeID(rd, sz) + + if info.Type == "commit" { + treeID, err := ReadTreeID(rd, info.Size) if err != nil && err != io.EOF { return nil, err } - _, _ = wr.Write([]byte(treeID + "\n")) - _, typ, sz, err = ReadBatchLine(rd) + info, rd, err = batch.QueryContent(treeID) if err != nil { return nil, err } } - if typ == "tree" { - t.entries, err = catBatchParseTreeEntries(t.ID.Type(), t, rd, sz) + if info.Type == "tree" { + t.entries, err = catBatchParseTreeEntries(t.ID.Type(), t, rd, info.Size) if err != nil { return nil, err } @@ -67,7 +58,7 @@ func (t *Tree) ListEntries() (Entries, error) { } // Not a tree just use ls-tree instead - if err := DiscardFull(rd, sz+1); err != nil { + if err := DiscardFull(rd, info.Size+1); err != nil { return nil, err } } @@ -94,10 +85,6 @@ func (t *Tree) ListEntries() (Entries, error) { // listEntriesRecursive returns all entries of current tree recursively including all subtrees // extraArgs could be "-l" to get the size, which is slower func (t *Tree) listEntriesRecursive(extraArgs gitcmd.TrustedCmdArgs) (Entries, error) { - if t.entriesRecursiveParsed { - return t.entriesRecursive, nil - } - stdout, _, runErr := gitcmd.NewCommand("ls-tree", "-t", "-r"). AddArguments(extraArgs...). AddDynamicArguments(t.ID.String()). @@ -107,13 +94,9 @@ func (t *Tree) listEntriesRecursive(extraArgs gitcmd.TrustedCmdArgs) (Entries, e return nil, runErr } - var err error - t.entriesRecursive, err = parseTreeEntries(stdout, t) - if err == nil { - t.entriesRecursiveParsed = true - } - - return t.entriesRecursive, err + // FIXME: the "name" field is abused, here it is a full path + // FIXME: this ptree is not right, fortunately it isn't really used + return parseTreeEntries(stdout, t) } // ListEntriesRecursiveFast returns all entries of current tree recursively including all subtrees, no size diff --git a/modules/git/url/url_test.go b/modules/git/url/url_test.go index 6655c20be3..76aa74a128 100644 --- a/modules/git/url/url_test.go +++ b/modules/git/url/url_test.go @@ -34,12 +34,12 @@ func TestParseGitURLs(t *testing.T) { }, }, { - kase: "git@[fe80:14fc:cec5:c174:d88%2510]:go-gitea/gitea.git", + kase: "git@[fe80::14fc:cec5:c174:d88%2510]:go-gitea/gitea.git", expected: &GitURL{ URL: &url.URL{ Scheme: "ssh", User: url.User("git"), - Host: "[fe80:14fc:cec5:c174:d88%10]", + Host: "[fe80::14fc:cec5:c174:d88%10]", Path: "go-gitea/gitea.git", }, extraMark: 1, @@ -137,11 +137,11 @@ func TestParseGitURLs(t *testing.T) { }, }, { - kase: "https://[fe80:14fc:cec5:c174:d88%2510]:20/go-gitea/gitea.git", + kase: "https://[fe80::14fc:cec5:c174:d88%2510]:20/go-gitea/gitea.git", expected: &GitURL{ URL: &url.URL{ Scheme: "https", - Host: "[fe80:14fc:cec5:c174:d88%10]:20", + Host: "[fe80::14fc:cec5:c174:d88%10]:20", Path: "/go-gitea/gitea.git", }, extraMark: 0, diff --git a/modules/git/utils.go b/modules/git/utils.go index b5f188904a..e7d30ce9ee 100644 --- a/modules/git/utils.go +++ b/modules/git/utils.go @@ -6,7 +6,6 @@ package git import ( "crypto/sha1" "encoding/hex" - "io" "strconv" "strings" "sync" @@ -68,32 +67,6 @@ func ParseBool(value string) (result, valid bool) { return intValue != 0, true } -// LimitedReaderCloser is a limited reader closer -type LimitedReaderCloser struct { - R io.Reader - C io.Closer - N int64 -} - -// Read implements io.Reader -func (l *LimitedReaderCloser) Read(p []byte) (n int, err error) { - if l.N <= 0 { - _ = l.C.Close() - return 0, io.EOF - } - if int64(len(p)) > l.N { - p = p[0:l.N] - } - n, err = l.R.Read(p) - l.N -= int64(n) - return n, err -} - -// Close implements io.Closer -func (l *LimitedReaderCloser) Close() error { - return l.C.Close() -} - func HashFilePathForWebUI(s string) string { h := sha1.New() _, _ = h.Write([]byte(s)) diff --git a/modules/gitrepo/archive.go b/modules/gitrepo/archive.go index b78922e126..191a1bd2c0 100644 --- a/modules/gitrepo/archive.go +++ b/modules/gitrepo/archive.go @@ -8,7 +8,9 @@ import ( "fmt" "io" "os" + "path" "path/filepath" + "slices" "strings" "code.gitea.io/gitea/modules/git/gitcmd" @@ -16,7 +18,7 @@ import ( ) // CreateArchive create archive content to the target path -func CreateArchive(ctx context.Context, repo Repository, format string, target io.Writer, usePrefix bool, commitID string) error { +func CreateArchive(ctx context.Context, repo Repository, format string, target io.Writer, usePrefix bool, commitID string, paths []string) error { if format == "unknown" { return fmt.Errorf("unknown format: %v", format) } @@ -28,11 +30,13 @@ func CreateArchive(ctx context.Context, repo Repository, format string, target i cmd.AddOptionFormat("--format=%s", format) cmd.AddDynamicArguments(commitID) - var stderr strings.Builder - if err := RunCmd(ctx, repo, cmd.WithStdout(target).WithStderr(&stderr)); err != nil { - return gitcmd.ConcatenateError(err, stderr.String()) + paths = slices.Clone(paths) + for i := range paths { + // although "git archive" already ensures the paths won't go outside the repo, we still clean them here for safety + paths[i] = path.Clean(paths[i]) } - return nil + cmd.AddDynamicArguments(paths...) + return RunCmdWithStderr(ctx, repo, cmd.WithStdoutCopy(target)) } // CreateBundle create bundle content to the target path diff --git a/modules/gitrepo/blame.go b/modules/gitrepo/blame.go index 3ce808d9b3..2352da1760 100644 --- a/modules/gitrepo/blame.go +++ b/modules/gitrepo/blame.go @@ -4,15 +4,205 @@ package gitrepo import ( + "bufio" + "bytes" "context" + "io" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/setting" ) func LineBlame(ctx context.Context, repo Repository, revision, file string, line uint) (string, error) { - return RunCmdString(ctx, repo, + stdout, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("blame"). AddOptionFormat("-L %d,%d", line, line). AddOptionValues("-p", revision). AddDashesAndList(file)) + return stdout, err +} + +// BlamePart represents block of blame - continuous lines with one sha +type BlamePart struct { + Sha string + Lines []string + PreviousSha string + PreviousPath string +} + +// BlameReader returns part of file blame one by one +type BlameReader struct { + bufferedReader *bufio.Reader + done chan error + lastSha *string + ignoreRevsFile string + objectFormat git.ObjectFormat + cleanupFuncs []func() +} + +func (r *BlameReader) UsesIgnoreRevs() bool { + return r.ignoreRevsFile != "" +} + +// NextPart returns next part of blame (sequential code lines with the same commit) +func (r *BlameReader) NextPart() (*BlamePart, error) { + var blamePart *BlamePart + + if r.lastSha != nil { + blamePart = &BlamePart{ + Sha: *r.lastSha, + Lines: make([]string, 0), + } + } + + const previousHeader = "previous " + var lineBytes []byte + var isPrefix bool + var err error + + for err != io.EOF { + lineBytes, isPrefix, err = r.bufferedReader.ReadLine() + if err != nil && err != io.EOF { + return blamePart, err + } + + if len(lineBytes) == 0 { + // isPrefix will be false + continue + } + + var objectID string + objectFormatLength := r.objectFormat.FullLength() + + if len(lineBytes) > objectFormatLength && lineBytes[objectFormatLength] == ' ' && r.objectFormat.IsValid(string(lineBytes[0:objectFormatLength])) { + objectID = string(lineBytes[0:objectFormatLength]) + } + if len(objectID) > 0 { + if blamePart == nil { + blamePart = &BlamePart{ + Sha: objectID, + Lines: make([]string, 0), + } + } + + if blamePart.Sha != objectID { + r.lastSha = &objectID + // need to munch to end of line... + for isPrefix { + _, isPrefix, err = r.bufferedReader.ReadLine() + if err != nil && err != io.EOF { + return blamePart, err + } + } + return blamePart, nil + } + } else if lineBytes[0] == '\t' { + blamePart.Lines = append(blamePart.Lines, string(lineBytes[1:])) + } else if bytes.HasPrefix(lineBytes, []byte(previousHeader)) { + offset := len(previousHeader) // already includes a space + blamePart.PreviousSha = string(lineBytes[offset : offset+objectFormatLength]) + offset += objectFormatLength + 1 // +1 for space + blamePart.PreviousPath = string(lineBytes[offset:]) + } + + // need to munch to end of line... + for isPrefix { + _, isPrefix, err = r.bufferedReader.ReadLine() + if err != nil && err != io.EOF { + return blamePart, err + } + } + } + + r.lastSha = nil + + return blamePart, nil +} + +// Close BlameReader - don't run NextPart after invoking that +func (r *BlameReader) Close() error { + if r.bufferedReader == nil { + return nil + } + + err := <-r.done + r.bufferedReader = nil + r.cleanup() + return err +} + +func (r *BlameReader) cleanup() { + for _, cleanup := range r.cleanupFuncs { + cleanup() + } +} + +// CreateBlameReader creates reader for given repository, commit and file +func CreateBlameReader(ctx context.Context, objectFormat git.ObjectFormat, repo Repository, commit *git.Commit, file string, bypassBlameIgnore bool) (rd *BlameReader, retErr error) { + defer func() { + if retErr != nil { + rd.cleanup() + } + }() + + rd = &BlameReader{ + done: make(chan error, 1), + objectFormat: objectFormat, + } + + cmd := gitcmd.NewCommand("blame", "--porcelain") + + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + rd.bufferedReader = bufio.NewReader(stdoutReader) + rd.cleanupFuncs = append(rd.cleanupFuncs, stdoutReaderClose) + + if git.DefaultFeatures().CheckVersionAtLeast("2.23") && !bypassBlameIgnore { + ignoreRevsFileName, ignoreRevsFileCleanup, err := tryCreateBlameIgnoreRevsFile(commit) + if err != nil && !git.IsErrNotExist(err) { + return nil, err + } else if err == nil { + rd.ignoreRevsFile = ignoreRevsFileName + rd.cleanupFuncs = append(rd.cleanupFuncs, ignoreRevsFileCleanup) + // Possible improvement: use --ignore-revs-file /dev/stdin on unix + // There is no equivalent on Windows. May be implemented if Gitea uses an external git backend. + cmd.AddOptionValues("--ignore-revs-file", ignoreRevsFileName) + } + } + + cmd.AddDynamicArguments(commit.ID.String()).AddDashesAndList(file) + + go func() { + // TODO: it doesn't work for directories (the directories shouldn't be "blamed"), and the "err" should be returned by "Read" but not by "Close" + rd.done <- RunCmdWithStderr(ctx, repo, cmd) + }() + + return rd, nil +} + +func tryCreateBlameIgnoreRevsFile(commit *git.Commit) (string, func(), error) { + entry, err := commit.GetTreeEntryByPath(".git-blame-ignore-revs") + if err != nil { + return "", nil, err + } + + r, err := entry.Blob().DataAsync() + if err != nil { + return "", nil, err + } + defer r.Close() + + f, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("git-blame-ignore-revs") + if err != nil { + return "", nil, err + } + filename := f.Name() + _, err = io.Copy(f, r) + _ = f.Close() + if err != nil { + cleanup() + return "", nil, err + } + + return filename, cleanup, nil } diff --git a/modules/git/blame_sha256_test.go b/modules/gitrepo/blame_sha256_test.go similarity index 88% rename from modules/git/blame_sha256_test.go rename to modules/gitrepo/blame_sha256_test.go index c0a97bed3b..e92931d596 100644 --- a/modules/git/blame_sha256_test.go +++ b/modules/gitrepo/blame_sha256_test.go @@ -1,12 +1,13 @@ // Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package git +package gitrepo import ( "context" "testing" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" @@ -17,13 +18,14 @@ func TestReadingBlameOutputSha256(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) defer cancel() - if isGogit { + if git.DefaultFeatures().UsingGogit { t.Skip("Skipping test since gogit does not support sha256") return } t.Run("Without .git-blame-ignore-revs", func(t *testing.T) { - repo, err := OpenRepository(ctx, "./tests/repos/repo5_pulls_sha256") + storage := &mockRepository{path: "repo5_pulls_sha256"} + repo, err := OpenRepository(ctx, storage) assert.NoError(t, err) defer repo.Close() @@ -47,7 +49,7 @@ func TestReadingBlameOutputSha256(t *testing.T) { } for _, bypass := range []bool{false, true} { - blameReader, err := CreateBlameReader(ctx, Sha256ObjectFormat, "./tests/repos/repo5_pulls_sha256", commit, "README.md", bypass) + blameReader, err := CreateBlameReader(ctx, git.Sha256ObjectFormat, storage, commit, "README.md", bypass) assert.NoError(t, err) assert.NotNil(t, blameReader) defer blameReader.Close() @@ -68,7 +70,8 @@ func TestReadingBlameOutputSha256(t *testing.T) { }) t.Run("With .git-blame-ignore-revs", func(t *testing.T) { - repo, err := OpenRepository(ctx, "./tests/repos/repo6_blame_sha256") + storage := &mockRepository{path: "repo6_blame_sha256"} + repo, err := OpenRepository(ctx, storage) assert.NoError(t, err) defer repo.Close() @@ -131,7 +134,7 @@ func TestReadingBlameOutputSha256(t *testing.T) { for _, c := range cases { commit, err := repo.GetCommit(c.CommitID) assert.NoError(t, err) - blameReader, err := CreateBlameReader(ctx, objectFormat, "./tests/repos/repo6_blame_sha256", commit, "blame.txt", c.Bypass) + blameReader, err := CreateBlameReader(ctx, objectFormat, storage, commit, "blame.txt", c.Bypass) assert.NoError(t, err) assert.NotNil(t, blameReader) defer blameReader.Close() diff --git a/modules/git/blame_test.go b/modules/gitrepo/blame_test.go similarity index 89% rename from modules/git/blame_test.go rename to modules/gitrepo/blame_test.go index 809d6fbcf7..0307a5fd33 100644 --- a/modules/git/blame_test.go +++ b/modules/gitrepo/blame_test.go @@ -1,12 +1,13 @@ // Copyright 2019 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package git +package gitrepo import ( "context" "testing" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" @@ -18,10 +19,10 @@ func TestReadingBlameOutput(t *testing.T) { defer cancel() t.Run("Without .git-blame-ignore-revs", func(t *testing.T) { - repo, err := OpenRepository(ctx, "./tests/repos/repo5_pulls") + storage := &mockRepository{path: "repo5_pulls"} + repo, err := OpenRepository(ctx, storage) assert.NoError(t, err) defer repo.Close() - commit, err := repo.GetCommit("f32b0a9dfd09a60f616f29158f772cedd89942d2") assert.NoError(t, err) @@ -42,7 +43,7 @@ func TestReadingBlameOutput(t *testing.T) { } for _, bypass := range []bool{false, true} { - blameReader, err := CreateBlameReader(ctx, Sha1ObjectFormat, "./tests/repos/repo5_pulls", commit, "README.md", bypass) + blameReader, err := CreateBlameReader(ctx, git.Sha1ObjectFormat, storage, commit, "README.md", bypass) assert.NoError(t, err) assert.NotNil(t, blameReader) defer blameReader.Close() @@ -63,7 +64,8 @@ func TestReadingBlameOutput(t *testing.T) { }) t.Run("With .git-blame-ignore-revs", func(t *testing.T) { - repo, err := OpenRepository(ctx, "./tests/repos/repo6_blame") + storage := &mockRepository{path: "repo6_blame"} + repo, err := OpenRepository(ctx, storage) assert.NoError(t, err) defer repo.Close() @@ -127,7 +129,7 @@ func TestReadingBlameOutput(t *testing.T) { commit, err := repo.GetCommit(c.CommitID) assert.NoError(t, err) - blameReader, err := CreateBlameReader(ctx, objectFormat, "./tests/repos/repo6_blame", commit, "blame.txt", c.Bypass) + blameReader, err := CreateBlameReader(ctx, objectFormat, storage, commit, "blame.txt", c.Bypass) assert.NoError(t, err) assert.NotNil(t, blameReader) defer blameReader.Close() diff --git a/modules/gitrepo/branch.go b/modules/gitrepo/branch.go index e05d75caf8..4c40d1fba3 100644 --- a/modules/gitrepo/branch.go +++ b/modules/gitrepo/branch.go @@ -36,14 +36,14 @@ func GetBranchCommitID(ctx context.Context, repo Repository, branch string) (str // SetDefaultBranch sets default branch of repository. func SetDefaultBranch(ctx context.Context, repo Repository, name string) error { - _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("symbolic-ref", "HEAD"). + _, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("symbolic-ref", "HEAD"). AddDynamicArguments(git.BranchPrefix+name)) return err } // GetDefaultBranch gets default branch of repository. func GetDefaultBranch(ctx context.Context, repo Repository) (string, error) { - stdout, err := RunCmdString(ctx, repo, gitcmd.NewCommand("symbolic-ref", "HEAD")) + stdout, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("symbolic-ref", "HEAD")) if err != nil { return "", err } @@ -56,7 +56,7 @@ func GetDefaultBranch(ctx context.Context, repo Repository) (string, error) { // IsReferenceExist returns true if given reference exists in the repository. func IsReferenceExist(ctx context.Context, repo Repository, name string) bool { - _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("show-ref", "--verify").AddDashesAndList(name)) + _, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("show-ref", "--verify").AddDashesAndList(name)) return err == nil } @@ -76,7 +76,7 @@ func DeleteBranch(ctx context.Context, repo Repository, name string, force bool) } cmd.AddDashesAndList(name) - _, err := RunCmdString(ctx, repo, cmd) + _, _, err := RunCmdString(ctx, repo, cmd) return err } @@ -85,12 +85,12 @@ func CreateBranch(ctx context.Context, repo Repository, branch, oldbranchOrCommi cmd := gitcmd.NewCommand("branch") cmd.AddDashesAndList(branch, oldbranchOrCommit) - _, err := RunCmdString(ctx, repo, cmd) + _, _, err := RunCmdString(ctx, repo, cmd) return err } // RenameBranch rename a branch func RenameBranch(ctx context.Context, repo Repository, from, to string) error { - _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("branch", "-m").AddDynamicArguments(from, to)) + _, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("branch", "-m").AddDynamicArguments(from, to)) return err } diff --git a/modules/gitrepo/cat_file.go b/modules/gitrepo/cat_file.go new file mode 100644 index 0000000000..42ca23acde --- /dev/null +++ b/modules/gitrepo/cat_file.go @@ -0,0 +1,14 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + + "code.gitea.io/gitea/modules/git" +) + +func NewBatch(ctx context.Context, repo Repository) (git.CatFileBatchCloser, error) { + return git.NewBatch(ctx, repoPath(repo)) +} diff --git a/modules/gitrepo/clone.go b/modules/gitrepo/clone.go new file mode 100644 index 0000000000..a0e4cc814c --- /dev/null +++ b/modules/gitrepo/clone.go @@ -0,0 +1,24 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + + "code.gitea.io/gitea/modules/git" +) + +// CloneExternalRepo clones an external repository to the managed repository. +func CloneExternalRepo(ctx context.Context, fromRemoteURL string, toRepo Repository, opts git.CloneRepoOptions) error { + return git.Clone(ctx, fromRemoteURL, repoPath(toRepo), opts) +} + +// CloneRepoToLocal clones a managed repository to a local path. +func CloneRepoToLocal(ctx context.Context, fromRepo Repository, toLocalPath string, opts git.CloneRepoOptions) error { + return git.Clone(ctx, repoPath(fromRepo), toLocalPath, opts) +} + +func Clone(ctx context.Context, fromRepo, toRepo Repository, opts git.CloneRepoOptions) error { + return git.Clone(ctx, repoPath(fromRepo), repoPath(toRepo), opts) +} diff --git a/modules/gitrepo/command.go b/modules/gitrepo/command.go index d4cb6093fc..fd21b9a725 100644 --- a/modules/gitrepo/command.go +++ b/modules/gitrepo/command.go @@ -13,11 +13,14 @@ func RunCmd(ctx context.Context, repo Repository, cmd *gitcmd.Command) error { return cmd.WithDir(repoPath(repo)).WithParentCallerInfo().Run(ctx) } -func RunCmdString(ctx context.Context, repo Repository, cmd *gitcmd.Command) (string, error) { - res, _, err := cmd.WithDir(repoPath(repo)).WithParentCallerInfo().RunStdString(ctx) - return res, err +func RunCmdString(ctx context.Context, repo Repository, cmd *gitcmd.Command) (string, string, gitcmd.RunStdError) { + return cmd.WithDir(repoPath(repo)).WithParentCallerInfo().RunStdString(ctx) } -func RunCmdBytes(ctx context.Context, repo Repository, cmd *gitcmd.Command) ([]byte, []byte, error) { +func RunCmdBytes(ctx context.Context, repo Repository, cmd *gitcmd.Command) ([]byte, []byte, gitcmd.RunStdError) { return cmd.WithDir(repoPath(repo)).WithParentCallerInfo().RunStdBytes(ctx) } + +func RunCmdWithStderr(ctx context.Context, repo Repository, cmd *gitcmd.Command) gitcmd.RunStdError { + return cmd.WithDir(repoPath(repo)).WithParentCallerInfo().RunWithStderr(ctx) +} diff --git a/modules/gitrepo/commit.go b/modules/gitrepo/commit.go new file mode 100644 index 0000000000..0ab17862fe --- /dev/null +++ b/modules/gitrepo/commit.go @@ -0,0 +1,112 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/gitcmd" +) + +// CommitsCountOptions the options when counting commits +type CommitsCountOptions struct { + Not string + Revision []string + RelPath []string + Since string + Until string +} + +// CommitsCount returns number of total commits of until given revision. +func CommitsCount(ctx context.Context, repo Repository, opts CommitsCountOptions) (int64, error) { + cmd := gitcmd.NewCommand("rev-list", "--count") + + cmd.AddDynamicArguments(opts.Revision...) + + if opts.Not != "" { + cmd.AddOptionValues("--not", opts.Not) + } + + if len(opts.RelPath) > 0 { + cmd.AddDashesAndList(opts.RelPath...) + } + + stdout, _, err := cmd.WithDir(repoPath(repo)).RunStdString(ctx) + if err != nil { + return 0, err + } + + return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64) +} + +// CommitsCountBetween return numbers of commits between two commits +func CommitsCountBetween(ctx context.Context, repo Repository, start, end string) (int64, error) { + count, err := CommitsCount(ctx, repo, CommitsCountOptions{ + Revision: []string{start + ".." + end}, + }) + + if err != nil && strings.Contains(err.Error(), "no merge base") { + // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated. + // previously it would return the results of git rev-list before last so let's try that... + return CommitsCount(ctx, repo, CommitsCountOptions{ + Revision: []string{start, end}, + }) + } + + return count, err +} + +// FileCommitsCount return the number of files at a revision +func FileCommitsCount(ctx context.Context, repo Repository, revision, file string) (int64, error) { + return CommitsCount(ctx, repo, + CommitsCountOptions{ + Revision: []string{revision}, + RelPath: []string{file}, + }) +} + +// CommitsCountOfCommit returns number of total commits of until current revision. +func CommitsCountOfCommit(ctx context.Context, repo Repository, commitID string) (int64, error) { + return CommitsCount(ctx, repo, CommitsCountOptions{ + Revision: []string{commitID}, + }) +} + +// AllCommitsCount returns count of all commits in repository +func AllCommitsCount(ctx context.Context, repo Repository, hidePRRefs bool, files ...string) (int64, error) { + cmd := gitcmd.NewCommand("rev-list") + if hidePRRefs { + cmd.AddArguments("--exclude=" + git.PullPrefix + "*") + } + cmd.AddArguments("--all", "--count") + if len(files) > 0 { + cmd.AddDashesAndList(files...) + } + + stdout, _, err := RunCmdString(ctx, repo, cmd) + if err != nil { + return 0, err + } + + return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64) +} + +func GetFullCommitID(ctx context.Context, repo Repository, shortID string) (string, error) { + return git.GetFullCommitID(ctx, repoPath(repo), shortID) +} + +// GetLatestCommitTime returns time for latest commit in repository (across all branches) +func GetLatestCommitTime(ctx context.Context, repo Repository) (time.Time, error) { + stdout, _, err := RunCmdString(ctx, repo, + gitcmd.NewCommand("for-each-ref", "--sort=-committerdate", git.BranchPrefix, "--count", "1", "--format=%(committerdate)")) + if err != nil { + return time.Time{}, err + } + commitTime := strings.TrimSpace(stdout) + return time.Parse("Mon Jan _2 15:04:05 2006 -0700", commitTime) +} diff --git a/modules/gitrepo/commit_file.go b/modules/gitrepo/commit_file.go new file mode 100644 index 0000000000..437b3b51ad --- /dev/null +++ b/modules/gitrepo/commit_file.go @@ -0,0 +1,88 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "bufio" + "context" + "io" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/log" +) + +// CommitFileStatus represents status of files in a commit. +type CommitFileStatus struct { + Added []string + Removed []string + Modified []string +} + +// NewCommitFileStatus creates a CommitFileStatus +func NewCommitFileStatus() *CommitFileStatus { + return &CommitFileStatus{ + []string{}, []string{}, []string{}, + } +} + +func parseCommitFileStatus(fileStatus *CommitFileStatus, stdout io.Reader) { + rd := bufio.NewReader(stdout) + peek, err := rd.Peek(1) + if err != nil { + if err != io.EOF { + log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) + } + return + } + if peek[0] == '\n' || peek[0] == '\x00' { + _, _ = rd.Discard(1) + } + for { + modifier, err := rd.ReadString('\x00') + if err != nil { + if err != io.EOF { + log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) + } + return + } + file, err := rd.ReadString('\x00') + if err != nil { + if err != io.EOF { + log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) + } + return + } + file = file[:len(file)-1] + switch modifier[0] { + case 'A': + fileStatus.Added = append(fileStatus.Added, file) + case 'D': + fileStatus.Removed = append(fileStatus.Removed, file) + case 'M': + fileStatus.Modified = append(fileStatus.Modified, file) + } + } +} + +// GetCommitFileStatus returns file status of commit in given repository. +func GetCommitFileStatus(ctx context.Context, repo Repository, commitID string) (*CommitFileStatus, error) { + cmd := gitcmd.NewCommand("log", "--name-status", "-m", "--pretty=format:", "--first-parent", "--no-renames", "-z", "-1") + stdout, stdoutClose := cmd.MakeStdoutPipe() + defer stdoutClose() + done := make(chan struct{}) + fileStatus := NewCommitFileStatus() + go func() { + parseCommitFileStatus(fileStatus, stdout) + close(done) + }() + err := cmd.AddDynamicArguments(commitID). + WithDir(repoPath(repo)). + RunWithStderr(ctx) + if err != nil { + return nil, err + } + + <-done + return fileStatus, nil +} diff --git a/modules/gitrepo/commit_file_test.go b/modules/gitrepo/commit_file_test.go new file mode 100644 index 0000000000..ec1018eeba --- /dev/null +++ b/modules/gitrepo/commit_file_test.go @@ -0,0 +1,175 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseCommitFileStatus(t *testing.T) { + type testcase struct { + output string + added []string + removed []string + modified []string + } + + kases := []testcase{ + { + // Merge commit + output: "MM\x00options/locale/locale_en-US.ini\x00", + modified: []string{ + "options/locale/locale_en-US.ini", + }, + added: []string{}, + removed: []string{}, + }, + { + // Spaces commit + output: "D\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00", + removed: []string{ + "b", + "b b/b", + }, + modified: []string{}, + added: []string{ + "b b/b b/b b/b", + "b b/b b/b b/b b/b", + }, + }, + { + // larger commit + output: "M\x00go.mod\x00M\x00go.sum\x00M\x00modules/ssh/ssh.go\x00M\x00vendor/github.com/gliderlabs/ssh/circle.yml\x00M\x00vendor/github.com/gliderlabs/ssh/context.go\x00A\x00vendor/github.com/gliderlabs/ssh/go.mod\x00A\x00vendor/github.com/gliderlabs/ssh/go.sum\x00M\x00vendor/github.com/gliderlabs/ssh/server.go\x00M\x00vendor/github.com/gliderlabs/ssh/session.go\x00M\x00vendor/github.com/gliderlabs/ssh/ssh.go\x00M\x00vendor/golang.org/x/sys/unix/mkerrors.sh\x00M\x00vendor/golang.org/x/sys/unix/syscall_darwin.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_linux.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go\x00M\x00vendor/modules.txt\x00", + modified: []string{ + "go.mod", + "go.sum", + "modules/ssh/ssh.go", + "vendor/github.com/gliderlabs/ssh/circle.yml", + "vendor/github.com/gliderlabs/ssh/context.go", + "vendor/github.com/gliderlabs/ssh/server.go", + "vendor/github.com/gliderlabs/ssh/session.go", + "vendor/github.com/gliderlabs/ssh/ssh.go", + "vendor/golang.org/x/sys/unix/mkerrors.sh", + "vendor/golang.org/x/sys/unix/syscall_darwin.go", + "vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go", + "vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go", + "vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go", + "vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go", + "vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go", + "vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go", + "vendor/golang.org/x/sys/unix/zerrors_linux.go", + "vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go", + "vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go", + "vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go", + "vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go", + "vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go", + "vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go", + "vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go", + "vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go", + "vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go", + "vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go", + "vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go", + "vendor/modules.txt", + }, + added: []string{ + "vendor/github.com/gliderlabs/ssh/go.mod", + "vendor/github.com/gliderlabs/ssh/go.sum", + }, + removed: []string{}, + }, + { + // git 1.7.2 adds an unnecessary \x00 on merge commit + output: "\x00MM\x00options/locale/locale_en-US.ini\x00", + modified: []string{ + "options/locale/locale_en-US.ini", + }, + added: []string{}, + removed: []string{}, + }, + { + // git 1.7.2 adds an unnecessary \n on normal commit + output: "\nD\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00", + removed: []string{ + "b", + "b b/b", + }, + modified: []string{}, + added: []string{ + "b b/b b/b b/b", + "b b/b b/b b/b b/b", + }, + }, + } + + for _, kase := range kases { + fileStatus := NewCommitFileStatus() + parseCommitFileStatus(fileStatus, strings.NewReader(kase.output)) + + assert.Equal(t, kase.added, fileStatus.Added) + assert.Equal(t, kase.removed, fileStatus.Removed) + assert.Equal(t, kase.modified, fileStatus.Modified) + } +} + +func TestGetCommitFileStatusMerges(t *testing.T) { + bareRepo6 := &mockRepository{path: "repo6_merge"} + + commitFileStatus, err := GetCommitFileStatus(t.Context(), bareRepo6, "022f4ce6214973e018f02bf363bf8a2e3691f699") + assert.NoError(t, err) + + expected := CommitFileStatus{ + []string{ + "add_file.txt", + }, + []string{ + "to_remove.txt", + }, + []string{ + "to_modify.txt", + }, + } + + assert.Equal(t, expected.Added, commitFileStatus.Added) + assert.Equal(t, expected.Removed, commitFileStatus.Removed) + assert.Equal(t, expected.Modified, commitFileStatus.Modified) +} + +func TestGetCommitFileStatusMergesSha256(t *testing.T) { + bareRepo6Sha256 := &mockRepository{path: "repo6_merge_sha256"} + + commitFileStatus, err := GetCommitFileStatus(t.Context(), bareRepo6Sha256, "d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1") + assert.NoError(t, err) + + expected := CommitFileStatus{ + []string{ + "add_file.txt", + }, + []string{}, + []string{ + "to_modify.txt", + }, + } + + assert.Equal(t, expected.Added, commitFileStatus.Added) + assert.Equal(t, expected.Removed, commitFileStatus.Removed) + assert.Equal(t, expected.Modified, commitFileStatus.Modified) + + expected = CommitFileStatus{ + []string{}, + []string{ + "to_remove.txt", + }, + []string{}, + } + + commitFileStatus, err = GetCommitFileStatus(t.Context(), bareRepo6Sha256, "da1ded40dc8e5b7c564171f4bf2fc8370487decfb1cb6a99ef28f3ed73d09172") + assert.NoError(t, err) + + assert.Equal(t, expected.Added, commitFileStatus.Added) + assert.Equal(t, expected.Removed, commitFileStatus.Removed) + assert.Equal(t, expected.Modified, commitFileStatus.Modified) +} diff --git a/modules/gitrepo/commit_test.go b/modules/gitrepo/commit_test.go new file mode 100644 index 0000000000..05cedc39ef --- /dev/null +++ b/modules/gitrepo/commit_test.go @@ -0,0 +1,45 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCommitsCount(t *testing.T) { + bareRepo1 := &mockRepository{path: "repo1_bare"} + + commitsCount, err := CommitsCount(t.Context(), bareRepo1, + CommitsCountOptions{ + Revision: []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"}, + }) + + assert.NoError(t, err) + assert.Equal(t, int64(3), commitsCount) +} + +func TestCommitsCountWithoutBase(t *testing.T) { + bareRepo1 := &mockRepository{path: "repo1_bare"} + + commitsCount, err := CommitsCount(t.Context(), bareRepo1, + CommitsCountOptions{ + Not: "master", + Revision: []string{"branch1"}, + }) + + assert.NoError(t, err) + assert.Equal(t, int64(2), commitsCount) +} + +func TestGetLatestCommitTime(t *testing.T) { + bareRepo1 := &mockRepository{path: "repo1_bare"} + lct, err := GetLatestCommitTime(t.Context(), bareRepo1) + assert.NoError(t, err) + // Time is Sun Nov 13 16:40:14 2022 +0100 + // which is the time of commit + // ce064814f4a0d337b333e646ece456cd39fab612 (refs/heads/master) + assert.EqualValues(t, 1668354014, lct.Unix()) +} diff --git a/modules/gitrepo/commitgraph.go b/modules/gitrepo/commitgraph.go new file mode 100644 index 0000000000..7310e167f6 --- /dev/null +++ b/modules/gitrepo/commitgraph.go @@ -0,0 +1,14 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + + "code.gitea.io/gitea/modules/git" +) + +func WriteCommitGraph(ctx context.Context, repo Repository) error { + return git.WriteCommitGraph(ctx, repoPath(repo)) +} diff --git a/modules/gitrepo/compare.go b/modules/gitrepo/compare.go index b8e4c30d6c..06cf880d99 100644 --- a/modules/gitrepo/compare.go +++ b/modules/gitrepo/compare.go @@ -22,7 +22,7 @@ type DivergeObject struct { func GetDivergingCommits(ctx context.Context, repo Repository, baseBranch, targetBranch string) (*DivergeObject, error) { cmd := gitcmd.NewCommand("rev-list", "--count", "--left-right"). AddDynamicArguments(baseBranch + "..." + targetBranch).AddArguments("--") - stdout, err1 := RunCmdString(ctx, repo, cmd) + stdout, _, err1 := RunCmdString(ctx, repo, cmd) if err1 != nil { return nil, err1 } diff --git a/modules/gitrepo/config.go b/modules/gitrepo/config.go index bc1746fc3f..9be3ef94ae 100644 --- a/modules/gitrepo/config.go +++ b/modules/gitrepo/config.go @@ -12,7 +12,7 @@ import ( ) func GitConfigGet(ctx context.Context, repo Repository, key string) (string, error) { - result, err := RunCmdString(ctx, repo, gitcmd.NewCommand("config", "--get"). + result, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("config", "--get"). AddDynamicArguments(key)) if err != nil { return "", err @@ -27,7 +27,7 @@ func getRepoConfigLockKey(repoStoragePath string) string { // GitConfigAdd add a git configuration key to a specific value for the given repository. func GitConfigAdd(ctx context.Context, repo Repository, key, value string) error { return globallock.LockAndDo(ctx, getRepoConfigLockKey(repo.RelativePath()), func(ctx context.Context) error { - _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("config", "--add"). + _, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("config", "--add"). AddDynamicArguments(key, value)) return err }) @@ -38,7 +38,7 @@ func GitConfigAdd(ctx context.Context, repo Repository, key, value string) error // If the key exists, it will be updated to the new value. func GitConfigSet(ctx context.Context, repo Repository, key, value string) error { return globallock.LockAndDo(ctx, getRepoConfigLockKey(repo.RelativePath()), func(ctx context.Context) error { - _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("config"). + _, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("config"). AddDynamicArguments(key, value)) return err }) diff --git a/modules/gitrepo/diff.go b/modules/gitrepo/diff.go index c98c3ffcfe..0092cf0bb8 100644 --- a/modules/gitrepo/diff.go +++ b/modules/gitrepo/diff.go @@ -6,6 +6,7 @@ package gitrepo import ( "context" "fmt" + "io" "regexp" "strconv" @@ -20,7 +21,7 @@ func GetDiffShortStatByCmdArgs(ctx context.Context, repo Repository, trustedArgs // we get: // " 9902 files changed, 2034198 insertions(+), 298800 deletions(-)\n" cmd := gitcmd.NewCommand("diff", "--shortstat").AddArguments(trustedArgs...).AddDynamicArguments(dynamicArgs...) - stdout, err := RunCmdString(ctx, repo, cmd) + stdout, _, err := RunCmdString(ctx, repo, cmd) if err != nil { return 0, 0, 0, err } @@ -60,3 +61,11 @@ func parseDiffStat(stdout string) (numFiles, totalAdditions, totalDeletions int, } return numFiles, totalAdditions, totalDeletions, err } + +// GetReverseRawDiff dumps the reverse diff results of repository in given commit ID to io.Writer. +func GetReverseRawDiff(ctx context.Context, repo Repository, commitID string, writer io.Writer) error { + return RunCmdWithStderr(ctx, repo, gitcmd.NewCommand("show", "--pretty=format:revert %H%n", "-R"). + AddDynamicArguments(commitID). + WithStdoutCopy(writer), + ) +} diff --git a/modules/gitrepo/fetch.go b/modules/gitrepo/fetch.go new file mode 100644 index 0000000000..0474d6111e --- /dev/null +++ b/modules/gitrepo/fetch.go @@ -0,0 +1,28 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/globallock" +) + +// FetchRemoteCommit fetches a specific commit and its related objects from a remote +// repository into the managed repository. +// +// If no reference (branch, tag, or other ref) points to the fetched commit, it will +// be treated as unreachable and cleaned up by `git gc` after the default prune +// expiration period (2 weeks). Ref: https://www.kernel.org/pub/software/scm/git/docs/git-gc.html +// +// This behavior is sufficient for temporary operations, such as determining the +// merge base between commits. +func FetchRemoteCommit(ctx context.Context, repo, remoteRepo Repository, commitID string) error { + return globallock.LockAndDo(ctx, getRepoWriteLockKey(repo.RelativePath()), func(ctx context.Context) error { + return RunCmd(ctx, repo, gitcmd.NewCommand("fetch", "--no-tags"). + AddDynamicArguments(repoPath(remoteRepo)). + AddDynamicArguments(commitID)) + }) +} diff --git a/modules/gitrepo/gitrepo.go b/modules/gitrepo/gitrepo.go index 59d2323599..535d72ed98 100644 --- a/modules/gitrepo/gitrepo.go +++ b/modules/gitrepo/gitrepo.go @@ -7,9 +7,12 @@ import ( "context" "fmt" "io" + "io/fs" + "os" "path/filepath" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -77,7 +80,12 @@ func DeleteRepository(ctx context.Context, repo Repository) error { // RenameRepository renames a repository's name on disk func RenameRepository(ctx context.Context, repo, newRepo Repository) error { - if err := util.Rename(repoPath(repo), repoPath(newRepo)); err != nil { + dstDir := repoPath(newRepo) + if err := os.MkdirAll(filepath.Dir(dstDir), os.ModePerm); err != nil { + return fmt.Errorf("Failed to create dir %s: %w", filepath.Dir(dstDir), err) + } + + if err := util.Rename(repoPath(repo), dstDir); err != nil { return fmt.Errorf("rename repository directory: %w", err) } return nil @@ -86,3 +94,35 @@ func RenameRepository(ctx context.Context, repo, newRepo Repository) error { func InitRepository(ctx context.Context, repo Repository, objectFormatName string) error { return git.InitRepository(ctx, repoPath(repo), true, objectFormatName) } + +func UpdateServerInfo(ctx context.Context, repo Repository) error { + _, _, err := RunCmdBytes(ctx, repo, gitcmd.NewCommand("update-server-info")) + return err +} + +func GetRepoFS(repo Repository) fs.FS { + return os.DirFS(repoPath(repo)) +} + +func IsRepoFileExist(ctx context.Context, repo Repository, relativeFilePath string) (bool, error) { + absoluteFilePath := filepath.Join(repoPath(repo), relativeFilePath) + return util.IsExist(absoluteFilePath) +} + +func IsRepoDirExist(ctx context.Context, repo Repository, relativeDirPath string) (bool, error) { + absoluteDirPath := filepath.Join(repoPath(repo), relativeDirPath) + return util.IsDir(absoluteDirPath) +} + +func RemoveRepoFileOrDir(ctx context.Context, repo Repository, relativeFileOrDirPath string) error { + absoluteFilePath := filepath.Join(repoPath(repo), relativeFileOrDirPath) + return util.Remove(absoluteFilePath) +} + +func CreateRepoFile(ctx context.Context, repo Repository, relativeFilePath string) (io.WriteCloser, error) { + absoluteFilePath := filepath.Join(repoPath(repo), relativeFilePath) + if err := os.MkdirAll(filepath.Dir(absoluteFilePath), os.ModePerm); err != nil { + return nil, err + } + return os.Create(absoluteFilePath) +} diff --git a/modules/gitrepo/main_test.go b/modules/gitrepo/main_test.go index 6e6636ce77..e47eda7bc9 100644 --- a/modules/gitrepo/main_test.go +++ b/modules/gitrepo/main_test.go @@ -4,29 +4,21 @@ package gitrepo import ( - "os" "path/filepath" "testing" - "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/tempdir" - "code.gitea.io/gitea/modules/test" ) func TestMain(m *testing.M) { - gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home") - if err != nil { - log.Fatal("Unable to create temp dir: %v", err) - } - defer cleanup() - // resolve repository path relative to the test directory - testRootDir := test.SetupGiteaRoot() + testRootDir := setting.SetupGiteaTestEnv() repoPath = func(repo Repository) string { - return filepath.Join(testRootDir, "/modules/git/tests/repos", repo.RelativePath()) + if filepath.IsAbs(repo.RelativePath()) { + return repo.RelativePath() // for testing purpose only + } + return filepath.Join(testRootDir, "modules/git/tests/repos", repo.RelativePath()) } - - setting.Git.HomePath = gitHomePath - os.Exit(m.Run()) + git.RunGitTests(m) } diff --git a/modules/gitrepo/merge.go b/modules/gitrepo/merge.go new file mode 100644 index 0000000000..8d58e21c8d --- /dev/null +++ b/modules/gitrepo/merge.go @@ -0,0 +1,22 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/modules/git/gitcmd" +) + +// MergeBase checks and returns merge base of two commits. +func MergeBase(ctx context.Context, repo Repository, baseCommitID, headCommitID string) (string, error) { + mergeBase, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("merge-base"). + AddDashesAndList(baseCommitID, headCommitID)) + if err != nil { + return "", fmt.Errorf("get merge-base of %s and %s failed: %w", baseCommitID, headCommitID, err) + } + return strings.TrimSpace(mergeBase), nil +} diff --git a/modules/gitrepo/merge_tree.go b/modules/gitrepo/merge_tree.go new file mode 100644 index 0000000000..6151b1179f --- /dev/null +++ b/modules/gitrepo/merge_tree.go @@ -0,0 +1,59 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "bufio" + "context" + "fmt" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/util" +) + +const MaxConflictedDetectFiles = 10 + +// MergeTree performs a merge between two commits (baseRef and headRef) with an optional merge base. +// It returns the resulting tree hash, a list of conflicted files (if any), and an error if the operation fails. +// If there are no conflicts, the list of conflicted files will be nil. +func MergeTree(ctx context.Context, repo Repository, baseRef, headRef, mergeBase string) (treeID string, isErrHasConflicts bool, conflictFiles []string, _ error) { + cmd := gitcmd.NewCommand("merge-tree", "--write-tree", "-z", "--name-only", "--no-messages"). + AddOptionFormat("--merge-base=%s", mergeBase). + AddDynamicArguments(baseRef, headRef) + + stdout, stdoutClose := cmd.MakeStdoutPipe() + defer stdoutClose() + cmd.WithPipelineFunc(func(ctx gitcmd.Context) error { + // https://git-scm.com/docs/git-merge-tree/2.38.0#OUTPUT + // For a conflicted merge, the output is: + // NUL + // NUL + // NUL + // ... + scanner := bufio.NewScanner(stdout) + scanner.Split(util.BufioScannerSplit(0)) + for scanner.Scan() { + line := scanner.Text() + if treeID == "" { // first line is tree ID + treeID = line + continue + } + conflictFiles = append(conflictFiles, line) + if len(conflictFiles) >= MaxConflictedDetectFiles { + break + } + } + return scanner.Err() + }) + + err := RunCmdWithStderr(ctx, repo, cmd) + // For a successful, non-conflicted merge, the exit status is 0. When the merge has conflicts, the exit status is 1. + // A merge can have conflicts without having individual files conflict + // https://git-scm.com/docs/git-merge-tree/2.38.0#_mistakes_to_avoid + isErrHasConflicts = gitcmd.IsErrorExitCode(err, 1) + if err == nil || isErrHasConflicts { + return treeID, isErrHasConflicts, conflictFiles, nil + } + return "", false, nil, fmt.Errorf("run merge-tree failed: %w", err) +} diff --git a/modules/gitrepo/merge_tree_test.go b/modules/gitrepo/merge_tree_test.go new file mode 100644 index 0000000000..9327a0c3d8 --- /dev/null +++ b/modules/gitrepo/merge_tree_test.go @@ -0,0 +1,82 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/modules/git/gitcmd" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func prepareRepoDirRenameConflict(t *testing.T) string { + repoDir := filepath.Join(t.TempDir(), "repo-dir-rename-conflict.git") + require.NoError(t, gitcmd.NewCommand("init", "--bare").AddDynamicArguments(repoDir).Run(t.Context())) + stdin := `blob +mark :1 +data 2 +b + +blob +mark :2 +data 2 +c + +reset refs/heads/master +commit refs/heads/master +mark :3 +author test 1769202331 -0800 +committer test 1769202331 -0800 +data 2 +O +M 100644 :1 z/b +M 100644 :2 z/c + +commit refs/heads/split +mark :4 +author test 1769202336 -0800 +committer test 1769202336 -0800 +data 2 +A +from :3 +M 100644 :2 w/c +M 100644 :1 y/b +D z/b +D z/c + +blob +mark :5 +data 2 +d + +commit refs/heads/add +mark :6 +author test 1769202342 -0800 +committer test 1769202342 -0800 +data 2 +B +from :3 +M 100644 :5 z/d +` + require.NoError(t, gitcmd.NewCommand("fast-import").WithDir(repoDir).WithStdinBytes([]byte(stdin)).Run(t.Context())) + return repoDir +} + +func TestMergeTreeDirectoryRenameConflictWithoutFiles(t *testing.T) { + repoDir := prepareRepoDirRenameConflict(t) + require.DirExists(t, repoDir) + repo := &mockRepository{path: repoDir} + + mergeBase, err := MergeBase(t.Context(), repo, "add", "split") + require.NoError(t, err) + + treeID, conflicted, conflictedFiles, err := MergeTree(t.Context(), repo, "add", "split", mergeBase) + require.NoError(t, err) + assert.True(t, conflicted) + assert.Empty(t, conflictedFiles) + assert.Equal(t, "5e3dd4cfc5b11e278a35b2daa83b7274175e3ab1", treeID) +} diff --git a/modules/gitrepo/push.go b/modules/gitrepo/push.go new file mode 100644 index 0000000000..920c317f79 --- /dev/null +++ b/modules/gitrepo/push.go @@ -0,0 +1,27 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + + "code.gitea.io/gitea/modules/git" +) + +// PushToExternal pushes a managed repository to an external remote. +func PushToExternal(ctx context.Context, repo Repository, opts git.PushOptions) error { + return git.Push(ctx, repoPath(repo), opts) +} + +// Push pushes from one managed repository to another managed repository. +func Push(ctx context.Context, fromRepo, toRepo Repository, opts git.PushOptions) error { + opts.Remote = repoPath(toRepo) + return git.Push(ctx, repoPath(fromRepo), opts) +} + +// PushFromLocal pushes from a local path to a managed repository. +func PushFromLocal(ctx context.Context, fromLocalPath string, toRepo Repository, opts git.PushOptions) error { + opts.Remote = repoPath(toRepo) + return git.Push(ctx, fromLocalPath, opts) +} diff --git a/modules/gitrepo/remote.go b/modules/gitrepo/remote.go index ce43988461..3cbc34eedb 100644 --- a/modules/gitrepo/remote.go +++ b/modules/gitrepo/remote.go @@ -6,8 +6,6 @@ package gitrepo import ( "context" "errors" - "io" - "time" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" @@ -36,7 +34,7 @@ func GitRemoteAdd(ctx context.Context, repo Repository, remoteName, remoteURL st return errors.New("unknown remote option: " + string(options[0])) } } - _, err := RunCmdString(ctx, repo, cmd.AddDynamicArguments(remoteName, remoteURL)) + _, _, err := RunCmdString(ctx, repo, cmd.AddDynamicArguments(remoteName, remoteURL)) return err }) } @@ -44,7 +42,7 @@ func GitRemoteAdd(ctx context.Context, repo Repository, remoteName, remoteURL st func GitRemoteRemove(ctx context.Context, repo Repository, remoteName string) error { return globallock.LockAndDo(ctx, getRepoConfigLockKey(repo.RelativePath()), func(ctx context.Context) error { cmd := gitcmd.NewCommand("remote", "rm").AddDynamicArguments(remoteName) - _, err := RunCmdString(ctx, repo, cmd) + _, _, err := RunCmdString(ctx, repo, cmd) return err }) } @@ -60,21 +58,3 @@ func GitRemoteGetURL(ctx context.Context, repo Repository, remoteName string) (* } return giturl.ParseGitURL(addr) } - -// GitRemotePrune prunes the remote branches that no longer exist in the remote repository. -func GitRemotePrune(ctx context.Context, repo Repository, remoteName string, timeout time.Duration, stdout, stderr io.Writer) error { - return RunCmd(ctx, repo, gitcmd.NewCommand("remote", "prune"). - AddDynamicArguments(remoteName). - WithTimeout(timeout). - WithStdout(stdout). - WithStderr(stderr)) -} - -// GitRemoteUpdatePrune updates the remote branches and prunes the ones that no longer exist in the remote repository. -func GitRemoteUpdatePrune(ctx context.Context, repo Repository, remoteName string, timeout time.Duration, stdout, stderr io.Writer) error { - return RunCmd(ctx, repo, gitcmd.NewCommand("remote", "update", "--prune"). - AddDynamicArguments(remoteName). - WithTimeout(timeout). - WithStdout(stdout). - WithStderr(stderr)) -} diff --git a/modules/gitrepo/repo_lock.go b/modules/gitrepo/repo_lock.go new file mode 100644 index 0000000000..2eb89ce807 --- /dev/null +++ b/modules/gitrepo/repo_lock.go @@ -0,0 +1,10 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +// getRepoWriteLockKey returns the global lock key for write operations on the repository. +// Parallel write operations on the same git repository should be avoided to prevent data corruption. +func getRepoWriteLockKey(repoStoragePath string) string { + return "repo-write:" + repoStoragePath +} diff --git a/modules/gitrepo/signing.go b/modules/gitrepo/signing.go new file mode 100644 index 0000000000..2f77758d8c --- /dev/null +++ b/modules/gitrepo/signing.go @@ -0,0 +1,14 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + + "code.gitea.io/gitea/modules/git" +) + +func GetSigningKey(ctx context.Context) (*git.SigningKey, *git.Signature) { + return git.GetSigningKey(ctx) +} diff --git a/modules/graceful/manager.go b/modules/graceful/manager.go index ee1872b999..51bd5a2334 100644 --- a/modules/graceful/manager.go +++ b/modules/graceful/manager.go @@ -74,12 +74,6 @@ func (g *Manager) RunWithCancel(rc RunCanceler) { g.RunAtShutdown(context.Background(), rc.Cancel) g.runningServerWaitGroup.Add(1) defer g.runningServerWaitGroup.Done() - defer func() { - if err := recover(); err != nil { - log.Critical("PANIC during RunWithCancel: %v\nStacktrace: %s", err, log.Stack(2)) - g.doShutdown() - } - }() rc.Run() } @@ -89,12 +83,6 @@ func (g *Manager) RunWithCancel(rc RunCanceler) { func (g *Manager) RunWithShutdownContext(run func(context.Context)) { g.runningServerWaitGroup.Add(1) defer g.runningServerWaitGroup.Done() - defer func() { - if err := recover(); err != nil { - log.Critical("PANIC during RunWithShutdownContext: %v\nStacktrace: %s", err, log.Stack(2)) - g.doShutdown() - } - }() ctx := g.ShutdownContext() pprof.SetGoroutineLabels(ctx) // We don't have a label to restore back to but I think this is fine run(ctx) diff --git a/modules/graceful/server.go b/modules/graceful/server.go index 2525a83e77..b440f68ab5 100644 --- a/modules/graceful/server.go +++ b/modules/graceful/server.go @@ -11,7 +11,6 @@ import ( "os" "strings" "sync" - "sync/atomic" "syscall" "time" @@ -30,12 +29,15 @@ type ServeFunction = func(net.Listener) error // Server represents our graceful server type Server struct { - network string - address string - listener net.Listener - wg sync.WaitGroup - state state - lock *sync.RWMutex + network string + address string + listener net.Listener + + lock sync.RWMutex + state state + connCounter int64 + connEmptyCond *sync.Cond + BeforeBegin func(network, address string) OnShutdown func() PerWriteTimeout time.Duration @@ -50,14 +52,13 @@ func NewServer(network, address, name string) *Server { log.Info("Starting new %s server: %s:%s on PID: %d", name, network, address, os.Getpid()) } srv := &Server{ - wg: sync.WaitGroup{}, state: stateInit, - lock: &sync.RWMutex{}, network: network, address: address, PerWriteTimeout: setting.PerWriteTimeout, PerWritePerKbTimeout: setting.PerWritePerKbTimeout, } + srv.connEmptyCond = sync.NewCond(&srv.lock) srv.BeforeBegin = func(network, addr string) { log.Debug("Starting server on %s:%s (PID: %d)", network, addr, syscall.Getpid()) @@ -154,7 +155,7 @@ func (srv *Server) Serve(serve ServeFunction) error { GetManager().RegisterServer() err := serve(srv.listener) log.Debug("Waiting for connections to finish... (PID: %d)", syscall.Getpid()) - srv.wg.Wait() + srv.waitForActiveConnections() srv.setState(stateTerminate) GetManager().ServerDone() // use of closed means that the listeners are closed - i.e. we should be shutting down - return nil @@ -178,16 +179,62 @@ func (srv *Server) setState(st state) { srv.state = st } +func (srv *Server) waitForActiveConnections() { + srv.lock.Lock() + for srv.connCounter > 0 { + srv.connEmptyCond.Wait() + } + srv.lock.Unlock() +} + +func (srv *Server) wrapConnection(c net.Conn) (net.Conn, error) { + srv.lock.Lock() + defer srv.lock.Unlock() + + if srv.state != stateRunning { + _ = c.Close() + return nil, syscall.EINVAL // same as AcceptTCP + } + + srv.connCounter++ + return &wrappedConn{Conn: c, server: srv}, nil +} + +func (srv *Server) removeConnection(_ *wrappedConn) { + srv.lock.Lock() + defer srv.lock.Unlock() + + srv.connCounter-- + if srv.connCounter <= 0 { + srv.connEmptyCond.Broadcast() + } +} + +// closeAllConnections forcefully closes all active connections +func (srv *Server) closeAllConnections() { + srv.lock.Lock() + if srv.connCounter > 0 { + log.Warn("After graceful shutdown period, %d connections are still active. Forcefully close.", srv.connCounter) + srv.connCounter = 0 // OS will close all the connections after the process exits, so we just assume there is no active connection now + } + srv.lock.Unlock() + srv.connEmptyCond.Broadcast() +} + type filer interface { File() (*os.File, error) } type wrappedListener struct { net.Listener - stopped bool - server *Server + server *Server } +var ( + _ net.Listener = (*wrappedListener)(nil) + _ filer = (*wrappedListener)(nil) +) + func newWrappedListener(l net.Listener, srv *Server) *wrappedListener { return &wrappedListener{ Listener: l, @@ -195,46 +242,24 @@ func newWrappedListener(l net.Listener, srv *Server) *wrappedListener { } } -func (wl *wrappedListener) Accept() (net.Conn, error) { - var c net.Conn - // Set keepalive on TCPListeners connections. +func (wl *wrappedListener) Accept() (c net.Conn, err error) { if tcl, ok := wl.Listener.(*net.TCPListener); ok { + // Set keepalive on TCPListeners connections if possible, see http.tcpKeepAliveListener tc, err := tcl.AcceptTCP() if err != nil { return nil, err } - _ = tc.SetKeepAlive(true) // see http.tcpKeepAliveListener - _ = tc.SetKeepAlivePeriod(3 * time.Minute) // see http.tcpKeepAliveListener + _ = tc.SetKeepAlive(true) + _ = tc.SetKeepAlivePeriod(3 * time.Minute) c = tc } else { - var err error c, err = wl.Listener.Accept() if err != nil { return nil, err } } - closed := int32(0) - - c = &wrappedConn{ - Conn: c, - server: wl.server, - closed: &closed, - perWriteTimeout: wl.server.PerWriteTimeout, - perWritePerKbTimeout: wl.server.PerWritePerKbTimeout, - } - - wl.server.wg.Add(1) - return c, nil -} - -func (wl *wrappedListener) Close() error { - if wl.stopped { - return syscall.EINVAL - } - - wl.stopped = true - return wl.Listener.Close() + return wl.server.wrapConnection(c) } func (wl *wrappedListener) File() (*os.File, error) { @@ -244,17 +269,14 @@ func (wl *wrappedListener) File() (*os.File, error) { type wrappedConn struct { net.Conn - server *Server - closed *int32 - deadline time.Time - perWriteTimeout time.Duration - perWritePerKbTimeout time.Duration + server *Server + deadline time.Time } func (w *wrappedConn) Write(p []byte) (n int, err error) { - if w.perWriteTimeout > 0 { - minTimeout := time.Duration(len(p)/1024) * w.perWritePerKbTimeout - minDeadline := time.Now().Add(minTimeout).Add(w.perWriteTimeout) + if w.server.PerWriteTimeout > 0 { + minTimeout := time.Duration(len(p)/1024) * w.server.PerWritePerKbTimeout + minDeadline := time.Now().Add(minTimeout).Add(w.server.PerWriteTimeout) w.deadline = w.deadline.Add(minTimeout) if minDeadline.After(w.deadline) { @@ -266,19 +288,6 @@ func (w *wrappedConn) Write(p []byte) (n int, err error) { } func (w *wrappedConn) Close() error { - if atomic.CompareAndSwapInt32(w.closed, 0, 1) { - defer func() { - if err := recover(); err != nil { - select { - case <-GetManager().IsHammer(): - // Likely deadlocked request released at hammertime - log.Warn("Panic during connection close! %v. Likely there has been a deadlocked request which has been released by forced shutdown.", err) - default: - log.Error("Panic during connection close! %v", err) - } - } - }() - w.server.wg.Done() - } + w.server.removeConnection(w) return w.Conn.Close() } diff --git a/modules/graceful/server_hooks.go b/modules/graceful/server_hooks.go index 9b67589571..b800c32503 100644 --- a/modules/graceful/server_hooks.go +++ b/modules/graceful/server_hooks.go @@ -5,7 +5,6 @@ package graceful import ( "os" - "runtime" "code.gitea.io/gitea/modules/log" ) @@ -48,26 +47,8 @@ func (srv *Server) doShutdown() { } func (srv *Server) doHammer() { - defer func() { - // We call srv.wg.Done() until it panics. - // This happens if we call Done() when the WaitGroup counter is already at 0 - // So if it panics -> we're done, Serve() will return and the - // parent will goroutine will exit. - if r := recover(); r != nil { - log.Error("WaitGroup at 0: Error: %v", r) - } - }() if srv.getState() != stateShuttingDown { return } - log.Warn("Forcefully shutting down parent") - for { - if srv.getState() == stateTerminate { - break - } - srv.wg.Done() - - // Give other goroutines a chance to finish before we forcibly stop them. - runtime.Gosched() - } + srv.closeAllConnections() } diff --git a/modules/hcaptcha/hcaptcha_test.go b/modules/hcaptcha/hcaptcha_test.go index 55e01ec535..5906faf17c 100644 --- a/modules/hcaptcha/hcaptcha_test.go +++ b/modules/hcaptcha/hcaptcha_test.go @@ -4,7 +4,10 @@ package hcaptcha import ( + "errors" + "io" "net/http" + "net/url" "os" "strings" "testing" @@ -21,6 +24,33 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } +type mockTransport struct{} + +func (mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.String() != verifyURL { + return nil, errors.New("unsupported url") + } + + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + + bodyValues, err := url.ParseQuery(string(body)) + if err != nil { + return nil, err + } + + var responseText string + if bodyValues.Get("response") == dummyToken { + responseText = `{"success":true,"credit":false,"hostname":"dummy-key-pass","challenge_ts":"2025-10-08T16:02:56.136Z"}` + } else { + responseText = `{"success":false,"error-codes":["invalid-input-response"]}` + } + + return &http.Response{Request: req, Body: io.NopCloser(strings.NewReader(responseText))}, nil +} + func TestCaptcha(t *testing.T) { tt := []struct { Name string @@ -54,7 +84,8 @@ func TestCaptcha(t *testing.T) { for _, tc := range tt { t.Run(tc.Name, func(t *testing.T) { client, err := New(tc.Secret, WithHTTP(&http.Client{ - Timeout: time.Second * 5, + Timeout: time.Second * 5, + Transport: mockTransport{}, })) if err != nil { // The only error that can be returned from creating a client diff --git a/modules/highlight/highlight.go b/modules/highlight/highlight.go index 77f24fa3f3..c7416c7a10 100644 --- a/modules/highlight/highlight.go +++ b/modules/highlight/highlight.go @@ -11,104 +11,94 @@ import ( gohtml "html" "html/template" "io" - "path" - "path/filepath" "strings" "sync" - "code.gitea.io/gitea/modules/analyze" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/formatters/html" - "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" - lru "github.com/hashicorp/golang-lru/v2" ) // don't index files larger than this many bytes for performance purposes const sizeLimit = 1024 * 1024 -var ( - // For custom user mapping - highlightMapping = map[string]string{} - - once sync.Once - - cache *lru.TwoQueueCache[string, any] - - githubStyles = styles.Get("github") -) - -// NewContext loads custom highlight map from local config -func NewContext() { - once.Do(func() { - highlightMapping = setting.GetHighlightMapping() - - // The size 512 is simply a conservative rule of thumb - c, err := lru.New2Q[string, any](512) - if err != nil { - panic(fmt.Sprintf("failed to initialize LRU cache for highlighter: %s", err)) - } - cache = c - }) +type globalVarsType struct { + highlightMapping map[string]string + githubStyles *chroma.Style } -// Code returns a HTML version of code string with chroma syntax highlighting classes and the matched lexer name -func Code(fileName, language, code string) (output template.HTML, lexerName string) { - NewContext() +var ( + globalVarsMu sync.Mutex + globalVarsPtr *globalVarsType +) +func globalVars() *globalVarsType { + // in the future, the globalVars might need to be re-initialized when settings change, so don't use sync.Once here + globalVarsMu.Lock() + defer globalVarsMu.Unlock() + if globalVarsPtr == nil { + globalVarsPtr = &globalVarsType{} + globalVarsPtr.githubStyles = styles.Get("github") + globalVarsPtr.highlightMapping = setting.GetHighlightMapping() + } + return globalVarsPtr +} + +// UnsafeSplitHighlightedLines splits highlighted code into lines preserving HTML tags +// It always includes '\n', '\n' can appear at the end of each line or in the middle of HTML tags +// The '\n' is necessary for copying code from web UI to preserve original code lines +// ATTENTION: It uses the unsafe conversion between string and []byte for performance reason +// DO NOT make any modification to the returned [][]byte slice items +func UnsafeSplitHighlightedLines(code template.HTML) (ret [][]byte) { + buf := util.UnsafeStringToBytes(string(code)) + lineCount := bytes.Count(buf, []byte("\n")) + 1 + ret = make([][]byte, 0, lineCount) + nlTagClose := []byte("\n 0 { + ret = append(ret, buf) + } + return ret + } + // Chroma highlighting output sometimes have "" right after \n, sometimes before. + // * "text\n" + // * "text\n" + if bytes.HasPrefix(buf[pos:], nlTagClose) { + pos1 := bytes.IndexByte(buf[pos:], '>') + if pos1 != -1 { + pos += pos1 + } + } + ret = append(ret, buf[:pos+1]) + buf = buf[pos+1:] + } +} + +// RenderCodeSlowGuess tries to get a lexer by file name and language first, +// if not found, it will try to guess the lexer by code content, which is slow (more than several hundreds of milliseconds). +func RenderCodeSlowGuess(fileName, language, code string) (output template.HTML, lexer chroma.Lexer, lexerDisplayName string) { // diff view newline will be passed as empty, change to literal '\n' so it can be copied // preserve literal newline in blame view if code == "" || code == "\n" { - return "\n", "" + return "\n", nil, "" } if len(code) > sizeLimit { - return template.HTML(template.HTMLEscapeString(code)), "" + return template.HTML(template.HTMLEscapeString(code)), nil, "" } - var lexer chroma.Lexer - - if len(language) > 0 { - lexer = lexers.Get(language) - - if lexer == nil { - // Attempt stripping off the '?' - if idx := strings.IndexByte(language, '?'); idx > 0 { - lexer = lexers.Get(language[:idx]) - } - } - } - - if lexer == nil { - if val, ok := highlightMapping[path.Ext(fileName)]; ok { - // use mapped value to find lexer - lexer = lexers.Get(val) - } - } - - if lexer == nil { - if l, ok := cache.Get(fileName); ok { - lexer = l.(chroma.Lexer) - } - } - - if lexer == nil { - lexer = lexers.Match(fileName) - if lexer == nil { - lexer = lexers.Fallback - } - cache.Add(fileName, lexer) - } - - return CodeFromLexer(lexer, code), formatLexerName(lexer.Config().Name) + lexer = detectChromaLexerWithAnalyze(fileName, language, util.UnsafeStringToBytes(code)) // it is also slow + return RenderCodeByLexer(lexer, code), lexer, formatLexerName(lexer.Config().Name) } -// CodeFromLexer returns a HTML version of code string with chroma syntax highlighting classes -func CodeFromLexer(lexer chroma.Lexer, code string) template.HTML { +// RenderCodeByLexer returns a HTML version of code string with chroma syntax highlighting classes +func RenderCodeByLexer(lexer chroma.Lexer, code string) template.HTML { formatter := html.New(html.WithClasses(true), html.WithLineNumbers(false), html.PreventSurroundingPre(true), @@ -123,7 +113,7 @@ func CodeFromLexer(lexer chroma.Lexer, code string) template.HTML { return template.HTML(template.HTMLEscapeString(code)) } // style not used for live site but need to pass something - err = formatter.Format(htmlw, githubStyles, iterator) + err = formatter.Format(htmlw, globalVars().githubStyles, iterator) if err != nil { log.Error("Can't format code: %v", err) return template.HTML(template.HTMLEscapeString(code)) @@ -135,12 +125,10 @@ func CodeFromLexer(lexer chroma.Lexer, code string) template.HTML { return template.HTML(strings.TrimSuffix(htmlbuf.String(), "\n")) } -// File returns a slice of chroma syntax highlighted HTML lines of code and the matched lexer name -func File(fileName, language string, code []byte) ([]template.HTML, string, error) { - NewContext() - +// RenderFullFile returns a slice of chroma syntax highlighted HTML lines of code and the matched lexer name +func RenderFullFile(fileName, language string, code []byte) ([]template.HTML, string, error) { if len(code) > sizeLimit { - return PlainText(code), "", nil + return RenderPlainText(code), "", nil } formatter := html.New(html.WithClasses(true), @@ -148,31 +136,7 @@ func File(fileName, language string, code []byte) ([]template.HTML, string, erro html.PreventSurroundingPre(true), ) - var lexer chroma.Lexer - - // provided language overrides everything - if language != "" { - lexer = lexers.Get(language) - } - - if lexer == nil { - if val, ok := highlightMapping[filepath.Ext(fileName)]; ok { - lexer = lexers.Get(val) - } - } - - if lexer == nil { - guessLanguage := analyze.GetCodeLanguage(fileName, code) - - lexer = lexers.Get(guessLanguage) - if lexer == nil { - lexer = lexers.Match(fileName) - if lexer == nil { - lexer = lexers.Fallback - } - } - } - + lexer := detectChromaLexerWithAnalyze(fileName, language, code) lexerName := formatLexerName(lexer.Config().Name) iterator, err := lexer.Tokenise(nil, string(code)) @@ -186,7 +150,7 @@ func File(fileName, language string, code []byte) ([]template.HTML, string, erro lines := make([]template.HTML, 0, len(tokensLines)) for _, tokens := range tokensLines { iterator = chroma.Literator(tokens...) - err = formatter.Format(htmlBuf, githubStyles, iterator) + err = formatter.Format(htmlBuf, globalVars().githubStyles, iterator) if err != nil { return nil, "", fmt.Errorf("can't format code: %w", err) } @@ -197,8 +161,8 @@ func File(fileName, language string, code []byte) ([]template.HTML, string, erro return lines, lexerName, nil } -// PlainText returns non-highlighted HTML for code -func PlainText(code []byte) []template.HTML { +// RenderPlainText returns non-highlighted HTML for code +func RenderPlainText(code []byte) []template.HTML { r := bufio.NewReader(bytes.NewReader(code)) m := make([]template.HTML, 0, bytes.Count(code, []byte{'\n'})+1) for { diff --git a/modules/highlight/highlight_test.go b/modules/highlight/highlight_test.go index b36de98c5c..d026210475 100644 --- a/modules/highlight/highlight_test.go +++ b/modules/highlight/highlight_test.go @@ -108,11 +108,17 @@ c=2 ), lexerName: "Python", }, + { + name: "test.sql", + code: "--\nSELECT", + want: []template.HTML{"--\n", `SELECT`}, + lexerName: "SQL", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - out, lexerName, err := File(tt.name, "", []byte(tt.code)) + out, lexerName, err := RenderFullFile(tt.name, "", []byte(tt.code)) assert.NoError(t, err) assert.Equal(t, tt.want, out) assert.Equal(t, tt.lexerName, lexerName) @@ -176,8 +182,26 @@ c=2`), for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - out := PlainText([]byte(tt.code)) + out := RenderPlainText([]byte(tt.code)) assert.Equal(t, tt.want, out) }) } } + +func TestUnsafeSplitHighlightedLines(t *testing.T) { + ret := UnsafeSplitHighlightedLines("") + assert.Empty(t, ret) + + ret = UnsafeSplitHighlightedLines("a") + assert.Len(t, ret, 1) + assert.Equal(t, "a", string(ret[0])) + + ret = UnsafeSplitHighlightedLines("\n") + assert.Len(t, ret, 1) + assert.Equal(t, "\n", string(ret[0])) + + ret = UnsafeSplitHighlightedLines("a\nb\n") + assert.Len(t, ret, 2) + assert.Equal(t, "a\n", string(ret[0])) + assert.Equal(t, "b\n", string(ret[1])) +} diff --git a/modules/highlight/lexerdetect.go b/modules/highlight/lexerdetect.go new file mode 100644 index 0000000000..5b39617566 --- /dev/null +++ b/modules/highlight/lexerdetect.go @@ -0,0 +1,279 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package highlight + +import ( + "path" + "strings" + "sync" + + "code.gitea.io/gitea/modules/analyze" + "code.gitea.io/gitea/modules/log" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/go-enry/go-enry/v2" +) + +const mapKeyLowerPrefix = "lower/" + +// chromaLexers is fully managed by us to do fast lookup for chroma lexers by file name or language name +// Don't use lexers.Get because it is very slow in many cases (iterate all rules, filepath glob match, etc.) +var chromaLexers = sync.OnceValue(func() (ret struct { + conflictingExtLangMap map[string]string + + lowerNameMap map[string]chroma.Lexer // lexer name (lang name) in lower-case + fileBaseMap map[string]chroma.Lexer + fileExtMap map[string]chroma.Lexer + fileParts []struct { + part string + lexer chroma.Lexer + } +}, +) { + ret.lowerNameMap = make(map[string]chroma.Lexer) + ret.fileBaseMap = make(map[string]chroma.Lexer) + ret.fileExtMap = make(map[string]chroma.Lexer) + + // Chroma has overlaps in file extension for different languages, + // When we need to do fast render, there is no way to detect the language by content, + // So we can only choose some default languages for the overlapped file extensions. + ret.conflictingExtLangMap = map[string]string{ + ".as": "ActionScript 3", // ActionScript + ".asm": "NASM", // TASM, NASM, RGBDS Assembly, Z80 Assembly + ".ASM": "NASM", + ".bas": "VB.net", // QBasic + ".bf": "Beef", // Brainfuck + ".fs": "FSharp", // Forth + ".gd": "GDScript", // GDScript3 + ".h": "C", // Objective-C + ".hcl": "Terraform", // HCL + ".hh": "C++", // HolyC + ".inc": "PHP", // ObjectPascal, POVRay, SourcePawn, PHTML + ".m": "Objective-C", // Matlab, Mathematica, Mason + ".mc": "Mason", // MonkeyC + ".network": "SYSTEMD", // INI + ".php": "PHP", // PHTML + ".php3": "PHP", // PHTML + ".php4": "PHP", // PHTML + ".php5": "PHP", // PHTML + ".pl": "Perl", // Prolog, Raku + ".pm": "Perl", // Promela, Raku + ".pp": "ObjectPascal", // Puppet + ".s": "ArmAsm", // GAS + ".S": "ArmAsm", // R, GAS + ".service": "SYSTEMD", // INI + ".socket": "SYSTEMD", // INI + ".sql": "SQL", // MySQL + ".t": "Perl", // Raku + ".ts": "TypeScript", // TypoScript + ".v": "V", // verilog + ".xslt": "HTML", // XML + } + + isPlainPattern := func(key string) bool { + return !strings.ContainsAny(key, "*?[]") // only support simple patterns + } + + setMapWithLowerKey := func(m map[string]chroma.Lexer, key string, lexer chroma.Lexer) { + if _, conflict := m[key]; conflict { + panic("duplicate key in lexer map: " + key + ", need to add it to conflictingExtLangMap") + } + m[key] = lexer + m[mapKeyLowerPrefix+strings.ToLower(key)] = lexer + } + + processFileName := func(fileName string, lexer chroma.Lexer) bool { + if isPlainPattern(fileName) { + // full base name match + setMapWithLowerKey(ret.fileBaseMap, fileName, lexer) + return true + } + if strings.HasPrefix(fileName, "*") { + // ext name match: "*.js" + fileExt := strings.Trim(fileName, "*") + if isPlainPattern(fileExt) { + presetName := ret.conflictingExtLangMap[fileExt] + if presetName == "" || lexer.Config().Name == presetName { + setMapWithLowerKey(ret.fileExtMap, fileExt, lexer) + } + return true + } + } + if strings.HasSuffix(fileName, "*") { + // part match: "*.env.*" + filePart := strings.Trim(fileName, "*") + if isPlainPattern(filePart) { + ret.fileParts = append(ret.fileParts, struct { + part string + lexer chroma.Lexer + }{ + part: filePart, + lexer: lexer, + }) + return true + } + } + return false + } + + expandGlobPatterns := func(patterns []string) []string { + // expand patterns like "file.[ch]" to "file.c" and "file.h", only one pair of "[]" is supported, enough for current Chroma lexers + for idx, s := range patterns { + idx1 := strings.IndexByte(s, '[') + idx2 := strings.IndexByte(s, ']') + if idx1 != -1 && idx2 != -1 && idx2 > idx1+1 { + left, mid, right := s[:idx1], s[idx1+1:idx2], s[idx2+1:] + patterns[idx] = left + mid[0:1] + right + for i := 1; i < len(mid); i++ { + patterns = append(patterns, left+mid[i:i+1]+right) + } + } + } + return patterns + } + + // add lexers to our map, for fast lookup + for _, lexer := range lexers.GlobalLexerRegistry.Lexers { + cfg := lexer.Config() + ret.lowerNameMap[strings.ToLower(lexer.Config().Name)] = lexer + for _, alias := range cfg.Aliases { + ret.lowerNameMap[strings.ToLower(alias)] = lexer + } + for _, s := range expandGlobPatterns(cfg.Filenames) { + if !processFileName(s, lexer) { + panic("unsupported file name pattern in lexer: " + s) + } + } + for _, s := range expandGlobPatterns(cfg.AliasFilenames) { + if !processFileName(s, lexer) { + panic("unsupported alias file name pattern in lexer: " + s) + } + } + } + + // final check: make sure the default ext-lang mapping is correct, nothing is missing + for ext, lexerName := range ret.conflictingExtLangMap { + if lexer, ok := ret.fileExtMap[ext]; !ok || lexer.Config().Name != lexerName { + panic("missing default ext-lang mapping for: " + ext) + } + } + return ret +}) + +func normalizeFileNameLang(fileName, fileLang string) (string, string) { + fileName = path.Base(fileName) + fileLang, _, _ = strings.Cut(fileLang, "?") // maybe, the value from gitattributes might contain `?` parameters? + ext := path.Ext(fileName) + // the "lang" might come from enry or gitattributes, it has different naming for some languages + switch fileLang { + case "F#": + fileLang = "FSharp" + case "Pascal": + fileLang = "ObjectPascal" + case "C": + if ext == ".C" || ext == ".H" { + fileLang = "C++" + } + } + return fileName, fileLang +} + +func DetectChromaLexerByFileName(fileName, fileLang string) chroma.Lexer { + lexer, _ := detectChromaLexerByFileName(fileName, fileLang) + return lexer +} + +func detectChromaLexerByFileName(fileName, fileLang string) (_ chroma.Lexer, byLang bool) { + fileName, fileLang = normalizeFileNameLang(fileName, fileLang) + fileExt := path.Ext(fileName) + + // apply custom mapping for file extension, highest priority, for example: + // * ".my-js" -> ".js" + // * ".my-html" -> "HTML" + if fileExt != "" { + if val, ok := globalVars().highlightMapping[fileExt]; ok { + if strings.HasPrefix(val, ".") { + fileName = "dummy" + val + fileLang = "" + } else { + fileLang = val + } + } + } + + // try to use language for lexer name + if fileLang != "" { + lexer := chromaLexers().lowerNameMap[strings.ToLower(fileLang)] + if lexer != nil { + return lexer, true + } + } + + if fileName == "" { + return lexers.Fallback, false + } + + // try base name + { + baseName := path.Base(fileName) + if lexer, ok := chromaLexers().fileBaseMap[baseName]; ok { + return lexer, false + } else if lexer, ok = chromaLexers().fileBaseMap[mapKeyLowerPrefix+strings.ToLower(baseName)]; ok { + return lexer, false + } + } + + if fileExt == "" { + return lexers.Fallback, false + } + + // try ext name + { + if lexer, ok := chromaLexers().fileExtMap[fileExt]; ok { + return lexer, false + } else if lexer, ok = chromaLexers().fileExtMap[mapKeyLowerPrefix+strings.ToLower(fileExt)]; ok { + return lexer, false + } + } + + // try file part match, for example: ".env.local" for "*.env.*" + // it assumes that there must be a dot in filename (fileExt isn't empty) + for _, item := range chromaLexers().fileParts { + if strings.Contains(fileName, item.part) { + return item.lexer, false + } + } + return lexers.Fallback, false +} + +// detectChromaLexerWithAnalyze returns a chroma lexer by given file name, language and code content. All parameters can be optional. +// When code content is provided, it will be slow if no lexer is found by file name or language. +// If no lexer is found, it will return the fallback lexer. +func detectChromaLexerWithAnalyze(fileName, lang string, code []byte) chroma.Lexer { + lexer, byLang := detectChromaLexerByFileName(fileName, lang) + + // if lang is provided, and it matches a lexer, use it directly + if byLang { + return lexer + } + + // if a lexer is detected and there is no conflict for the file extension, use it directly + fileExt := path.Ext(fileName) + _, hasConflicts := chromaLexers().conflictingExtLangMap[fileExt] + if !hasConflicts && lexer != lexers.Fallback { + return lexer + } + + // try to detect language by content, for best guessing for the language + // when using "code" to detect, analyze.GetCodeLanguage is slow, it iterates many rules to detect language from content + analyzedLanguage := analyze.GetCodeLanguage(fileName, code) + lexer = DetectChromaLexerByFileName(fileName, analyzedLanguage) + if lexer == lexers.Fallback { + if analyzedLanguage != enry.OtherLanguage { + log.Warn("No chroma lexer found for enry detected language: %s (file: %s), need to fix the language mapping between enry and chroma.", analyzedLanguage, fileName) + } + } + return lexer +} diff --git a/modules/highlight/lexerdetect_test.go b/modules/highlight/lexerdetect_test.go new file mode 100644 index 0000000000..868e793a68 --- /dev/null +++ b/modules/highlight/lexerdetect_test.go @@ -0,0 +1,90 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package highlight + +import ( + "strings" + "testing" + + "github.com/alecthomas/chroma/v2/lexers" + "github.com/stretchr/testify/assert" +) + +func BenchmarkDetectChromaLexerByFileName(b *testing.B) { + for b.Loop() { + // BenchmarkDetectChromaLexerByFileName-12 18214717 61.35 ns/op + DetectChromaLexerByFileName("a.sql", "") + } +} + +func BenchmarkDetectChromaLexerWithAnalyze(b *testing.B) { + b.StopTimer() + code := []byte(strings.Repeat("SELECT * FROM table;\n", 1000)) + b.StartTimer() + for b.Loop() { + // BenchmarkRenderCodeSlowGuess-12 87946 13310 ns/op + detectChromaLexerWithAnalyze("a", "", code) + } +} + +func BenchmarkChromaAnalyze(b *testing.B) { + b.StopTimer() + code := strings.Repeat("SELECT * FROM table;\n", 1000) + b.StartTimer() + for b.Loop() { + // comparing to detectChromaLexerWithAnalyze (go-enry), "chroma/lexers.Analyse" is very slow + // BenchmarkChromaAnalyze-12 519 2247104 ns/op + lexers.Analyse(code) + } +} + +func BenchmarkRenderCodeByLexer(b *testing.B) { + b.StopTimer() + code := strings.Repeat("SELECT * FROM table;\n", 1000) + lexer := DetectChromaLexerByFileName("a.sql", "") + b.StartTimer() + for b.Loop() { + // Really slow ....... + // BenchmarkRenderCodeByLexer-12 22 47159038 ns/op + RenderCodeByLexer(lexer, code) + } +} + +func TestDetectChromaLexer(t *testing.T) { + globalVars().highlightMapping[".my-html"] = "HTML" + t.Cleanup(func() { delete(globalVars().highlightMapping, ".my-html") }) + + cases := []struct { + fileName string + language string + content string + expected string + }{ + {"test.py", "", "", "Python"}, + + {"any-file", "javascript", "", "JavaScript"}, + {"any-file", "", "/* vim: set filetype=python */", "Python"}, + {"any-file", "", "", "fallback"}, + + {"test.fs", "", "", "FSharp"}, + {"test.fs", "F#", "", "FSharp"}, + {"test.fs", "", "let x = 1", "FSharp"}, + + {"test.c", "", "", "C"}, + {"test.C", "", "", "C++"}, + {"OLD-CODE.PAS", "", "", "ObjectPascal"}, + {"test.my-html", "", "", "HTML"}, + + {"a.php", "", "", "PHP"}, + {"a.sql", "", "", "SQL"}, + {"dhcpd.conf", "", "", "ISCdhcpd"}, + {".env.my-production", "", "", "Bash"}, + } + for _, c := range cases { + lexer := detectChromaLexerWithAnalyze(c.fileName, c.language, []byte(c.content)) + if assert.NotNil(t, lexer, "case: %+v", c) { + assert.Equal(t, c.expected, lexer.Config().Name, "case: %+v", c) + } + } +} diff --git a/modules/htmlutil/html.go b/modules/htmlutil/html.go index efbc174b2e..8dbfe0c22e 100644 --- a/modules/htmlutil/html.go +++ b/modules/htmlutil/html.go @@ -6,6 +6,7 @@ package htmlutil import ( "fmt" "html/template" + "io" "slices" "strings" ) @@ -31,7 +32,7 @@ func ParseSizeAndClass(defaultSize int, defaultClass string, others ...any) (int return size, class } -func HTMLFormat(s template.HTML, rawArgs ...any) template.HTML { +func htmlFormatArgs(s template.HTML, rawArgs []any) []any { if !strings.Contains(string(s), "%") || len(rawArgs) == 0 { panic("HTMLFormat requires one or more arguments") } @@ -50,5 +51,35 @@ func HTMLFormat(s template.HTML, rawArgs ...any) template.HTML { args[i] = template.HTMLEscapeString(fmt.Sprint(v)) } } - return template.HTML(fmt.Sprintf(string(s), args...)) + return args +} + +func HTMLFormat(s template.HTML, rawArgs ...any) template.HTML { + return template.HTML(fmt.Sprintf(string(s), htmlFormatArgs(s, rawArgs)...)) +} + +func HTMLPrintf(w io.Writer, s template.HTML, rawArgs ...any) (int, error) { + return fmt.Fprintf(w, string(s), htmlFormatArgs(s, rawArgs)...) +} + +func HTMLPrint(w io.Writer, s template.HTML) (int, error) { + return io.WriteString(w, string(s)) +} + +func HTMLPrintTag(w io.Writer, tag template.HTML, attrs map[string]string) (written int, err error) { + n, err := io.WriteString(w, "<"+string(tag)) + written += n + if err != nil { + return written, err + } + for k, v := range attrs { + n, err = fmt.Fprintf(w, ` %s="%s"`, template.HTMLEscapeString(k), template.HTMLEscapeString(v)) + written += n + if err != nil { + return written, err + } + } + n, err = io.WriteString(w, ">") + written += n + return written, err } diff --git a/modules/httplib/request.go b/modules/httplib/request.go index 49ea6f4b73..8542a57d36 100644 --- a/modules/httplib/request.go +++ b/modules/httplib/request.go @@ -7,54 +7,53 @@ package httplib import ( "bytes" "context" - "crypto/tls" - "errors" "fmt" "io" "net" "net/http" "net/url" "strings" + "sync" "time" ) -var defaultSetting = Settings{"GiteaServer", 60 * time.Second, 60 * time.Second, nil, nil} - -// newRequest returns *Request with specific method -func newRequest(url, method string) *Request { - var resp http.Response - req := http.Request{ - Method: method, - Header: make(http.Header), - Proto: "HTTP/1.1", - ProtoMajor: 1, - ProtoMinor: 1, +var defaultTransport = sync.OnceValue(func() http.RoundTripper { + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: DialContextWithTimeout(10 * time.Second), // it is good enough in modern days + } +}) + +func DialContextWithTimeout(timeout time.Duration) func(ctx context.Context, network, address string) (net.Conn, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + return (&net.Dialer{Timeout: timeout}).DialContext(ctx, network, address) } - return &Request{url, &req, map[string]string{}, defaultSetting, &resp, nil} } -// NewRequest returns *Request with specific method func NewRequest(url, method string) *Request { - return newRequest(url, method) + return &Request{ + url: url, + req: &http.Request{ + Method: method, + Header: make(http.Header), + Proto: "HTTP/1.1", // FIXME: from legacy httplib, it shouldn't be hardcoded + ProtoMajor: 1, + ProtoMinor: 1, + }, + params: map[string]string{}, + + // ATTENTION: from legacy httplib, callers must pay more attention to it, it will cause annoying bugs when the response takes a long time + readWriteTimeout: 60 * time.Second, + } } -// Settings is the default settings for http client -type Settings struct { - UserAgent string - ConnectTimeout time.Duration - ReadWriteTimeout time.Duration - TLSClientConfig *tls.Config - Transport http.RoundTripper -} - -// Request provides more useful methods for requesting one url than http.Request. type Request struct { - url string - req *http.Request - params map[string]string - setting Settings - resp *http.Response - body []byte + url string + req *http.Request + params map[string]string + + readWriteTimeout time.Duration + transport http.RoundTripper } // SetContext sets the request's Context @@ -63,36 +62,24 @@ func (r *Request) SetContext(ctx context.Context) *Request { return r } -// SetTimeout sets connect time out and read-write time out for BeegoRequest. -func (r *Request) SetTimeout(connectTimeout, readWriteTimeout time.Duration) *Request { - r.setting.ConnectTimeout = connectTimeout - r.setting.ReadWriteTimeout = readWriteTimeout +// SetTransport sets the request transport, if not set, will use httplib's default transport with environment proxy support +// ATTENTION: the http.Transport has a connection pool, so it should be reused as much as possible, do not create a lot of transports +func (r *Request) SetTransport(transport http.RoundTripper) *Request { + r.transport = transport return r } func (r *Request) SetReadWriteTimeout(readWriteTimeout time.Duration) *Request { - r.setting.ReadWriteTimeout = readWriteTimeout + r.readWriteTimeout = readWriteTimeout return r } -// SetTLSClientConfig sets tls connection configurations if visiting https url. -func (r *Request) SetTLSClientConfig(config *tls.Config) *Request { - r.setting.TLSClientConfig = config - return r -} - -// Header add header item string in request. +// Header set header item string in request. func (r *Request) Header(key, value string) *Request { r.req.Header.Set(key, value) return r } -// SetTransport sets transport to -func (r *Request) SetTransport(transport http.RoundTripper) *Request { - r.setting.Transport = transport - return r -} - // Param adds query param in to request. // params build query string as ?key1=value1&key2=value2... func (r *Request) Param(key, value string) *Request { @@ -125,11 +112,9 @@ func (r *Request) Body(data any) *Request { return r } -func (r *Request) getResponse() (*http.Response, error) { - if r.resp.StatusCode != 0 { - return r.resp, nil - } - +// Response executes request client and returns the response. +// Caller MUST close the response body if no error occurs. +func (r *Request) Response() (*http.Response, error) { var paramBody string if len(r.params) > 0 { var buf bytes.Buffer @@ -160,59 +145,19 @@ func (r *Request) getResponse() (*http.Response, error) { return nil, err } - trans := r.setting.Transport - if trans == nil { - // create default transport - trans = &http.Transport{ - TLSClientConfig: r.setting.TLSClientConfig, - Proxy: http.ProxyFromEnvironment, - DialContext: TimeoutDialer(r.setting.ConnectTimeout), - } - } else if t, ok := trans.(*http.Transport); ok { - if t.TLSClientConfig == nil { - t.TLSClientConfig = r.setting.TLSClientConfig - } - if t.DialContext == nil { - t.DialContext = TimeoutDialer(r.setting.ConnectTimeout) - } - } - client := &http.Client{ - Transport: trans, - Timeout: r.setting.ReadWriteTimeout, + Transport: r.transport, + Timeout: r.readWriteTimeout, + } + if client.Transport == nil { + client.Transport = defaultTransport() } - if len(r.setting.UserAgent) > 0 && len(r.req.Header.Get("User-Agent")) == 0 { - r.req.Header.Set("User-Agent", r.setting.UserAgent) + if r.req.Header.Get("User-Agent") == "" { + r.req.Header.Set("User-Agent", "GiteaHttpLib") } - resp, err := client.Do(r.req) - if err != nil { - return nil, err - } - r.resp = resp - return resp, nil -} - -// Response executes request client gets response manually. -// Caller MUST close the response body if no error occurs -func (r *Request) Response() (*http.Response, error) { - if r == nil { - return nil, errors.New("invalid request") - } - return r.getResponse() -} - -// TimeoutDialer returns functions of connection dialer with timeout settings for http.Transport Dial field. -func TimeoutDialer(cTimeout time.Duration) func(ctx context.Context, net, addr string) (c net.Conn, err error) { - return func(ctx context.Context, netw, addr string) (net.Conn, error) { - d := net.Dialer{Timeout: cTimeout} - conn, err := d.DialContext(ctx, netw, addr) - if err != nil { - return nil, err - } - return conn, nil - } + return client.Do(r.req) } func (r *Request) GoString() string { diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index 7c1edf432d..2d66a86a8b 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -19,7 +19,6 @@ import ( charsetModule "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/httpcache" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" @@ -109,11 +108,7 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt } if isPlain { - charset, err := charsetModule.DetectEncoding(mineBuf) - if err != nil { - log.Error("Detect raw file %s charset failed: %v, using by default utf-8", opts.Filename, err) - charset = "utf-8" - } + charset, _ := charsetModule.DetectEncoding(mineBuf) opts.ContentTypeCharset = strings.ToLower(charset) } @@ -126,6 +121,7 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt // no sandbox attribute for pdf as it breaks rendering in at least safari. this // should generally be safe as scripts inside PDF can not escape the PDF document // see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion + // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") } diff --git a/modules/indexer/code/bleve/bleve.go b/modules/indexer/code/bleve/bleve.go index 0e2d0f879a..010ee39660 100644 --- a/modules/indexer/code/bleve/bleve.go +++ b/modules/indexer/code/bleve/bleve.go @@ -4,7 +4,6 @@ package bleve import ( - "bufio" "context" "fmt" "io" @@ -151,7 +150,7 @@ func NewIndexer(indexDir string) *Indexer { } } -func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserError, batchReader *bufio.Reader, commitSha string, +func (b *Indexer) addUpdate(ctx context.Context, catFileBatch git.CatFileBatch, commitSha string, update internal.FileUpdate, repo *repo_model.Repository, batch *inner_bleve.FlushingBatch, ) error { // Ignore vendored files in code search @@ -164,7 +163,7 @@ func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserErro var err error if !update.Sized { var stdout string - stdout, err = gitrepo.RunCmdString(ctx, repo, gitcmd.NewCommand("cat-file", "-s").AddDynamicArguments(update.BlobSha)) + stdout, _, err = gitrepo.RunCmdString(ctx, repo, gitcmd.NewCommand("cat-file", "-s").AddDynamicArguments(update.BlobSha)) if err != nil { return err } @@ -177,16 +176,11 @@ func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserErro return b.addDelete(update.Filename, repo, batch) } - if _, err := batchWriter.Write([]byte(update.BlobSha + "\n")); err != nil { - return err - } - - _, _, size, err = git.ReadBatchLine(batchReader) + info, batchReader, err := catFileBatch.QueryContent(update.BlobSha) if err != nil { return err } - - fileContents, err := io.ReadAll(io.LimitReader(batchReader, size)) + fileContents, err := io.ReadAll(io.LimitReader(batchReader, info.Size)) if err != nil { return err } else if !typesniffer.DetectContentType(fileContents).IsText() { @@ -203,7 +197,7 @@ func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserErro RepoID: repo.ID, CommitID: commitSha, Filename: update.Filename, - Content: string(charset.ToUTF8DropErrors(fileContents, charset.ConvertOpts{})), + Content: string(charset.ToUTF8DropErrors(fileContents)), Language: analyze.GetCodeLanguage(update.Filename, fileContents), UpdatedAt: time.Now().UTC(), }) @@ -218,18 +212,17 @@ func (b *Indexer) addDelete(filename string, repo *repo_model.Repository, batch func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *internal.RepoChanges) error { batch := inner_bleve.NewFlushingBatch(b.inner.Indexer, maxBatchSize) if len(changes.Updates) > 0 { - gitBatch, err := git.NewBatch(ctx, repo.RepoPath()) + catfileBatch, err := gitrepo.NewBatch(ctx, repo) if err != nil { return err } - defer gitBatch.Close() + defer catfileBatch.Close() for _, update := range changes.Updates { - if err := b.addUpdate(ctx, gitBatch.Writer, gitBatch.Reader, sha, update, repo, batch); err != nil { + if err := b.addUpdate(ctx, catfileBatch, sha, update, repo, batch); err != nil { return err } } - gitBatch.Close() } for _, filename := range changes.RemovedFilenames { if err := b.addDelete(filename, repo, batch); err != nil { diff --git a/modules/indexer/code/elasticsearch/elasticsearch.go b/modules/indexer/code/elasticsearch/elasticsearch.go index 012c57da29..99f974b646 100644 --- a/modules/indexer/code/elasticsearch/elasticsearch.go +++ b/modules/indexer/code/elasticsearch/elasticsearch.go @@ -4,7 +4,6 @@ package elasticsearch import ( - "bufio" "context" "fmt" "io" @@ -139,7 +138,7 @@ const ( }` ) -func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserError, batchReader *bufio.Reader, sha string, update internal.FileUpdate, repo *repo_model.Repository) ([]elastic.BulkableRequest, error) { +func (b *Indexer) addUpdate(ctx context.Context, catFileBatch git.CatFileBatch, sha string, update internal.FileUpdate, repo *repo_model.Repository) ([]elastic.BulkableRequest, error) { // Ignore vendored files in code search if setting.Indexer.ExcludeVendored && analyze.IsVendor(update.Filename) { return nil, nil @@ -149,7 +148,7 @@ func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserErro var err error if !update.Sized { var stdout string - stdout, err = gitrepo.RunCmdString(ctx, repo, gitcmd.NewCommand("cat-file", "-s").AddDynamicArguments(update.BlobSha)) + stdout, _, err = gitrepo.RunCmdString(ctx, repo, gitcmd.NewCommand("cat-file", "-s").AddDynamicArguments(update.BlobSha)) if err != nil { return nil, err } @@ -162,16 +161,12 @@ func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserErro return []elastic.BulkableRequest{b.addDelete(update.Filename, repo)}, nil } - if _, err := batchWriter.Write([]byte(update.BlobSha + "\n")); err != nil { - return nil, err - } - - _, _, size, err = git.ReadBatchLine(batchReader) + info, batchReader, err := catFileBatch.QueryContent(update.BlobSha) if err != nil { return nil, err } - fileContents, err := io.ReadAll(io.LimitReader(batchReader, size)) + fileContents, err := io.ReadAll(io.LimitReader(batchReader, info.Size)) if err != nil { return nil, err } else if !typesniffer.DetectContentType(fileContents).IsText() { @@ -191,7 +186,7 @@ func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserErro Doc(map[string]any{ "repo_id": repo.ID, "filename": update.Filename, - "content": string(charset.ToUTF8DropErrors(fileContents, charset.ConvertOpts{})), + "content": string(charset.ToUTF8DropErrors(fileContents)), "commit_id": sha, "language": analyze.GetCodeLanguage(update.Filename, fileContents), "updated_at": timeutil.TimeStampNow(), @@ -210,14 +205,14 @@ func (b *Indexer) addDelete(filename string, repo *repo_model.Repository) elasti func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *internal.RepoChanges) error { reqs := make([]elastic.BulkableRequest, 0) if len(changes.Updates) > 0 { - batch, err := git.NewBatch(ctx, repo.RepoPath()) + batch, err := gitrepo.NewBatch(ctx, repo) if err != nil { return err } defer batch.Close() for _, update := range changes.Updates { - updateReqs, err := b.addUpdate(ctx, batch.Writer, batch.Reader, sha, update, repo) + updateReqs, err := b.addUpdate(ctx, batch, sha, update, repo) if err != nil { return err } @@ -225,7 +220,6 @@ func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha st reqs = append(reqs, updateReqs...) } } - batch.Close() } for _, filename := range changes.RemovedFilenames { diff --git a/modules/indexer/code/git.go b/modules/indexer/code/git.go index ca9c6a2974..a17b10551d 100644 --- a/modules/indexer/code/git.go +++ b/modules/indexer/code/git.go @@ -18,7 +18,7 @@ import ( ) func getDefaultBranchSha(ctx context.Context, repo *repo_model.Repository) (string, error) { - stdout, err := gitrepo.RunCmdString(ctx, repo, gitcmd.NewCommand("show-ref", "-s").AddDynamicArguments(git.BranchPrefix+repo.DefaultBranch)) + stdout, _, err := gitrepo.RunCmdString(ctx, repo, gitcmd.NewCommand("show-ref", "-s").AddDynamicArguments(git.BranchPrefix+repo.DefaultBranch)) if err != nil { return "", err } @@ -35,7 +35,7 @@ func getRepoChanges(ctx context.Context, repo *repo_model.Repository, revision s needGenesis := len(status.CommitSha) == 0 if !needGenesis { hasAncestorCmd := gitcmd.NewCommand("merge-base").AddDynamicArguments(status.CommitSha, revision) - stdout, _ := gitrepo.RunCmdString(ctx, repo, hasAncestorCmd) + stdout, _, _ := gitrepo.RunCmdString(ctx, repo, hasAncestorCmd) // FIXME: error is not handled needGenesis = len(stdout) == 0 } @@ -101,7 +101,7 @@ func genesisChanges(ctx context.Context, repo *repo_model.Repository, revision s // nonGenesisChanges get changes since the previous indexer update func nonGenesisChanges(ctx context.Context, repo *repo_model.Repository, revision string) (*internal.RepoChanges, error) { diffCmd := gitcmd.NewCommand("diff", "--name-status").AddDynamicArguments(repo.CodeIndexerStatus.CommitSha, revision) - stdout, runErr := gitrepo.RunCmdString(ctx, repo, diffCmd) + stdout, _, runErr := gitrepo.RunCmdString(ctx, repo, diffCmd) if runErr != nil { // previous commit sha may have been removed by a force push, so // try rebuilding from scratch diff --git a/modules/indexer/code/internal/util.go b/modules/indexer/code/internal/util.go index fa958be473..5d62a5ccb9 100644 --- a/modules/indexer/code/internal/util.go +++ b/modules/indexer/code/internal/util.go @@ -17,20 +17,20 @@ func FilenameIndexerID(repoID int64, filename string) string { } func ParseIndexerID(indexerID string) (int64, string) { - index := strings.IndexByte(indexerID, '_') - if index == -1 { + before, after, ok := strings.Cut(indexerID, "_") + if !ok { log.Error("Unexpected ID in repo indexer: %s", indexerID) } - repoID, _ := internal.ParseBase36(indexerID[:index]) - return repoID, indexerID[index+1:] + repoID, _ := internal.ParseBase36(before) + return repoID, after } func FilenameOfIndexerID(indexerID string) string { - index := strings.IndexByte(indexerID, '_') - if index == -1 { + _, after, ok := strings.Cut(indexerID, "_") + if !ok { log.Error("Unexpected ID in repo indexer: %s", indexerID) } - return indexerID[index+1:] + return after } // FilenameMatchIndexPos returns the boundaries of its first seven lines. diff --git a/modules/indexer/code/search.go b/modules/indexer/code/search.go index a7a5d7d2e3..eb20b70e71 100644 --- a/modules/indexer/code/search.go +++ b/modules/indexer/code/search.go @@ -72,10 +72,11 @@ func writeStrings(buf *bytes.Buffer, strs ...string) error { func HighlightSearchResultCode(filename, language string, lineNums []int, code string) []*ResultLine { // we should highlight the whole code block first, otherwise it doesn't work well with multiple line highlighting - hl, _ := highlight.Code(filename, language, code) + lexer := highlight.DetectChromaLexerByFileName(filename, language) + hl := highlight.RenderCodeByLexer(lexer, code) highlightedLines := strings.Split(string(hl), "\n") - // The lineNums outputted by highlight.Code might not match the original lineNums, because "highlight" removes the last `\n` + // The lineNums outputted by render might not match the original lineNums, because "highlight" removes the last `\n` lines := make([]*ResultLine, min(len(highlightedLines), len(lineNums))) for i := range lines { lines[i] = &ResultLine{ diff --git a/modules/indexer/issues/elasticsearch/elasticsearch_test.go b/modules/indexer/issues/elasticsearch/elasticsearch_test.go index dc329c07dd..cb9ed3889d 100644 --- a/modules/indexer/issues/elasticsearch/elasticsearch_test.go +++ b/modules/indexer/issues/elasticsearch/elasticsearch_test.go @@ -30,7 +30,11 @@ func TestElasticsearchIndexer(t *testing.T) { require.Eventually(t, func() bool { resp, err := http.Get(url) - return err == nil && resp.StatusCode == http.StatusOK + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK }, time.Minute, time.Second, "Expected elasticsearch to be up") indexer := NewIndexer(url, fmt.Sprintf("test_elasticsearch_indexer_%d", time.Now().Unix())) diff --git a/modules/indexer/issues/meilisearch/meilisearch.go b/modules/indexer/issues/meilisearch/meilisearch.go index b7fae6ee9a..5715cf4794 100644 --- a/modules/indexer/issues/meilisearch/meilisearch.go +++ b/modules/indexer/issues/meilisearch/meilisearch.go @@ -124,7 +124,7 @@ func (b *Indexer) Delete(_ context.Context, ids ...int64) error { } for _, id := range ids { - _, err := b.inner.Client.Index(b.inner.VersionedIndexName()).DeleteDocument(strconv.FormatInt(id, 10)) + _, err := b.inner.Client.Index(b.inner.VersionedIndexName()).DeleteDocument(strconv.FormatInt(id, 10), nil) if err != nil { return err } diff --git a/modules/indexer/issues/meilisearch/meilisearch_test.go b/modules/indexer/issues/meilisearch/meilisearch_test.go index a32cbdd6de..81a27487bb 100644 --- a/modules/indexer/issues/meilisearch/meilisearch_test.go +++ b/modules/indexer/issues/meilisearch/meilisearch_test.go @@ -36,7 +36,11 @@ func TestMeilisearchIndexer(t *testing.T) { require.Eventually(t, func() bool { resp, err := http.Get(url) - return err == nil && resp.StatusCode == http.StatusOK + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK }, time.Minute, time.Second, "Expected meilisearch to be up") indexer := NewIndexer(url, key, fmt.Sprintf("test_meilisearch_indexer_%d", time.Now().Unix())) diff --git a/modules/issue/template/unmarshal.go b/modules/issue/template/unmarshal.go index 1d8e9dd02d..ceab6babf4 100644 --- a/modules/issue/template/unmarshal.go +++ b/modules/issue/template/unmarshal.go @@ -5,7 +5,6 @@ package template import ( "fmt" - "io" "path" "strconv" @@ -76,7 +75,7 @@ func unmarshalFromEntry(entry *git.TreeEntry, filename string) (*api.IssueTempla } defer r.Close() - content, err := io.ReadAll(r) + content, err := util.ReadWithLimit(r, 1024*1024) if err != nil { return nil, fmt.Errorf("read all: %w", err) } diff --git a/modules/lfs/pointer_scanner_gogit.go b/modules/lfs/pointer_scanner_gogit.go index e153b8e24e..ccfb16b6c0 100644 --- a/modules/lfs/pointer_scanner_gogit.go +++ b/modules/lfs/pointer_scanner_gogit.go @@ -15,7 +15,7 @@ import ( ) // SearchPointerBlobs scans the whole repository for LFS pointer files -func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob, errChan chan<- error) { +func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob) error { gitRepo := repo.GoGitRepo() err := func() error { @@ -49,14 +49,7 @@ func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan c return nil }) }() - if err != nil { - select { - case <-ctx.Done(): - default: - errChan <- err - } - } close(pointerChan) - close(errChan) + return err } diff --git a/modules/lfs/pointer_scanner_nogogit.go b/modules/lfs/pointer_scanner_nogogit.go index c37a93e73b..29f5d0e346 100644 --- a/modules/lfs/pointer_scanner_nogogit.go +++ b/modules/lfs/pointer_scanner_nogogit.go @@ -8,96 +8,84 @@ package lfs import ( "bufio" "context" + "errors" "io" "strconv" "strings" - "sync" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/git/pipeline" + "code.gitea.io/gitea/modules/util" + + "golang.org/x/sync/errgroup" ) // SearchPointerBlobs scans the whole repository for LFS pointer files -func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob, errChan chan<- error) { - basePath := repo.Path +func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob) error { + cmd1AllObjs, cmd3BatchContent := gitcmd.NewCommand(), gitcmd.NewCommand() - catFileCheckReader, catFileCheckWriter := io.Pipe() - shasToBatchReader, shasToBatchWriter := io.Pipe() - catFileBatchReader, catFileBatchWriter := io.Pipe() + cmd1AllObjsStdout, cmd1AllObjsStdoutClose := cmd1AllObjs.MakeStdoutPipe() + defer cmd1AllObjsStdoutClose() - wg := sync.WaitGroup{} - wg.Add(4) - - // Create the go-routines in reverse order. + cmd3BatchContentIn, cmd3BatchContentOut, cmd3BatchContentClose := cmd3BatchContent.MakeStdinStdoutPipe() + defer cmd3BatchContentClose() + // Create the go-routines in reverse order (update: the order is not needed any more, the pipes are properly prepared) + wg := errgroup.Group{} // 4. Take the output of cat-file --batch and check if each file in turn // to see if they're pointers to files in the LFS store - go createPointerResultsFromCatFileBatch(ctx, catFileBatchReader, &wg, pointerChan) + wg.Go(func() error { + return createPointerResultsFromCatFileBatch(cmd3BatchContentOut, pointerChan) + }) // 3. Take the shas of the blobs and batch read them - go pipeline.CatFileBatch(ctx, shasToBatchReader, catFileBatchWriter, &wg, basePath) + wg.Go(func() error { + return pipeline.CatFileBatch(ctx, cmd3BatchContent, repo.Path) + }) // 2. From the provided objects restrict to blobs <=1k - go pipeline.BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader, shasToBatchWriter, &wg) + wg.Go(func() error { + return pipeline.BlobsLessThan1024FromCatFileBatchCheck(cmd1AllObjsStdout, cmd3BatchContentIn) + }) // 1. Run batch-check on all objects in the repository - if !git.DefaultFeatures().CheckVersionAtLeast("2.6.0") { - revListReader, revListWriter := io.Pipe() - shasToCheckReader, shasToCheckWriter := io.Pipe() - wg.Add(2) - go pipeline.CatFileBatchCheck(ctx, shasToCheckReader, catFileCheckWriter, &wg, basePath) - go pipeline.BlobsFromRevListObjects(revListReader, shasToCheckWriter, &wg) - go pipeline.RevListAllObjects(ctx, revListWriter, &wg, basePath, errChan) - } else { - go pipeline.CatFileBatchCheckAllObjects(ctx, catFileCheckWriter, &wg, basePath, errChan) - } - wg.Wait() - + wg.Go(func() error { + return pipeline.CatFileBatchCheckAllObjects(ctx, cmd1AllObjs, repo.Path) + }) + err := wg.Wait() close(pointerChan) - close(errChan) + return err } -func createPointerResultsFromCatFileBatch(ctx context.Context, catFileBatchReader *io.PipeReader, wg *sync.WaitGroup, pointerChan chan<- PointerBlob) { - defer wg.Done() +func createPointerResultsFromCatFileBatch(catFileBatchReader io.ReadCloser, pointerChan chan<- PointerBlob) error { defer catFileBatchReader.Close() bufferedReader := bufio.NewReader(catFileBatchReader) buf := make([]byte, 1025) -loop: for { - select { - case <-ctx.Done(): - break loop - default: - } - // File descriptor line: sha sha, err := bufferedReader.ReadString(' ') if err != nil { - _ = catFileBatchReader.CloseWithError(err) - break + return util.Iif(errors.Is(err, io.EOF), nil, err) } sha = strings.TrimSpace(sha) // Throw away the blob if _, err := bufferedReader.ReadString(' '); err != nil { - _ = catFileBatchReader.CloseWithError(err) - break + return err } sizeStr, err := bufferedReader.ReadString('\n') if err != nil { - _ = catFileBatchReader.CloseWithError(err) - break + return err } size, err := strconv.Atoi(sizeStr[:len(sizeStr)-1]) if err != nil { - _ = catFileBatchReader.CloseWithError(err) - break + return err } pointerBuf := buf[:size+1] if _, err := io.ReadFull(bufferedReader, pointerBuf); err != nil { - _ = catFileBatchReader.CloseWithError(err) - break + return err } pointerBuf = pointerBuf[:size] // Now we need to check if the pointerBuf is an LFS pointer @@ -105,7 +93,6 @@ loop: if !pointer.IsValid() { continue } - pointerChan <- PointerBlob{Hash: sha, Pointer: pointer} } } diff --git a/modules/lfs/shared.go b/modules/lfs/shared.go index cd9488e3db..e04c089e51 100644 --- a/modules/lfs/shared.go +++ b/modules/lfs/shared.go @@ -66,6 +66,21 @@ type Link struct { ExpiresAt *time.Time `json:"expires_at,omitempty"` } +func NewLink(href string) *Link { + return &Link{Href: href} +} + +func (l *Link) WithHeader(k, v string) *Link { + if v == "" { + return l + } + if l.Header == nil { + l.Header = make(map[string]string) + } + l.Header[k] = v + return l +} + // ObjectError defines the JSON structure returned to the client in case of an error. type ObjectError struct { Code int `json:"code"` diff --git a/modules/lfstransfer/backend/backend.go b/modules/lfstransfer/backend/backend.go index dd4108ea56..f4e6157091 100644 --- a/modules/lfstransfer/backend/backend.go +++ b/modules/lfstransfer/backend/backend.go @@ -157,7 +157,7 @@ func (g *GiteaBackend) Batch(_ string, pointers []transfer.BatchItem, args trans } // Download implements transfer.Backend. The returned reader must be closed by the caller. -func (g *GiteaBackend) Download(oid string, args transfer.Args) (io.ReadCloser, int64, error) { +func (g *GiteaBackend) Download(oid string, args transfer.Args) (_ io.ReadCloser, _ int64, retErr error) { idMapStr, exists := args[argID] if !exists { return nil, 0, ErrMissingID @@ -188,7 +188,15 @@ func (g *GiteaBackend) Download(oid string, args transfer.Args) (io.ReadCloser, if err != nil { return nil, 0, fmt.Errorf("failed to get response: %w", err) } - // no need to close the body here by "defer resp.Body.Close()", see below + // We must return the ReaderCloser but not "ReadAll", to avoid OOM. + // "transfer.Backend" will check io.Closer interface and close the Body reader. + // So only close the Body when error occurs + defer func() { + if retErr != nil { + _ = resp.Body.Close() + } + }() + if resp.StatusCode != http.StatusOK { return nil, 0, statusCodeToErr(resp.StatusCode) } @@ -197,7 +205,6 @@ func (g *GiteaBackend) Download(oid string, args transfer.Args) (io.ReadCloser, if err != nil { return nil, 0, fmt.Errorf("failed to parse content length: %w", err) } - // transfer.Backend will check io.Closer interface and close this Body reader return resp.Body, respSize, nil } diff --git a/modules/log/init.go b/modules/log/init.go index 3fb5200ad7..ccaab50de3 100644 --- a/modules/log/init.go +++ b/modules/log/init.go @@ -35,10 +35,10 @@ func init() { } } -func newProcessTypedContext(parent context.Context, desc string) (ctx context.Context, cancel context.CancelFunc) { +func newProcessTypedContext(parent context.Context, desc string) (context.Context, context.CancelFunc) { // the "process manager" also calls "log.Trace()" to output logs, so if we want to create new contexts by the manager, we need to disable the trace temporarily process.TraceLogDisable(true) defer process.TraceLogDisable(false) - ctx, _, cancel = process.GetManager().AddTypedContext(parent, desc, process.SystemProcessType, false) - return ctx, cancel + ctx, _, finished := process.GetManager().AddTypedContext(parent, desc, process.SystemProcessType, false) + return ctx, context.CancelFunc(finished) } diff --git a/modules/markup/asciicast/asciicast.go b/modules/markup/asciicast/asciicast.go index d86d61d7c4..b3af5eef09 100644 --- a/modules/markup/asciicast/asciicast.go +++ b/modules/markup/asciicast/asciicast.go @@ -20,14 +20,12 @@ func init() { // See https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md type Renderer struct{} -// Name implements markup.Renderer func (Renderer) Name() string { return "asciicast" } -// Extensions implements markup.Renderer -func (Renderer) Extensions() []string { - return []string{".cast"} +func (Renderer) FileNamePatterns() []string { + return []string{"*.cast"} } const ( @@ -35,12 +33,10 @@ const ( playerSrcAttr = "data-asciinema-player-src" ) -// SanitizerRules implements markup.Renderer func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { return []setting.MarkupSanitizerRule{{Element: "div", AllowAttr: playerSrcAttr}} } -// Render implements markup.Renderer func (Renderer) Render(ctx *markup.RenderContext, _ io.Reader, output io.Writer) error { rawURL := fmt.Sprintf("%s/%s/%s/raw/%s/%s", setting.AppSubURL, diff --git a/modules/markup/common/footnote.go b/modules/markup/common/footnote.go index 1ece436c66..e552a28237 100644 --- a/modules/markup/common/footnote.go +++ b/modules/markup/common/footnote.go @@ -405,9 +405,9 @@ func (r *FootnoteHTMLRenderer) renderFootnoteLink(w util.BufWriter, source []byt if entering { n := node.(*FootnoteLink) is := strconv.Itoa(n.Index) - _, _ = w.WriteString(``) // FIXME: here and below, need to keep the classes _, _ = w.WriteString(is) @@ -419,7 +419,7 @@ func (r *FootnoteHTMLRenderer) renderFootnoteLink(w util.BufWriter, source []byt func (r *FootnoteHTMLRenderer) renderFootnoteBackLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { n := node.(*FootnoteBackLink) - _, _ = w.WriteString(` `) _, _ = w.WriteString("↩︎") @@ -431,7 +431,7 @@ func (r *FootnoteHTMLRenderer) renderFootnoteBackLink(w util.BufWriter, source [ func (r *FootnoteHTMLRenderer) renderFootnote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { n := node.(*Footnote) if entering { - _, _ = w.WriteString(`
  • 0 { - markup.RegisterRenderer(&Renderer{renderer}) - } + markup.RegisterRenderer(&Renderer{renderer}) } } @@ -36,34 +37,27 @@ var ( _ markup.ExternalRenderer = (*Renderer)(nil) ) -// Name returns the external tool name func (p *Renderer) Name() string { return p.MarkupName } -// NeedPostProcess implements markup.Renderer func (p *Renderer) NeedPostProcess() bool { return p.MarkupRenderer.NeedPostProcess } -// Extensions returns the supported extensions of the tool -func (p *Renderer) Extensions() []string { - return p.FileExtensions +func (p *Renderer) FileNamePatterns() []string { + return p.FilePatterns } -// SanitizerRules implements markup.Renderer func (p *Renderer) SanitizerRules() []setting.MarkupSanitizerRule { return p.MarkupSanitizerRules } -// SanitizerDisabled disabled sanitize if return true -func (p *Renderer) SanitizerDisabled() bool { - return p.RenderContentMode == setting.RenderContentModeNoSanitizer || p.RenderContentMode == setting.RenderContentModeIframe -} - -// DisplayInIFrame represents whether render the content with an iframe -func (p *Renderer) DisplayInIFrame() bool { - return p.RenderContentMode == setting.RenderContentModeIframe +func (p *Renderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) { + ret.SanitizerDisabled = p.RenderContentMode == setting.RenderContentModeNoSanitizer || p.RenderContentMode == setting.RenderContentModeIframe + ret.DisplayInIframe = p.RenderContentMode == setting.RenderContentModeIframe + ret.ContentSandbox = p.RenderContentSandbox + return ret } func envMark(envName string) string { @@ -81,7 +75,10 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io. envMark("GITEA_PREFIX_SRC"), baseLinkSrc, envMark("GITEA_PREFIX_RAW"), baseLinkRaw, ).Replace(p.Command) - commands := strings.Fields(command) + commands, err := shellquote.Split(command) + if err != nil || len(commands) == 0 { + return fmt.Errorf("%s invalid command %q: %w", p.Name(), p.Command, err) + } args := commands[1:] if p.IsInputFile { diff --git a/modules/markup/external/openapi.go b/modules/markup/external/openapi.go new file mode 100644 index 0000000000..ac5eae53ff --- /dev/null +++ b/modules/markup/external/openapi.go @@ -0,0 +1,79 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package external + +import ( + "fmt" + "html" + "io" + + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +type openAPIRenderer struct{} + +var ( + _ markup.PostProcessRenderer = (*openAPIRenderer)(nil) + _ markup.ExternalRenderer = (*openAPIRenderer)(nil) +) + +func (p *openAPIRenderer) Name() string { + return "openapi" +} + +func (p *openAPIRenderer) NeedPostProcess() bool { + return false +} + +func (p *openAPIRenderer) FileNamePatterns() []string { + return []string{ + "openapi.yaml", + "openapi.yml", + "openapi.json", + "swagger.yaml", + "swagger.yml", + "swagger.json", + } +} + +func (p *openAPIRenderer) SanitizerRules() []setting.MarkupSanitizerRule { + return nil +} + +func (p *openAPIRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) { + ret.SanitizerDisabled = true + ret.DisplayInIframe = true + ret.ContentSandbox = "" + return ret +} + +func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { + content, err := util.ReadWithLimit(input, int(setting.UI.MaxDisplayFileSize)) + if err != nil { + return err + } + // TODO: can extract this to a tmpl file later + _, err = io.WriteString(output, fmt.Sprintf( + ` + + + + + + +
    + + +`, + setting.StaticURLPrefix, + setting.AssetVersion, + html.EscapeString(ctx.RenderOptions.RelativePath), + html.EscapeString(util.UnsafeBytesToString(content)), + setting.StaticURLPrefix, + setting.AssetVersion, + )) + return err +} diff --git a/modules/markup/html.go b/modules/markup/html.go index 51afd4be00..1c2ae6918d 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -12,7 +12,9 @@ import ( "strings" "sync" + "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/markup/common" + "code.gitea.io/gitea/modules/translation" "golang.org/x/net/html" "golang.org/x/net/html/atom" @@ -60,7 +62,7 @@ var globalVars = sync.OnceValue(func() *globalVarsType { v.shortLinkPattern = regexp.MustCompile(`\[\[(.*?)\]\](\w*)`) // anyHashPattern splits url containing SHA into parts - v.anyHashPattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{40,64})(/[-+~%./\w]+)?(\?[-+~%.\w&=]+)?(#[-+~%.\w]+)?`) + v.anyHashPattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{40,64})((\.\w+)*)(/[-+~%./\w]+)?(\?[-+~%.\w&=]+)?(#[-+~%.\w]+)?`) // comparePattern matches "http://domain/org/repo/compare/COMMIT1...COMMIT2#hash" v.comparePattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{7,64})(\.\.\.?)([0-9a-f]{7,64})?(#[-+~_%.a-zA-Z0-9]+)?`) @@ -234,6 +236,49 @@ func postProcessString(ctx *RenderContext, procs []processor, content string) (s return buf.String(), nil } +func RenderTocHeadingItems(ctx *RenderContext, nodeDetailsAttrs map[string]string, out io.Writer) { + locale, ok := ctx.Value(translation.ContextKey).(translation.Locale) + if !ok { + locale = translation.NewLocale("") + } + _, _ = htmlutil.HTMLPrintTag(out, "details", nodeDetailsAttrs) + _, _ = htmlutil.HTMLPrintf(out, "%s\n", locale.TrString("toc")) + + baseLevel := 6 + for _, header := range ctx.TocHeadingItems { + if header.HeadingLevel < baseLevel { + baseLevel = header.HeadingLevel + } + } + + currentLevel := baseLevel + indent := []byte{' ', ' '} + _, _ = htmlutil.HTMLPrint(out, "
    \n") + currentLevel-- + } + _, _ = htmlutil.HTMLPrint(out, "\n\n") +} + func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output io.Writer) error { if !ctx.usedByRender && ctx.RenderHelper != nil { defer ctx.RenderHelper.CleanUp() @@ -284,6 +329,9 @@ func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output } // Render everything to buf. + if ctx.TocShowInSection == TocShowInMain && len(ctx.TocHeadingItems) > 0 { + RenderTocHeadingItems(ctx, nil, output) + } for _, node := range newNodes { if err := html.Render(output, node); err != nil { return fmt.Errorf("markup.postProcess: html.Render: %w", err) @@ -314,7 +362,7 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod return node.NextSibling } - processNodeAttrID(node) + processNodeHeadingAndID(ctx, node) processFootnoteNode(ctx, node) // FIXME: the footnote processing should be done in the "footnote.go" renderer directly if isEmojiNode(node) { diff --git a/modules/markup/html_commit.go b/modules/markup/html_commit.go index fe7a034967..0a9b329589 100644 --- a/modules/markup/html_commit.go +++ b/modules/markup/html_commit.go @@ -16,12 +16,14 @@ import ( ) type anyHashPatternResult struct { - PosStart int - PosEnd int - FullURL string - CommitID string - SubPath string - QueryHash string + PosStart int + PosEnd int + FullURL string + CommitID string + CommitExt string + SubPath string + QueryParams string + QueryHash string } func createCodeLink(href, content, class string) *html.Node { @@ -56,7 +58,11 @@ func anyHashPatternExtract(s string) (ret anyHashPatternResult, ok bool) { return ret, false } - ret.PosStart, ret.PosEnd = m[0], m[1] + pos := 0 + + ret.PosStart, ret.PosEnd = m[pos], m[pos+1] + pos += 2 + ret.FullURL = s[ret.PosStart:ret.PosEnd] if strings.HasSuffix(ret.FullURL, ".") { // if url ends in '.', it's very likely that it is not part of the actual url but used to finish a sentence. @@ -67,14 +73,24 @@ func anyHashPatternExtract(s string) (ret anyHashPatternResult, ok bool) { } } - ret.CommitID = s[m[2]:m[3]] - if m[5] > 0 { - ret.SubPath = s[m[4]:m[5]] - } + ret.CommitID = s[m[pos]:m[pos+1]] + pos += 2 - lastStart, lastEnd := m[len(m)-2], m[len(m)-1] - if lastEnd > 0 { - ret.QueryHash = s[lastStart:lastEnd][1:] + ret.CommitExt = s[m[pos]:m[pos+1]] + pos += 4 + + if m[pos] > 0 { + ret.SubPath = s[m[pos]:m[pos+1]] + } + pos += 2 + + if m[pos] > 0 { + ret.QueryParams = s[m[pos]:m[pos+1]] + } + pos += 2 + + if m[pos] > 0 { + ret.QueryHash = s[m[pos]:m[pos+1]][1:] } return ret, true } @@ -96,6 +112,9 @@ func fullHashPatternProcessor(ctx *RenderContext, node *html.Node) { continue } text := base.ShortSha(ret.CommitID) + if ret.CommitExt != "" { + text += ret.CommitExt + } if ret.SubPath != "" { text += ret.SubPath } diff --git a/modules/markup/html_emoji.go b/modules/markup/html_emoji.go index c638065425..91ba26c676 100644 --- a/modules/markup/html_emoji.go +++ b/modules/markup/html_emoji.go @@ -5,6 +5,7 @@ package markup import ( "strings" + "unicode" "code.gitea.io/gitea/modules/emoji" "code.gitea.io/gitea/modules/setting" @@ -66,26 +67,31 @@ func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) { } m[0] += start m[1] += start - start = m[1] alias := node.Data[m[0]:m[1]] - alias = strings.ReplaceAll(alias, ":", "") - converted := emoji.FromAlias(alias) - if converted == nil { - // check if this is a custom reaction - if _, exist := setting.UI.CustomEmojisMap[alias]; exist { - replaceContent(node, m[0], m[1], createCustomEmoji(ctx, alias)) - node = node.NextSibling.NextSibling - start = 0 - continue - } + + var nextChar byte + if m[1] < len(node.Data) { + nextChar = node.Data[m[1]] + } + if nextChar == ':' || unicode.IsLetter(rune(nextChar)) || unicode.IsDigit(rune(nextChar)) { continue } - replaceContent(node, m[0], m[1], createEmoji(ctx, converted.Emoji, converted.Description)) - node = node.NextSibling.NextSibling - start = 0 + alias = strings.Trim(alias, ":") + converted := emoji.FromAlias(alias) + if converted != nil { + // standard emoji + replaceContent(node, m[0], m[1], createEmoji(ctx, converted.Emoji, converted.Description)) + node = node.NextSibling.NextSibling + start = 0 // restart searching start since node has changed + } else if _, exist := setting.UI.CustomEmojisMap[alias]; exist { + // custom reaction + replaceContent(node, m[0], m[1], createCustomEmoji(ctx, alias)) + node = node.NextSibling.NextSibling + start = 0 // restart searching start since node has changed + } } } diff --git a/modules/markup/html_link.go b/modules/markup/html_link.go index 43faef1681..1702950da8 100644 --- a/modules/markup/html_link.go +++ b/modules/markup/html_link.go @@ -33,7 +33,7 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) { // Of text and link contents sl := strings.SplitSeq(content, "|") for v := range sl { - if equalPos := strings.IndexByte(v, '='); equalPos == -1 { + if found := strings.Contains(v, "="); !found { // There is no equal in this argument; this is a mandatory arg if props["name"] == "" { if IsFullURLString(v) { @@ -55,8 +55,8 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) { } else { // There is an equal; optional argument. - sep := strings.IndexByte(v, '=') - key, val := v[:sep], html.UnescapeString(v[sep+1:]) + before, after, _ := strings.Cut(v, "=") + key, val := before, html.UnescapeString(after) // When parsing HTML, x/net/html will change all quotes which are // not used for syntax into UTF-8 quotes. So checking val[0] won't @@ -208,7 +208,6 @@ func createDescriptionLink(href, content string) *html.Node { Attr: []html.Attribute{ {Key: "href", Val: href}, {Key: "target", Val: "_blank"}, - {Key: "rel", Val: "noopener noreferrer"}, }, } textNode.Parent = linkNode diff --git a/modules/markup/html_node.go b/modules/markup/html_node.go index 4eb78fdd2b..f98e7429a2 100644 --- a/modules/markup/html_node.go +++ b/modules/markup/html_node.go @@ -6,13 +6,15 @@ package markup import ( "strings" + "code.gitea.io/gitea/modules/markup/common" + "golang.org/x/net/html" ) func isAnchorIDUserContent(s string) bool { // blackfridayExtRegex is for blackfriday extensions create IDs like fn:user-content-footnote // old logic: blackfridayExtRegex = regexp.MustCompile(`[^:]*:user-content-`) - return strings.HasPrefix(s, "user-content-") || strings.Contains(s, ":user-content-") + return strings.HasPrefix(s, "user-content-") || strings.Contains(s, ":user-content-") || isAnchorIDFootnote(s) } func isAnchorIDFootnote(s string) bool { @@ -23,16 +25,80 @@ func isAnchorHrefFootnote(s string) bool { return strings.HasPrefix(s, "#fnref:user-content-") || strings.HasPrefix(s, "#fn:user-content-") } -func processNodeAttrID(node *html.Node) { +// isHeadingTag returns true if the node is a heading tag (h1-h6) +func isHeadingTag(node *html.Node) bool { + return node.Type == html.ElementNode && + len(node.Data) == 2 && + node.Data[0] == 'h' && + node.Data[1] >= '1' && node.Data[1] <= '6' +} + +// getNodeText extracts the text content from a node and its children +func getNodeText(node *html.Node, cached **string) string { + if *cached != nil { + return **cached + } + var text strings.Builder + var extractText func(*html.Node) + extractText = func(n *html.Node) { + if n.Type == html.TextNode { + text.WriteString(n.Data) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + extractText(c) + } + } + extractText(node) + textStr := text.String() + *cached = &textStr + return textStr +} + +func processNodeHeadingAndID(ctx *RenderContext, node *html.Node) { + // TODO: handle duplicate IDs, need to track existing IDs in the document // Add user-content- to IDs and "#" links if they don't already have them, // and convert the link href to a relative link to the host root + attrIDVal := "" for idx, attr := range node.Attr { if attr.Key == "id" { - if !isAnchorIDUserContent(attr.Val) { - node.Attr[idx].Val = "user-content-" + attr.Val + attrIDVal = attr.Val + if !isAnchorIDUserContent(attrIDVal) { + attrIDVal = "user-content-" + attrIDVal + node.Attr[idx].Val = attrIDVal } } } + + if !isHeadingTag(node) || !ctx.RenderOptions.EnableHeadingIDGeneration { + return + } + + // For heading tags (h1-h6) without an id attribute, generate one from the text content. + // This ensures HTML headings like

    Title

    get proper permalink anchors + // matching the behavior of Markdown headings. + // Only enabled for repository files and wiki pages via EnableHeadingIDGeneration option. + var nodeTextCached *string + if attrIDVal == "" { + nodeText := getNodeText(node, &nodeTextCached) + if nodeText != "" { + // Use the same CleanValue function used by Markdown heading ID generation + attrIDVal = string(common.CleanValue([]byte(nodeText))) + if attrIDVal != "" { + attrIDVal = "user-content-" + attrIDVal + node.Attr = append(node.Attr, html.Attribute{Key: "id", Val: attrIDVal}) + } + } + } + if ctx.TocShowInSection != "" { + nodeText := getNodeText(node, &nodeTextCached) + if nodeText != "" && attrIDVal != "" { + ctx.TocHeadingItems = append(ctx.TocHeadingItems, &TocHeadingItem{ + HeadingLevel: int(node.Data[1] - '0'), + AnchorID: attrIDVal, + InnerText: nodeText, + }) + } + } } func processFootnoteNode(ctx *RenderContext, node *html.Node) { diff --git a/modules/markup/html_node_test.go b/modules/markup/html_node_test.go new file mode 100644 index 0000000000..007e3c2a12 --- /dev/null +++ b/modules/markup/html_node_test.go @@ -0,0 +1,104 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markup + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProcessNodeAttrID_HTMLHeadingWithoutID(t *testing.T) { + // Test that HTML headings without id get an auto-generated id from their text content + // when EnableHeadingIDGeneration is true (for repo files and wiki pages) + testCases := []struct { + name string + input string + expected string + }{ + { + name: "h1 without id", + input: `

    Heading without ID

    `, + expected: `

    Heading without ID

    `, + }, + { + name: "h2 without id", + input: `

    Another Heading

    `, + expected: `

    Another Heading

    `, + }, + { + name: "h3 without id", + input: `

    Third Level

    `, + expected: `

    Third Level

    `, + }, + { + name: "h1 with existing id should keep it", + input: `

    Heading with ID

    `, + expected: `

    Heading with ID

    `, + }, + { + name: "h1 with user-content prefix should not double prefix", + input: `

    Already Prefixed

    `, + expected: `

    Already Prefixed

    `, + }, + { + name: "heading with special characters", + input: `

    What is Wine Staging?

    `, + expected: `

    What is Wine Staging?

    `, + }, + { + name: "heading with nested elements", + input: `

    Bold and Italic

    `, + expected: `

    Bold and Italic

    `, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var result strings.Builder + ctx := NewTestRenderContext().WithEnableHeadingIDGeneration(true) + err := PostProcessDefault(ctx, strings.NewReader(tc.input), &result) + assert.NoError(t, err) + assert.Equal(t, tc.expected, strings.TrimSpace(result.String())) + }) + } +} + +func TestProcessNodeAttrID_SkipHeadingIDForComments(t *testing.T) { + // Test that HTML headings in comment-like contexts (issue comments) + // do NOT get auto-generated IDs to avoid duplicate IDs on pages with multiple documents. + // This is controlled by EnableHeadingIDGeneration which defaults to false. + testCases := []struct { + name string + input string + expected string + }{ + { + name: "h1 without id in comment context", + input: `

    Heading without ID

    `, + expected: `

    Heading without ID

    `, + }, + { + name: "h2 without id in comment context", + input: `

    Another Heading

    `, + expected: `

    Another Heading

    `, + }, + { + name: "h1 with existing id should still be prefixed", + input: `

    Heading with ID

    `, + expected: `

    Heading with ID

    `, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var result strings.Builder + // Default context without EnableHeadingIDGeneration (simulates comment rendering) + err := PostProcessDefault(NewTestRenderContext(), strings.NewReader(tc.input), &result) + assert.NoError(t, err) + assert.Equal(t, tc.expected, strings.TrimSpace(result.String())) + }) + } +} diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go index 5fdbf43f7c..76013ccd13 100644 --- a/modules/markup/html_test.go +++ b/modules/markup/html_test.go @@ -102,6 +102,16 @@ func TestRender_CrossReferences(t *testing.T) { test( inputURL, `

    0123456789/foo.txt (L2-L3)

    `) + + inputURL = "https://example.com/repo/owner/archive/0123456789012345678901234567890123456789.tar.gz" + test( + inputURL, + `

    0123456789.tar.gz

    `) + + inputURL = "https://example.com/owner/repo/commit/0123456789012345678901234567890123456789.patch?key=val" + test( + inputURL, + `

    0123456789.patch

    `) } func TestRender_links(t *testing.T) { @@ -357,12 +367,9 @@ func TestRender_emoji(t *testing.T) { `

    😎🤪🔐🤑

    `) // should match nothing - test( - "2001:0db8:85a3:0000:0000:8a2e:0370:7334", - `

    2001:0db8:85a3:0000:0000:8a2e:0370:7334

    `) - test( - ":not exist:", - `

    :not exist:

    `) + test(":100:200", `

    :100:200

    `) + test("std::thread::something", `

    std::thread::something

    `) + test(":not exist:", `

    :not exist:

    `) } func TestRender_ShortLinks(t *testing.T) { diff --git a/modules/markup/html_toc_test.go b/modules/markup/html_toc_test.go new file mode 100644 index 0000000000..e93cfc9346 --- /dev/null +++ b/modules/markup/html_toc_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markup_test + +import ( + "regexp" + "testing" + + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestToCWithHTML(t *testing.T) { + defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() + + t1 := `tag link and Bold` + t2 := "code block ``" + t3 := "markdown **bold**" + input := `--- +include_toc: true +--- + +# ` + t1 + ` +## ` + t2 + ` +#### ` + t3 + ` +## last +` + + renderCtx := markup.NewTestRenderContext().WithEnableHeadingIDGeneration(true) + resultHTML, err := markdown.RenderString(renderCtx, input) + assert.NoError(t, err) + result := string(resultHTML) + re := regexp.MustCompile(`(?s)
    .*?
    `) + result = re.ReplaceAllString(result, "\n") + expected := `
    toc + +
    + +

    tag link and Bold

    +

    code block <a>

    +

    markdown bold

    +

    last

    +` + assert.Equal(t, expected, result) +} diff --git a/modules/markup/internal/finalprocessor.go b/modules/markup/internal/finalprocessor.go index 14d46a161f..4442afa0c9 100644 --- a/modules/markup/internal/finalprocessor.go +++ b/modules/markup/internal/finalprocessor.go @@ -5,11 +5,13 @@ package internal import ( "bytes" + "html/template" "io" ) type finalProcessor struct { renderInternal *RenderInternal + extraHeadHTML template.HTML output io.Writer buf bytes.Buffer @@ -25,6 +27,32 @@ func (p *finalProcessor) Close() error { // because "postProcess" already does so. In the future we could optimize the code to process data on the fly. buf := p.buf.Bytes() buf = bytes.ReplaceAll(buf, []byte(` data-attr-class="`+p.renderInternal.secureIDPrefix), []byte(` class="`)) - _, err := p.output.Write(buf) + + tmp := bytes.TrimSpace(buf) + isLikelyHTML := len(tmp) != 0 && tmp[0] == '<' && tmp[len(tmp)-1] == '>' && bytes.Index(tmp, []byte(` 0 + if !isLikelyHTML { + // not HTML, write back directly + _, err := p.output.Write(buf) + return err + } + + // add our extra head HTML into output + headBytes := []byte("") + posHead := bytes.Index(buf, headBytes) + var part1, part2 []byte + if posHead >= 0 { + part1, part2 = buf[:posHead+len(headBytes)], buf[posHead+len(headBytes):] + } else { + part1, part2 = nil, buf + } + if len(part1) > 0 { + if _, err := p.output.Write(part1); err != nil { + return err + } + } + if _, err := io.WriteString(p.output, string(p.extraHeadHTML)); err != nil { + return err + } + _, err := p.output.Write(part2) return err } diff --git a/modules/markup/internal/internal_test.go b/modules/markup/internal/internal_test.go index 590bcbb67f..a216d75203 100644 --- a/modules/markup/internal/internal_test.go +++ b/modules/markup/internal/internal_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestRenderInternal(t *testing.T) { +func TestRenderInternalAttrs(t *testing.T) { cases := []struct { input, protected, recovered string }{ @@ -30,7 +30,7 @@ func TestRenderInternal(t *testing.T) { for _, c := range cases { var r RenderInternal out := &bytes.Buffer{} - in := r.init("sec", out) + in := r.init("sec", out, "") protected := r.ProtectSafeAttrs(template.HTML(c.input)) assert.EqualValues(t, c.protected, protected) _, _ = io.WriteString(in, string(protected)) @@ -41,7 +41,7 @@ func TestRenderInternal(t *testing.T) { var r1, r2 RenderInternal protected := r1.ProtectSafeAttrs(`
    `) assert.EqualValues(t, `
    `, protected, "non-initialized RenderInternal should not protect any attributes") - _ = r1.init("sec", nil) + _ = r1.init("sec", nil, "") protected = r1.ProtectSafeAttrs(`
    `) assert.EqualValues(t, `
    `, protected) assert.Equal(t, "data-attr-class", r1.SafeAttr("class")) @@ -54,8 +54,37 @@ func TestRenderInternal(t *testing.T) { assert.Empty(t, recovered) out2 := &bytes.Buffer{} - in2 := r2.init("sec-other", out2) + in2 := r2.init("sec-other", out2, "") _, _ = io.WriteString(in2, string(protected)) _ = in2.Close() assert.Equal(t, `
    `, out2.String(), "different secureID should not recover the value") } + +func TestRenderInternalExtraHead(t *testing.T) { + t.Run("HeadExists", func(t *testing.T) { + out := &bytes.Buffer{} + var r RenderInternal + in := r.init("sec", out, ``) + _, _ = io.WriteString(in, `any`) + _ = in.Close() + assert.Equal(t, `any`, out.String()) + }) + + t.Run("HeadNotExists", func(t *testing.T) { + out := &bytes.Buffer{} + var r RenderInternal + in := r.init("sec", out, ``) + _, _ = io.WriteString(in, `
    `) + _ = in.Close() + assert.Equal(t, `
    `, out.String()) + }) + + t.Run("NotHTML", func(t *testing.T) { + out := &bytes.Buffer{} + var r RenderInternal + in := r.init("sec", out, ``) + _, _ = io.WriteString(in, ``) + _ = in.Close() + assert.Equal(t, ``, out.String()) + }) +} diff --git a/modules/markup/internal/renderinternal.go b/modules/markup/internal/renderinternal.go index 7a3e37b120..9fd9a1c0e8 100644 --- a/modules/markup/internal/renderinternal.go +++ b/modules/markup/internal/renderinternal.go @@ -29,19 +29,19 @@ type RenderInternal struct { secureIDPrefix string } -func (r *RenderInternal) Init(output io.Writer) io.WriteCloser { +func (r *RenderInternal) Init(output io.Writer, extraHeadHTML template.HTML) io.WriteCloser { buf := make([]byte, 12) _, err := rand.Read(buf) if err != nil { panic("unable to generate secure id") } - return r.init(base64.URLEncoding.EncodeToString(buf), output) + return r.init(base64.URLEncoding.EncodeToString(buf), output, extraHeadHTML) } -func (r *RenderInternal) init(secID string, output io.Writer) io.WriteCloser { +func (r *RenderInternal) init(secID string, output io.Writer, extraHeadHTML template.HTML) io.WriteCloser { r.secureID = secID r.secureIDPrefix = r.secureID + ":" - return &finalProcessor{renderInternal: r, output: output} + return &finalProcessor{renderInternal: r, output: output, extraHeadHTML: extraHeadHTML} } func (r *RenderInternal) RecoverProtectedValue(v string) (string, bool) { diff --git a/modules/markup/main_test.go b/modules/markup/main_test.go index 564f55ac11..a8dcff475d 100644 --- a/modules/markup/main_test.go +++ b/modules/markup/main_test.go @@ -14,5 +14,7 @@ import ( func TestMain(m *testing.M) { setting.IsInTesting = true markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true + setting.Markdown.FileNamePatterns = []string{"*.md"} + markup.RefreshFileNamePatterns() os.Exit(m.Run()) } diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index b28fa9824e..555a171685 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -41,11 +41,10 @@ func (g *ASTTransformer) applyElementDir(n ast.Node) { // Transform transforms the given AST tree. func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { firstChild := node.FirstChild() - tocMode := "" ctx := pc.Get(renderContextKey).(*markup.RenderContext) rc := pc.Get(renderConfigKey).(*RenderConfig) - tocList := make([]Header, 0, 20) + tocMode := "" if rc.yamlNode != nil { metaNode := rc.toMetaNode(g) if metaNode != nil { @@ -60,8 +59,6 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa } switch v := n.(type) { - case *ast.Heading: - g.transformHeading(ctx, v, reader, &tocList) case *ast.Paragraph: g.applyElementDir(v) case *ast.List: @@ -79,19 +76,18 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa return ast.WalkContinue, nil }) - showTocInMain := tocMode == "true" /* old behavior, in main view */ || tocMode == "main" - showTocInSidebar := !showTocInMain && tocMode != "false" // not hidden, not main, then show it in sidebar - if len(tocList) > 0 && (showTocInMain || showTocInSidebar) { - if showTocInMain { - tocNode := createTOCNode(tocList, rc.Lang, nil) - node.InsertBefore(node, firstChild, tocNode) - } else { - tocNode := createTOCNode(tocList, rc.Lang, map[string]string{"open": "open"}) - ctx.SidebarTocNode = tocNode + if ctx.RenderOptions.EnableHeadingIDGeneration { + showTocInMain := tocMode == "true" /* old behavior, in main view */ || tocMode == "main" + showTocInSidebar := !showTocInMain && tocMode != "false" // not hidden, not main, then show it in sidebar + switch { + case showTocInMain: + ctx.TocShowInSection = markup.TocShowInMain + case showTocInSidebar: + ctx.TocShowInSection = markup.TocShowInSidebar } } - if len(rc.Lang) > 0 { + if rc.Lang != "" { node.SetAttributeString("lang", []byte(rc.Lang)) } } diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go index 3b788432ba..cca44a8774 100644 --- a/modules/markup/markdown/markdown.go +++ b/modules/markup/markdown/markdown.go @@ -5,6 +5,7 @@ package markdown import ( + "bytes" "errors" "html/template" "io" @@ -21,10 +22,12 @@ import ( "github.com/yuin/goldmark" highlighting "github.com/yuin/goldmark-highlighting/v2" meta "github.com/yuin/goldmark-meta" + "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" ) @@ -57,7 +60,7 @@ func (l *limitWriter) Write(data []byte) (int, error) { // newParserContext creates a parser.Context with the render context set func newParserContext(ctx *markup.RenderContext) parser.Context { - pc := parser.NewContext(parser.WithIDs(newPrefixedIDs())) + pc := parser.NewContext() pc.Set(renderContextKey, ctx) return pc } @@ -101,12 +104,48 @@ func (r *GlodmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.C } } +type goldmarkEmphasisParser struct { + parser.InlineParser +} + +func goldmarkNewEmphasisParser() parser.InlineParser { + return &goldmarkEmphasisParser{parser.NewEmphasisParser()} +} + +func (s *goldmarkEmphasisParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + line, _ := block.PeekLine() + if len(line) > 1 && line[0] == '_' { + // a special trick to avoid parsing emphasis in filenames like "module/__init__.py" + end := bytes.IndexByte(line[1:], '_') + mark := bytes.Index(line, []byte("_.py")) + // check whether the "end" matches "_.py" or "__.py" + if mark != -1 && (end == mark || end == mark-1) { + return nil + } + } + return s.InlineParser.Parse(parent, block, pc) +} + +func goldmarkDefaultParser() parser.Parser { + return parser.NewParser(parser.WithBlockParsers(parser.DefaultBlockParsers()...), + parser.WithInlineParsers([]util.PrioritizedValue{ + util.Prioritized(parser.NewCodeSpanParser(), 100), + util.Prioritized(parser.NewLinkParser(), 200), + util.Prioritized(parser.NewAutoLinkParser(), 300), + util.Prioritized(parser.NewRawHTMLParser(), 400), + util.Prioritized(goldmarkNewEmphasisParser(), 500), + }...), + parser.WithParagraphTransformers(parser.DefaultParagraphTransformers()...), + ) +} + // SpecializedMarkdown sets up the Gitea specific markdown extensions func SpecializedMarkdown(ctx *markup.RenderContext) *GlodmarkRender { // TODO: it could use a pool to cache the renderers to reuse them with different contexts // at the moment it is fast enough (see the benchmarks) r := &GlodmarkRender{ctx: ctx} r.goldmarkMarkdown = goldmark.New( + goldmark.WithParser(goldmarkDefaultParser()), goldmark.WithExtensions( extension.NewTable(extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)), extension.Strikethrough, @@ -131,7 +170,6 @@ func SpecializedMarkdown(ctx *markup.RenderContext) *GlodmarkRender { ), goldmark.WithParserOptions( parser.WithAttribute(), - parser.WithAutoHeadingID(), parser.WithASTTransformers(util.Prioritized(NewASTTransformer(&ctx.RenderInternal), 10000)), ), goldmark.WithRendererOptions(html.WithUnsafe()), @@ -202,30 +240,24 @@ func init() { markup.RegisterRenderer(Renderer{}) } -// Renderer implements markup.Renderer type Renderer struct{} var _ markup.PostProcessRenderer = (*Renderer)(nil) -// Name implements markup.Renderer func (Renderer) Name() string { return MarkupName } -// NeedPostProcess implements markup.PostProcessRenderer func (Renderer) NeedPostProcess() bool { return true } -// Extensions implements markup.Renderer -func (Renderer) Extensions() []string { - return setting.Markdown.FileExtensions +func (Renderer) FileNamePatterns() []string { + return setting.Markdown.FileNamePatterns } -// SanitizerRules implements markup.Renderer func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { return []setting.MarkupSanitizerRule{} } -// Render implements markup.Renderer func (Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { return render(ctx, input, output) } diff --git a/modules/markup/markdown/markdown_math_test.go b/modules/markup/markdown/markdown_math_test.go index a75f18d36a..9e368cb689 100644 --- a/modules/markup/markdown/markdown_math_test.go +++ b/modules/markup/markdown/markdown_math_test.go @@ -30,6 +30,10 @@ func TestMathRender(t *testing.T) { "$ a $", `

    a

    ` + nl, }, + { + "$a$$b$", + `

    ab

    ` + nl, + }, { "$a$ $b$", `

    a b

    ` + nl, @@ -59,7 +63,7 @@ func TestMathRender(t *testing.T) { `

    a$b $a a$b b$

    ` + nl, }, { - "a$x$", + "a$x$", // Pattern: "word$other$" The real world example is: "Price is between US$1 and US$2.", so don't parse this. `

    a$x$

    ` + nl, }, { @@ -70,6 +74,10 @@ func TestMathRender(t *testing.T) { "$a$ ($b$) [$c$] {$d$}", `

    a (b) [$c$] {$d$}

    ` + nl, }, + { + "[$a$](link)", + `

    a

    ` + nl, + }, { "$$a$$", `

    a

    ` + nl, diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go index 4eb01bcc2d..47b293e1e9 100644 --- a/modules/markup/markdown/markdown_test.go +++ b/modules/markup/markdown/markdown_test.go @@ -88,6 +88,7 @@ func TestRender_Images(t *testing.T) { } func TestTotal_RenderString(t *testing.T) { + setting.AppURL = AppURL defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() // Test cases without ambiguous links (It is not right to copy a whole file here, instead it should clearly test what is being tested) @@ -258,7 +259,7 @@ This PR has been generated by [Renovate Bot](https://github.com/renovatebot/reno }, }) for i := range sameCases { - line, err := markdown.RenderString(markup.NewTestRenderContext(localMetas), sameCases[i]) + line, err := markdown.RenderString(markup.NewTestRenderContext(localMetas).WithEnableHeadingIDGeneration(true), sameCases[i]) assert.NoError(t, err) assert.Equal(t, testAnswers[i], string(line)) } @@ -545,5 +546,11 @@ func TestMarkdownLink(t *testing.T) { assert.Equal(t, `

    link1 link2 link3

    +`, string(result)) + + input = "https://example.com/__init__.py" + result, err = markdown.RenderString(markup.NewTestRenderContext("/base", localMetas), input) + assert.NoError(t, err) + assert.Equal(t, `

    https://example.com/__init__.py

    `, string(result)) } diff --git a/modules/markup/markdown/math/inline_parser.go b/modules/markup/markdown/math/inline_parser.go index a711d1e1cd..564861df90 100644 --- a/modules/markup/markdown/math/inline_parser.go +++ b/modules/markup/markdown/math/inline_parser.go @@ -54,6 +54,10 @@ func isAlphanumeric(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') } +func isInMarkdownLinkText(block text.Reader, lineAfter []byte) bool { + return block.PrecendingCharacter() == '[' && bytes.HasPrefix(lineAfter, []byte("](")) +} + // Parse parses the current line and returns a result of parsing. func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { line, _ := block.PeekLine() @@ -115,7 +119,9 @@ func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser. } // check valid ending character isValidEndingChar := isPunctuation(succeedingCharacter) || isParenthesesClose(succeedingCharacter) || - succeedingCharacter == ' ' || succeedingCharacter == '\n' || succeedingCharacter == 0 + succeedingCharacter == ' ' || succeedingCharacter == '\n' || succeedingCharacter == 0 || + succeedingCharacter == '$' || + isInMarkdownLinkText(block, line[i+len(stopMark):]) if checkSurrounding && !isValidEndingChar { break } diff --git a/modules/markup/markdown/prefixed_id.go b/modules/markup/markdown/prefixed_id.go deleted file mode 100644 index 63d7fadc0a..0000000000 --- a/modules/markup/markdown/prefixed_id.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package markdown - -import ( - "bytes" - "fmt" - - "code.gitea.io/gitea/modules/container" - "code.gitea.io/gitea/modules/markup/common" - "code.gitea.io/gitea/modules/util" - - "github.com/yuin/goldmark/ast" -) - -type prefixedIDs struct { - values container.Set[string] -} - -// Generate generates a new element id. -func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte { - dft := []byte("id") - if kind == ast.KindHeading { - dft = []byte("heading") - } - return p.GenerateWithDefault(value, dft) -} - -// GenerateWithDefault generates a new element id. -func (p *prefixedIDs) GenerateWithDefault(value, dft []byte) []byte { - result := common.CleanValue(value) - if len(result) == 0 { - result = dft - } - if !bytes.HasPrefix(result, []byte("user-content-")) { - result = append([]byte("user-content-"), result...) - } - if p.values.Add(util.UnsafeBytesToString(result)) { - return result - } - for i := 1; ; i++ { - newResult := fmt.Sprintf("%s-%d", result, i) - if p.values.Add(newResult) { - return []byte(newResult) - } - } -} - -// Put puts a given element id to the used ids table. -func (p *prefixedIDs) Put(value []byte) { - p.values.Add(util.UnsafeBytesToString(value)) -} - -func newPrefixedIDs() *prefixedIDs { - return &prefixedIDs{ - values: make(container.Set[string]), - } -} diff --git a/modules/markup/markdown/toc.go b/modules/markup/markdown/toc.go deleted file mode 100644 index a11b9d0390..0000000000 --- a/modules/markup/markdown/toc.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package markdown - -import ( - "net/url" - - "code.gitea.io/gitea/modules/translation" - - "github.com/yuin/goldmark/ast" -) - -// Header holds the data about a header. -type Header struct { - Level int - Text string - ID string -} - -func createTOCNode(toc []Header, lang string, detailsAttrs map[string]string) ast.Node { - details := NewDetails() - summary := NewSummary() - - for k, v := range detailsAttrs { - details.SetAttributeString(k, []byte(v)) - } - - summary.AppendChild(summary, ast.NewString([]byte(translation.NewLocale(lang).TrString("toc")))) - details.AppendChild(details, summary) - ul := ast.NewList('-') - details.AppendChild(details, ul) - currentLevel := 6 - for _, header := range toc { - if header.Level < currentLevel { - currentLevel = header.Level - } - } - for _, header := range toc { - for currentLevel > header.Level { - ul = ul.Parent().(*ast.List) - currentLevel-- - } - for currentLevel < header.Level { - newL := ast.NewList('-') - ul.AppendChild(ul, newL) - currentLevel++ - ul = newL - } - li := ast.NewListItem(currentLevel * 2) - a := ast.NewLink() - a.Destination = []byte("#" + url.QueryEscape(header.ID)) - a.AppendChild(a, ast.NewString([]byte(header.Text))) - li.AppendChild(li, a) - ul.AppendChild(ul, li) - } - - return details -} diff --git a/modules/markup/markdown/transform_heading.go b/modules/markup/markdown/transform_heading.go deleted file mode 100644 index a229a7b1a4..0000000000 --- a/modules/markup/markdown/transform_heading.go +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package markdown - -import ( - "fmt" - - "code.gitea.io/gitea/modules/markup" - "code.gitea.io/gitea/modules/util" - - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/text" -) - -func (g *ASTTransformer) transformHeading(_ *markup.RenderContext, v *ast.Heading, reader text.Reader, tocList *[]Header) { - for _, attr := range v.Attributes() { - if _, ok := attr.Value.([]byte); !ok { - v.SetAttribute(attr.Name, fmt.Appendf(nil, "%v", attr.Value)) - } - } - txt := v.Text(reader.Source()) //nolint:staticcheck // Text is deprecated - header := Header{ - Text: util.UnsafeBytesToString(txt), - Level: v.Level, - } - if id, found := v.AttributeString("id"); found { - header.ID = util.UnsafeBytesToString(id.([]byte)) - } - *tocList = append(*tocList, header) - g.applyElementDir(v) -} diff --git a/modules/markup/mdstripper/mdstripper.go b/modules/markup/mdstripper/mdstripper.go index 5a6504416a..bf69051e87 100644 --- a/modules/markup/mdstripper/mdstripper.go +++ b/modules/markup/mdstripper/mdstripper.go @@ -46,7 +46,7 @@ func (r *stripRenderer) Render(w io.Writer, source []byte, doc ast.Node) error { coalesce := prevSibIsText r.processString( w, - v.Text(source), //nolint:staticcheck // Text is deprecated + v.Value(source), coalesce) if v.SoftLineBreak() { r.doubleSpace(w) @@ -165,7 +165,6 @@ func StripMarkdownBytes(rawBytes []byte) ([]byte, []string) { ), goldmark.WithParserOptions( parser.WithAttribute(), - parser.WithAutoHeadingID(), ), goldmark.WithRendererOptions( html.WithUnsafe(), diff --git a/modules/markup/orgmode/orgmode.go b/modules/markup/orgmode/orgmode.go index 93c335d244..fd3071645a 100644 --- a/modules/markup/orgmode/orgmode.go +++ b/modules/markup/orgmode/orgmode.go @@ -5,7 +5,6 @@ package orgmode import ( "fmt" - "html" "html/template" "io" "strings" @@ -17,7 +16,6 @@ import ( "code.gitea.io/gitea/modules/setting" "github.com/alecthomas/chroma/v2" - "github.com/alecthomas/chroma/v2/lexers" "github.com/niklasfasching/go-org/org" ) @@ -33,20 +31,16 @@ var ( _ markup.PostProcessRenderer = (*renderer)(nil) ) -// Name implements markup.Renderer func (renderer) Name() string { return "orgmode" } -// NeedPostProcess implements markup.PostProcessRenderer func (renderer) NeedPostProcess() bool { return true } -// Extensions implements markup.Renderer -func (renderer) Extensions() []string { - return []string{".org"} +func (renderer) FileNamePatterns() []string { + return []string{"*.org"} } -// SanitizerRules implements markup.Renderer func (renderer) SanitizerRules() []setting.MarkupSanitizerRule { return []setting.MarkupSanitizerRule{} } @@ -57,40 +51,20 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error htmlWriter.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string { defer func() { if err := recover(); err != nil { + // catch the panic, log the error and return empty result log.Error("Panic in HighlightCodeBlock: %v\n%s", err, log.Stack(2)) - panic(err) } }() - w := &strings.Builder{} - lexer := lexers.Get(lang) - if lexer == nil && lang == "" { - lexer = lexers.Analyse(source) - if lexer == nil { - lexer = lexers.Fallback - } - lang = strings.ToLower(lexer.Config().Name) - } + lexer := highlight.DetectChromaLexerByFileName("", lang) // don't use content to detect, it is too slow + lexer = chroma.Coalesce(lexer) + sb := &strings.Builder{} // include language-x class as part of commonmark spec - if err := ctx.RenderInternal.FormatWithSafeAttrs(w, `
    `, lang); err != nil {
    -			return ""
    -		}
    -		if lexer == nil {
    -			if _, err := w.WriteString(html.EscapeString(source)); err != nil {
    -				return ""
    -			}
    -		} else {
    -			lexer = chroma.Coalesce(lexer)
    -			if _, err := w.WriteString(string(highlight.CodeFromLexer(lexer, source))); err != nil {
    -				return ""
    -			}
    -		}
    -		if _, err := w.WriteString("
    "); err != nil { - return "" - } - - return w.String() + _ = ctx.RenderInternal.FormatWithSafeAttrs(sb, `
    `, strings.ToLower(lexer.Config().Name))
    +		_, _ = sb.WriteString(string(highlight.RenderCodeByLexer(lexer, source)))
    +		_, _ = sb.WriteString("
    ") + return sb.String() } w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter} diff --git a/modules/markup/render.go b/modules/markup/render.go index 79f1f473c2..5785dc5ad5 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -4,19 +4,22 @@ package markup import ( + "bytes" "context" "fmt" + "html/template" "io" "net/url" "strconv" "strings" "time" + "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/markup/internal" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" - "github.com/yuin/goldmark/ast" "golang.org/x/sync/errgroup" ) @@ -52,6 +55,23 @@ type RenderOptions struct { // used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page InStandalonePage bool + + // EnableHeadingIDGeneration controls whether to auto-generate IDs for HTML headings without id attribute. + // This should be enabled for repository files and wiki pages, but disabled for comments to avoid duplicate IDs. + EnableHeadingIDGeneration bool +} + +type TocShowInSectionType string + +const ( + TocShowInSidebar TocShowInSectionType = "sidebar" + TocShowInMain TocShowInSectionType = "main" +) + +type TocHeadingItem struct { + HeadingLevel int + AnchorID string + InnerText string } // RenderContext represents a render context @@ -61,7 +81,8 @@ type RenderContext struct { // the context might be used by the "render" function, but it might also be used by "postProcess" function usedByRender bool - SidebarTocNode ast.Node + TocShowInSection TocShowInSectionType + TocHeadingItems []*TocHeadingItem RenderHelper RenderHelper RenderOptions RenderOptions @@ -110,6 +131,11 @@ func (ctx *RenderContext) WithInStandalonePage(v bool) *RenderContext { return ctx } +func (ctx *RenderContext) WithEnableHeadingIDGeneration(v bool) *RenderContext { + ctx.RenderOptions.EnableHeadingIDGeneration = v + return ctx +} + func (ctx *RenderContext) WithUseAbsoluteLink(v bool) *RenderContext { ctx.RenderOptions.UseAbsoluteLink = v return ctx @@ -120,31 +146,45 @@ func (ctx *RenderContext) WithHelper(helper RenderHelper) *RenderContext { return ctx } -// Render renders markup file to HTML with all specific handling stuff. -func Render(ctx *RenderContext, input io.Reader, output io.Writer) error { +func (ctx *RenderContext) DetectMarkupRenderer(prefetchBuf []byte) Renderer { if ctx.RenderOptions.MarkupType == "" && ctx.RenderOptions.RelativePath != "" { - ctx.RenderOptions.MarkupType = DetectMarkupTypeByFileName(ctx.RenderOptions.RelativePath) - if ctx.RenderOptions.MarkupType == "" { - return util.NewInvalidArgumentErrorf("unsupported file to render: %q", ctx.RenderOptions.RelativePath) + var sniffedType typesniffer.SniffedType + if len(prefetchBuf) > 0 { + sniffedType = typesniffer.DetectContentType(prefetchBuf) } + ctx.RenderOptions.MarkupType = DetectRendererTypeByPrefetch(ctx.RenderOptions.RelativePath, sniffedType, prefetchBuf) } + return renderers[ctx.RenderOptions.MarkupType] +} - renderer := renderers[ctx.RenderOptions.MarkupType] +func (ctx *RenderContext) DetectMarkupRendererByReader(in io.Reader) (Renderer, io.Reader, error) { + prefetchBuf := make([]byte, 512) + n, err := util.ReadAtMost(in, prefetchBuf) + if err != nil && err != io.EOF { + return nil, nil, err + } + prefetchBuf = prefetchBuf[:n] + renderer := ctx.DetectMarkupRenderer(prefetchBuf) if renderer == nil { - return util.NewInvalidArgumentErrorf("unsupported markup type: %q", ctx.RenderOptions.MarkupType) + return nil, nil, util.NewInvalidArgumentErrorf("unable to find a render") } + return renderer, io.MultiReader(bytes.NewReader(prefetchBuf), in), nil +} - if ctx.RenderOptions.RelativePath != "" { - if externalRender, ok := renderer.(ExternalRenderer); ok && externalRender.DisplayInIFrame() { - if !ctx.RenderOptions.InStandalonePage { - // for an external "DisplayInIFrame" render, it could only output its content in a standalone page - // otherwise, a `, - setting.AppSubURL, +func renderIFrame(ctx *RenderContext, sandbox string, output io.Writer) error { + src := fmt.Sprintf("%s/%s/%s/render/%s/%s", setting.AppSubURL, url.PathEscape(ctx.RenderOptions.Metas["user"]), url.PathEscape(ctx.RenderOptions.Metas["repo"]), - ctx.RenderOptions.Metas["RefTypeNameSubURL"], - url.PathEscape(ctx.RenderOptions.RelativePath), - )) + util.PathEscapeSegments(ctx.RenderOptions.Metas["RefTypeNameSubURL"]), + util.PathEscapeSegments(ctx.RenderOptions.RelativePath), + ) + + var sandboxAttrValue template.HTML + if sandbox != "" { + sandboxAttrValue = htmlutil.HTMLFormat(`sandbox="%s"`, sandbox) + } + iframe := htmlutil.HTMLFormat(``, src, sandboxAttrValue) + _, err := io.WriteString(output, string(iframe)) return err } @@ -185,13 +221,34 @@ func pipes() (io.ReadCloser, io.WriteCloser, func()) { } } -func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error { +func getExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, _ bool) { + if externalRender, ok := renderer.(ExternalRenderer); ok { + return externalRender.GetExternalRendererOptions(), true + } + return ret, false +} + +func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error { + var extraHeadHTML template.HTML + if extOpts, ok := getExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe { + if !ctx.RenderOptions.InStandalonePage { + // for an external "DisplayInIFrame" render, it could only output its content in a standalone page + // otherwise, a