From 87b855bd15336c1d7029a18ee5ce87d8841b6abe Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 12 Dec 2025 16:44:53 +0100 Subject: [PATCH 01/28] Bump `actions/checkout` to v6 (#36136) https://github.com/actions/checkout#checkout-v6 Result of `perl -p -i -e 's#actions\/checkout\@v5#actions/checkout\@v6#g' .github/workflows/*` --- .github/workflows/cron-licenses.yml | 2 +- .github/workflows/cron-translations.yml | 2 +- .github/workflows/files-changed.yml | 2 +- .github/workflows/pull-compliance.yml | 24 +++++++++++------------ .github/workflows/pull-db-tests.yml | 10 +++++----- .github/workflows/pull-docker-dryrun.yml | 2 +- .github/workflows/release-nightly.yml | 4 ++-- .github/workflows/release-tag-rc.yml | 4 ++-- .github/workflows/release-tag-version.yml | 4 ++-- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/cron-licenses.yml b/.github/workflows/cron-licenses.yml index 12f52289b61..5b34d5c8ec1 100644 --- a/.github/workflows/cron-licenses.yml +++ b/.github/workflows/cron-licenses.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest if: github.repository == 'go-gitea/gitea' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: go.mod diff --git a/.github/workflows/cron-translations.yml b/.github/workflows/cron-translations.yml index ae2238ad2d8..334a2218933 100644 --- a/.github/workflows/cron-translations.yml +++ b/.github/workflows/cron-translations.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest if: github.repository == 'go-gitea/gitea' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: crowdin/github-action@v1 with: upload_sources: true diff --git a/.github/workflows/files-changed.yml b/.github/workflows/files-changed.yml index b21341a2770..e0c28703190 100644 --- a/.github/workflows/files-changed.yml +++ b/.github/workflows/files-changed.yml @@ -34,7 +34,7 @@ jobs: swagger: ${{ steps.changes.outputs.swagger }} yaml: ${{ steps.changes.outputs.yaml }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: dorny/paths-filter@v3 id: changes with: diff --git a/.github/workflows/pull-compliance.yml b/.github/workflows/pull-compliance.yml index f73772e9342..065bdb26dbd 100644 --- a/.github/workflows/pull-compliance.yml +++ b/.github/workflows/pull-compliance.yml @@ -16,7 +16,7 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -31,7 +31,7 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v6 - run: uv python install 3.12 - uses: pnpm/action-setup@v4 @@ -47,7 +47,7 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v6 - run: uv python install 3.12 - run: make deps-py @@ -58,7 +58,7 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v5 with: @@ -71,7 +71,7 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -83,7 +83,7 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -100,7 +100,7 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -115,7 +115,7 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -128,7 +128,7 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v5 with: @@ -144,7 +144,7 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -176,7 +176,7 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v5 with: @@ -189,7 +189,7 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: go.mod diff --git a/.github/workflows/pull-db-tests.yml b/.github/workflows/pull-db-tests.yml index 21ec76b48ea..1d5a652d6f0 100644 --- a/.github/workflows/pull-db-tests.yml +++ b/.github/workflows/pull-db-tests.yml @@ -38,7 +38,7 @@ jobs: ports: - "9000:9000" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -66,7 +66,7 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -124,7 +124,7 @@ jobs: ports: - 10000:10000 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -177,7 +177,7 @@ jobs: - "587:587" - "993:993" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -217,7 +217,7 @@ jobs: ports: - 10000:10000 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: go.mod diff --git a/.github/workflows/pull-docker-dryrun.yml b/.github/workflows/pull-docker-dryrun.yml index 9c9dd2ffe6b..2b4b2b49be4 100644 --- a/.github/workflows/pull-docker-dryrun.yml +++ b/.github/workflows/pull-docker-dryrun.yml @@ -16,7 +16,7 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: docker/setup-buildx-action@v3 - name: Build regular container image uses: docker/build-push-action@v5 diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index ada4c18d337..3e0dab9edfd 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -12,7 +12,7 @@ jobs: nightly-binary: runs-on: namespace-profile-gitea-release-binary steps: - - uses: actions/checkout@v5 + - 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 @@ -61,7 +61,7 @@ jobs: permissions: packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v5 + - 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 diff --git a/.github/workflows/release-tag-rc.yml b/.github/workflows/release-tag-rc.yml index 35558933e0b..eb430632913 100644 --- a/.github/workflows/release-tag-rc.yml +++ b/.github/workflows/release-tag-rc.yml @@ -13,7 +13,7 @@ jobs: binary: runs-on: namespace-profile-gitea-release-binary steps: - - uses: actions/checkout@v5 + - 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 @@ -71,7 +71,7 @@ jobs: permissions: packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v5 + - 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 diff --git a/.github/workflows/release-tag-version.yml b/.github/workflows/release-tag-version.yml index 56426d3bc33..4ade365d9c5 100644 --- a/.github/workflows/release-tag-version.yml +++ b/.github/workflows/release-tag-version.yml @@ -17,7 +17,7 @@ jobs: permissions: packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v5 + - 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 @@ -75,7 +75,7 @@ jobs: permissions: packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v5 + - 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 From 4c06c98dda6638f9f386ee5f13d0513cabe0470f Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 12 Dec 2025 17:48:29 +0100 Subject: [PATCH 02/28] Add explicit permissions to all actions workflows (#36140) Explicitely specify all workflow [`permissions`](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions). This will fix [26 CodeQL alerts](https://github.com/go-gitea/gitea/security/code-scanning?query=permissions+is%3Aopen+branch%3Amain+). --- .github/workflows/cron-licenses.yml | 2 ++ .github/workflows/cron-translations.yml | 2 ++ .github/workflows/files-changed.yml | 2 ++ .github/workflows/pull-compliance.yml | 24 +++++++++++++++++++++++ .github/workflows/pull-db-tests.yml | 10 ++++++++++ .github/workflows/pull-docker-dryrun.yml | 2 ++ .github/workflows/release-nightly.yml | 4 ++++ .github/workflows/release-tag-rc.yml | 4 ++++ .github/workflows/release-tag-version.yml | 3 +++ 9 files changed, 53 insertions(+) diff --git a/.github/workflows/cron-licenses.yml b/.github/workflows/cron-licenses.yml index 5b34d5c8ec1..a8be1ffa59b 100644 --- a/.github/workflows/cron-licenses.yml +++ b/.github/workflows/cron-licenses.yml @@ -9,6 +9,8 @@ jobs: cron-licenses: runs-on: ubuntu-latest if: github.repository == 'go-gitea/gitea' + permissions: + contents: write steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 diff --git a/.github/workflows/cron-translations.yml b/.github/workflows/cron-translations.yml index 334a2218933..3a012e9876c 100644 --- a/.github/workflows/cron-translations.yml +++ b/.github/workflows/cron-translations.yml @@ -9,6 +9,8 @@ jobs: crowdin-pull: runs-on: ubuntu-latest if: github.repository == 'go-gitea/gitea' + permissions: + contents: write steps: - uses: actions/checkout@v6 - uses: crowdin/github-action@v1 diff --git a/.github/workflows/files-changed.yml b/.github/workflows/files-changed.yml index e0c28703190..d18ee6e9980 100644 --- a/.github/workflows/files-changed.yml +++ b/.github/workflows/files-changed.yml @@ -24,6 +24,8 @@ jobs: detect: runs-on: ubuntu-latest timeout-minutes: 3 + permissions: + contents: read outputs: backend: ${{ steps.changes.outputs.backend }} frontend: ${{ steps.changes.outputs.frontend }} diff --git a/.github/workflows/pull-compliance.yml b/.github/workflows/pull-compliance.yml index 065bdb26dbd..9e1963d48a6 100644 --- a/.github/workflows/pull-compliance.yml +++ b/.github/workflows/pull-compliance.yml @@ -15,6 +15,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 steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 @@ -30,6 +32,8 @@ jobs: if: needs.files-changed.outputs.templates == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v6 @@ -46,6 +50,8 @@ jobs: if: needs.files-changed.outputs.yaml == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v6 @@ -57,6 +63,8 @@ jobs: if: needs.files-changed.outputs.swagger == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 @@ -70,6 +78,8 @@ 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@v6 - uses: actions/setup-go@v6 @@ -82,6 +92,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 steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 @@ -99,6 +111,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 steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 @@ -114,6 +128,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 steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 @@ -127,6 +143,8 @@ 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@v6 - uses: pnpm/action-setup@v4 @@ -143,6 +161,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 steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 @@ -175,6 +195,8 @@ 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@v6 - uses: pnpm/action-setup@v4 @@ -188,6 +210,8 @@ 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@v6 - uses: actions/setup-go@v6 diff --git a/.github/workflows/pull-db-tests.yml b/.github/workflows/pull-db-tests.yml index 1d5a652d6f0..16c9e004a51 100644 --- a/.github/workflows/pull-db-tests.yml +++ b/.github/workflows/pull-db-tests.yml @@ -15,6 +15,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: pgsql: image: postgres:14 @@ -65,6 +67,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 steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 @@ -90,6 +94,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 @@ -152,6 +158,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 @@ -203,6 +211,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 diff --git a/.github/workflows/pull-docker-dryrun.yml b/.github/workflows/pull-docker-dryrun.yml index 2b4b2b49be4..e1b86e5e38e 100644 --- a/.github/workflows/pull-docker-dryrun.yml +++ b/.github/workflows/pull-docker-dryrun.yml @@ -15,6 +15,8 @@ jobs: if: needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 - uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index 3e0dab9edfd..c8ce0aa7877 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -11,6 +11,8 @@ concurrency: jobs: nightly-binary: runs-on: namespace-profile-gitea-release-binary + permissions: + contents: read steps: - uses: actions/checkout@v6 # fetch all commits instead of only the last as some branches are long lived and could have many between versions @@ -56,9 +58,11 @@ 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-container: runs-on: namespace-profile-gitea-release-docker permissions: + contents: read packages: write # to publish to ghcr.io steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/release-tag-rc.yml b/.github/workflows/release-tag-rc.yml index eb430632913..ef36e55a944 100644 --- a/.github/workflows/release-tag-rc.yml +++ b/.github/workflows/release-tag-rc.yml @@ -12,6 +12,8 @@ concurrency: jobs: binary: runs-on: namespace-profile-gitea-release-binary + permissions: + contents: read steps: - uses: actions/checkout@v6 # fetch all commits instead of only the last as some branches are long lived and could have many between versions @@ -66,9 +68,11 @@ jobs: gh release create ${{ github.ref_name }} --title ${{ github.ref_name }} --draft --notes-from-tag dist/release/* env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + container: runs-on: namespace-profile-gitea-release-docker permissions: + contents: read packages: write # to publish to ghcr.io steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/release-tag-version.yml b/.github/workflows/release-tag-version.yml index 4ade365d9c5..a3838de3c0b 100644 --- a/.github/workflows/release-tag-version.yml +++ b/.github/workflows/release-tag-version.yml @@ -15,6 +15,7 @@ jobs: binary: runs-on: namespace-profile-gitea-release-binary permissions: + contents: read packages: write # to publish to ghcr.io steps: - uses: actions/checkout@v6 @@ -70,9 +71,11 @@ jobs: gh release create ${{ github.ref_name }} --title ${{ github.ref_name }} --notes-from-tag dist/release/* env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + container: runs-on: namespace-profile-gitea-release-docker permissions: + contents: read packages: write # to publish to ghcr.io steps: - uses: actions/checkout@v6 From 3e57ba5b36a110065804a3f70f63b10587b17ea3 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 12 Dec 2025 18:38:59 +0100 Subject: [PATCH 03/28] Add permissions to`files-changed` jobs (#36142) Followup to https://github.com/go-gitea/gitea/pull/36140. `files-changed` is a job that imports another workflow via `uses` statement but CodeQL still complains about lack of permissions on these jobs, so add it. This will fix the remaining [3 CodeQL issues](https://github.com/go-gitea/gitea/security/code-scanning?query=is%3Aopen+branch%3Amain+permissions). --- .github/workflows/pull-compliance.yml | 2 ++ .github/workflows/pull-db-tests.yml | 2 ++ .github/workflows/pull-docker-dryrun.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.github/workflows/pull-compliance.yml b/.github/workflows/pull-compliance.yml index 9e1963d48a6..c146b439e0b 100644 --- a/.github/workflows/pull-compliance.yml +++ b/.github/workflows/pull-compliance.yml @@ -10,6 +10,8 @@ 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' diff --git a/.github/workflows/pull-db-tests.yml b/.github/workflows/pull-db-tests.yml index 16c9e004a51..66f48d5af86 100644 --- a/.github/workflows/pull-db-tests.yml +++ b/.github/workflows/pull-db-tests.yml @@ -10,6 +10,8 @@ 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' diff --git a/.github/workflows/pull-docker-dryrun.yml b/.github/workflows/pull-docker-dryrun.yml index e1b86e5e38e..1cd1ba31ddd 100644 --- a/.github/workflows/pull-docker-dryrun.yml +++ b/.github/workflows/pull-docker-dryrun.yml @@ -10,6 +10,8 @@ concurrency: jobs: files-changed: uses: ./.github/workflows/files-changed.yml + permissions: + contents: read container: if: needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.actions == 'true' From 3102c04c1eb9251d933797465e4187d60b17e8a0 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 12 Dec 2025 19:12:35 +0100 Subject: [PATCH 04/28] Fix issue close timeline icon (#36138) Previously there was a icon mismatch between a issue's label and the timeline close event icon --- templates/repo/issue/view_content/comments.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index 089cdf2ccdd..6d23186d08f 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -96,7 +96,7 @@ {{else if eq .Type 2}}
- {{svg "octicon-circle-slash"}} + {{svg "octicon-issue-closed"}} {{if not .OriginalAuthor}} {{template "shared/user/avatarlink" dict "user" .Poster}} {{end}} From 1e72b1563906ef5625f7f0dcb67ed4bad5e2429c Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 13 Dec 2025 02:56:05 +0800 Subject: [PATCH 05/28] Fix various bugs (#36139) * Fix #35768 * Fix #36064 * Fix #36051 * Fix cherry-pick panic --- modules/packages/npm/creator.go | 25 +++++++++++++++++++-- modules/packages/npm/creator_test.go | 28 ++++++++++++++++++++++- modules/packages/npm/metadata.go | 2 +- routers/web/repo/editor_cherry_pick.go | 4 +--- services/mailer/sender/sender.go | 31 ++++++++++---------------- services/repository/generate.go | 4 ++-- services/repository/generate_test.go | 21 ++++++++++------- templates/package/content/pypi.tmpl | 2 +- 8 files changed, 80 insertions(+), 37 deletions(-) diff --git a/modules/packages/npm/creator.go b/modules/packages/npm/creator.go index 11b5123c278..cc7695726bb 100644 --- a/modules/packages/npm/creator.go +++ b/modules/packages/npm/creator.go @@ -62,7 +62,28 @@ type PackageMetadata struct { Author User `json:"author"` ReadmeFilename string `json:"readmeFilename,omitempty"` Users map[string]bool `json:"users,omitempty"` - License string `json:"license,omitempty"` + License License `json:"license,omitempty"` +} + +type License string + +func (l *License) UnmarshalJSON(data []byte) error { + switch data[0] { + case '"': + var value string + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *l = License(value) + case '{': + var values map[string]any + if err := json.Unmarshal(data, &values); err != nil { + return err + } + value, _ := values["type"].(string) + *l = License(value) + } + return nil } // PackageMetadataVersion documentation: https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version @@ -74,7 +95,7 @@ type PackageMetadataVersion struct { Description string `json:"description"` Author User `json:"author"` Homepage string `json:"homepage,omitempty"` - License string `json:"license,omitempty"` + License License `json:"license,omitempty"` Repository Repository `json:"repository"` Keywords []string `json:"keywords,omitempty"` Dependencies map[string]string `json:"dependencies,omitempty"` diff --git a/modules/packages/npm/creator_test.go b/modules/packages/npm/creator_test.go index 806377a52bc..40c50de91f9 100644 --- a/modules/packages/npm/creator_test.go +++ b/modules/packages/npm/creator_test.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/modules/json" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParsePackage(t *testing.T) { @@ -291,11 +292,36 @@ func TestParsePackage(t *testing.T) { assert.Equal(t, packageDescription, p.Metadata.Readme) assert.Equal(t, packageAuthor, p.Metadata.Author) assert.Equal(t, packageBin, p.Metadata.Bin["bin"]) - assert.Equal(t, "MIT", p.Metadata.License) + assert.Equal(t, "MIT", string(p.Metadata.License)) assert.Equal(t, "https://gitea.io/", p.Metadata.ProjectURL) assert.Contains(t, p.Metadata.Dependencies, "package") assert.Equal(t, "1.2.0", p.Metadata.Dependencies["package"]) assert.Equal(t, repository.Type, p.Metadata.Repository.Type) assert.Equal(t, repository.URL, p.Metadata.Repository.URL) }) + + t.Run("ValidLicenseMap", func(t *testing.T) { + packageJSON := `{ + "versions": { + "0.1.1": { + "name": "dev-null", + "version": "0.1.1", + "license": { + "type": "MIT" + }, + "dist": { + "integrity": "sha256-" + } + } + }, + "_attachments": { + "foo": { + "data": "AAAA" + } + } +}` + p, err := ParsePackage(strings.NewReader(packageJSON)) + require.NoError(t, err) + require.Equal(t, "MIT", string(p.Metadata.License)) + }) } diff --git a/modules/packages/npm/metadata.go b/modules/packages/npm/metadata.go index 362d0470d55..e6bbcb11777 100644 --- a/modules/packages/npm/metadata.go +++ b/modules/packages/npm/metadata.go @@ -12,7 +12,7 @@ type Metadata struct { Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` Author string `json:"author,omitempty"` - License string `json:"license,omitempty"` + License License `json:"license,omitempty"` ProjectURL string `json:"project_url,omitempty"` Keywords []string `json:"keywords,omitempty"` Dependencies map[string]string `json:"dependencies,omitempty"` diff --git a/routers/web/repo/editor_cherry_pick.go b/routers/web/repo/editor_cherry_pick.go index c1f3ae861b4..ca0e19517af 100644 --- a/routers/web/repo/editor_cherry_pick.go +++ b/routers/web/repo/editor_cherry_pick.go @@ -36,9 +36,7 @@ func CherryPick(ctx *context.Context) { ctx.Data["commit_message"] = "revert " + cherryPickCommit.Message() } else { ctx.Data["CherryPickType"] = "cherry-pick" - splits := strings.SplitN(cherryPickCommit.Message(), "\n", 2) - ctx.Data["commit_summary"] = splits[0] - ctx.Data["commit_message"] = splits[1] + ctx.Data["commit_summary"], ctx.Data["commit_message"], _ = strings.Cut(cherryPickCommit.Message(), "\n") } ctx.HTML(http.StatusOK, tplCherryPick) diff --git a/services/mailer/sender/sender.go b/services/mailer/sender/sender.go index e470c2f2b3d..30c6feaf7a5 100644 --- a/services/mailer/sender/sender.go +++ b/services/mailer/sender/sender.go @@ -4,10 +4,8 @@ package sender import ( + "errors" "io" - - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/setting" ) type Sender interface { @@ -16,23 +14,18 @@ type Sender interface { var Send = send -func send(sender Sender, msgs ...*Message) error { - if setting.MailService == nil { - log.Error("Mailer: Send is being invoked but mail service hasn't been initialized") - return nil +func send(sender Sender, msg *Message) error { + m := msg.ToMessage() + froms := m.GetFrom() + to, err := m.GetRecipients() + if err != nil { + return err } - for _, msg := range msgs { - m := msg.ToMessage() - froms := m.GetFrom() - to, err := m.GetRecipients() - if err != nil { - return err - } - // TODO: implement sending from multiple addresses - if err := sender.Send(froms[0].Address, to, m); err != nil { - return err - } + // TODO: implement sending from multiple addresses + if len(froms) == 0 { + // FIXME: no idea why sometimes the "froms" can be empty, need to figure out the root problem + return errors.New("no FROM specified") } - return nil + return sender.Send(froms[0].Address, to, m) } diff --git a/services/repository/generate.go b/services/repository/generate.go index 3ec31dac225..b2913cd110f 100644 --- a/services/repository/generate.go +++ b/services/repository/generate.go @@ -177,7 +177,7 @@ func substGiteaTemplateFile(ctx context.Context, tmpDir, tmpDirSubPath string, t } generatedContent := generateExpansion(ctx, string(content), templateRepo, generateRepo) - substSubPath := filepath.Clean(filePathSanitize(generateExpansion(ctx, tmpDirSubPath, templateRepo, generateRepo))) + substSubPath := filePathSanitize(generateExpansion(ctx, tmpDirSubPath, templateRepo, generateRepo)) newLocalPath := filepath.Join(tmpDir, substSubPath) regular, err := util.IsRegularFile(newLocalPath) if canWrite := regular || errors.Is(err, fs.ErrNotExist); !canWrite { @@ -358,5 +358,5 @@ func filePathSanitize(s string) string { } fields[i] = field } - return filepath.FromSlash(strings.Join(fields, "/")) + return filepath.Clean(filepath.FromSlash(strings.Trim(strings.Join(fields, "/"), "/"))) } diff --git a/services/repository/generate_test.go b/services/repository/generate_test.go index 9c01911ded0..432de4dc590 100644 --- a/services/repository/generate_test.go +++ b/services/repository/generate_test.go @@ -54,19 +54,24 @@ text/*.txt } func TestFilePathSanitize(t *testing.T) { - assert.Equal(t, "test_CON", filePathSanitize("test_CON")) - assert.Equal(t, "test CON", filePathSanitize("test CON ")) - assert.Equal(t, "__/traverse/__", filePathSanitize(".. /traverse/ ..")) - assert.Equal(t, "./__/a/_git/b_", filePathSanitize("./../a/.git/ b: ")) + // path clean + assert.Equal(t, "a", filePathSanitize("//a/")) + assert.Equal(t, "_a", filePathSanitize(`\a`)) + assert.Equal(t, "__/a/__", filePathSanitize(".. /a/ ..")) + assert.Equal(t, "__/a/_git/b_", filePathSanitize("./../a/.git/ b: ")) + + // Windows reserved names assert.Equal(t, "_", filePathSanitize("CoN")) assert.Equal(t, "_", filePathSanitize("LpT1")) assert.Equal(t, "_", filePathSanitize("CoM1")) + assert.Equal(t, "test_CON", filePathSanitize("test_CON")) + assert.Equal(t, "test CON", filePathSanitize("test CON ")) + + // special chars assert.Equal(t, "_", filePathSanitize("\u0000")) - assert.Equal(t, "目标", filePathSanitize("目标")) - // unlike filepath.Clean, it only sanitizes, doesn't change the separator layout - assert.Equal(t, "", filePathSanitize("")) //nolint:testifylint // for easy reading + assert.Equal(t, ".", filePathSanitize("")) assert.Equal(t, ".", filePathSanitize(".")) - assert.Equal(t, "/", filePathSanitize("/")) + assert.Equal(t, ".", filePathSanitize("/")) } func TestProcessGiteaTemplateFile(t *testing.T) { diff --git a/templates/package/content/pypi.tmpl b/templates/package/content/pypi.tmpl index 2625c160fe0..15d8971eaa7 100644 --- a/templates/package/content/pypi.tmpl +++ b/templates/package/content/pypi.tmpl @@ -4,7 +4,7 @@
-
pip install --index-url  --extra-index-url https://pypi.org/ {{.PackageDescriptor.Package.Name}}
+
pip install --index-url  --extra-index-url https://pypi.org/simple {{.PackageDescriptor.Package.Name}}
From ac8308b5cbb10c46086862494e87f1b4fb79d211 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sat, 13 Dec 2025 14:03:51 +0100 Subject: [PATCH 06/28] Refactor `FileTreeItem` type (#36137) --- web_src/js/components/ViewFileTreeItem.vue | 14 ++------------ web_src/js/components/ViewFileTreeStore.ts | 13 +++++++++++-- web_src/js/globals.d.ts | 4 ++-- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/web_src/js/components/ViewFileTreeItem.vue b/web_src/js/components/ViewFileTreeItem.vue index 9a50adedaac..ce019e655f4 100644 --- a/web_src/js/components/ViewFileTreeItem.vue +++ b/web_src/js/components/ViewFileTreeItem.vue @@ -2,20 +2,10 @@ import {SvgIcon} from '../svg.ts'; import {isPlainClick} from '../utils/dom.ts'; import {shallowRef} from 'vue'; -import {type createViewFileTreeStore} from './ViewFileTreeStore.ts'; - -export type Item = { - entryName: string; - entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown'; - entryIcon: string; - entryIconOpen: string; - fullPath: string; - submoduleUrl?: string; - children?: Item[]; -}; +import type {createViewFileTreeStore, FileTreeItem} from './ViewFileTreeStore.ts'; const props = defineProps<{ - item: Item, + item: FileTreeItem, store: ReturnType }>(); diff --git a/web_src/js/components/ViewFileTreeStore.ts b/web_src/js/components/ViewFileTreeStore.ts index 2dc80938783..936db077764 100644 --- a/web_src/js/components/ViewFileTreeStore.ts +++ b/web_src/js/components/ViewFileTreeStore.ts @@ -3,11 +3,20 @@ import {GET} from '../modules/fetch.ts'; import {pathEscapeSegments} from '../utils/url.ts'; import {createElementFromHTML} from '../utils/dom.ts'; import {html} from '../utils/html.ts'; -import type {Item} from './ViewFileTreeItem.vue'; + +export type FileTreeItem = { + entryName: string; + entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown'; + entryIcon: string; + entryIconOpen: string; + fullPath: string; + submoduleUrl?: string; + children?: Array; +}; export function createViewFileTreeStore(props: {repoLink: string, treePath: string, currentRefNameSubURL: string}) { const store = reactive({ - rootFiles: [] as Array, + rootFiles: [] as Array, selectedItem: props.treePath, async loadChildren(treePath: string, subPath: string = '') { diff --git a/web_src/js/globals.d.ts b/web_src/js/globals.d.ts index 00f1744a957..49ce63d688f 100644 --- a/web_src/js/globals.d.ts +++ b/web_src/js/globals.d.ts @@ -12,8 +12,8 @@ declare module '*.vue' { import type {DefineComponent} from 'vue'; const component: DefineComponent; export default component; - // List of named exports from vue components, used to make `tsc` output clean. - // To actually lint .vue files, `vue-tsc` is used because `tsc` can not parse them. + // Here we declare all exports from vue files so `tsc` or `tsgo` can work for + // non-vue files. To lint .vue files, `vue-tsc` must be used. export function initDashboardRepoList(): void; export function initRepositoryActionView(): void; } From 29057ea55f3e21f56b54621581bb9b5b0956aba8 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 13 Dec 2025 05:54:03 -0800 Subject: [PATCH 07/28] Fix bug when viewing the commit diff page with non-ANSI files (#36149) Fix #35504 --------- Co-authored-by: wxiaoguang --- modules/charset/charset.go | 154 +++++----- modules/charset/charset_test.go | 269 +++++------------- modules/httplib/serve.go | 7 +- modules/indexer/code/bleve/bleve.go | 2 +- .../code/elasticsearch/elasticsearch.go | 2 +- modules/setting/setting.go | 1 + routers/web/repo/editor.go | 6 +- services/gitdiff/gitdiff.go | 43 ++- services/gitdiff/gitdiff_test.go | 39 +++ services/gitdiff/highlightdiff_test.go | 8 +- .../migration-test/migration_test.go | 6 +- 11 files changed, 220 insertions(+), 317 deletions(-) diff --git a/modules/charset/charset.go b/modules/charset/charset.go index 597ce5120c6..b1566549733 100644 --- a/modules/charset/charset.go +++ b/modules/charset/charset.go @@ -5,12 +5,10 @@ package charset import ( "bytes" - "fmt" "io" "strings" "unicode/utf8" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -23,60 +21,39 @@ import ( var UTF8BOM = []byte{'\xef', '\xbb', '\xbf'} type ConvertOpts struct { - KeepBOM bool + KeepBOM bool + ErrorReplacement []byte + ErrorReturnOrigin bool } +var ToUTF8WithFallbackReaderPrefetchSize = 16 * 1024 + // ToUTF8WithFallbackReader detects the encoding of content and converts to UTF-8 reader if possible func ToUTF8WithFallbackReader(rd io.Reader, opts ConvertOpts) io.Reader { - buf := make([]byte, 2048) + buf := make([]byte, ToUTF8WithFallbackReaderPrefetchSize) n, err := util.ReadAtMost(rd, buf) if err != nil { - return io.MultiReader(bytes.NewReader(MaybeRemoveBOM(buf[:n], opts)), rd) - } - - charsetLabel, err := DetectEncoding(buf[:n]) - if err != nil || charsetLabel == "UTF-8" { - return io.MultiReader(bytes.NewReader(MaybeRemoveBOM(buf[:n], opts)), rd) - } - - encoding, _ := charset.Lookup(charsetLabel) - if encoding == nil { + // read error occurs, don't do any processing return io.MultiReader(bytes.NewReader(buf[:n]), rd) } - return transform.NewReader( - io.MultiReader( - bytes.NewReader(MaybeRemoveBOM(buf[:n], opts)), - rd, - ), - encoding.NewDecoder(), - ) -} - -// ToUTF8 converts content to UTF8 encoding -func ToUTF8(content []byte, opts ConvertOpts) (string, error) { - charsetLabel, err := DetectEncoding(content) - if err != nil { - return "", err - } else if charsetLabel == "UTF-8" { - return string(MaybeRemoveBOM(content, opts)), nil + charsetLabel, _ := DetectEncoding(buf[:n]) + if charsetLabel == "UTF-8" { + // is utf-8, try to remove BOM and read it as-is + return io.MultiReader(bytes.NewReader(maybeRemoveBOM(buf[:n], opts)), rd) } encoding, _ := charset.Lookup(charsetLabel) if encoding == nil { - return string(content), fmt.Errorf("Unknown encoding: %s", charsetLabel) + // unknown charset, don't do any processing + return io.MultiReader(bytes.NewReader(buf[:n]), rd) } - // If there is an error, we concatenate the nicely decoded part and the - // original left over. This way we won't lose much data. - result, n, err := transform.Bytes(encoding.NewDecoder(), content) - if err != nil { - result = append(result, content[n:]...) - } - - result = MaybeRemoveBOM(result, opts) - - return string(result), err + // convert from charset to utf-8 + return transform.NewReader( + io.MultiReader(bytes.NewReader(buf[:n]), rd), + encoding.NewDecoder(), + ) } // ToUTF8WithFallback detects the encoding of content and converts to UTF-8 if possible @@ -85,73 +62,84 @@ func ToUTF8WithFallback(content []byte, opts ConvertOpts) []byte { return bs } -// ToUTF8DropErrors makes sure the return string is valid utf-8; attempts conversion if possible -func ToUTF8DropErrors(content []byte, opts ConvertOpts) []byte { - charsetLabel, err := DetectEncoding(content) - if err != nil || charsetLabel == "UTF-8" { - return MaybeRemoveBOM(content, opts) +func ToUTF8DropErrors(content []byte) []byte { + return ToUTF8(content, ConvertOpts{ErrorReplacement: []byte{' '}}) +} + +func ToUTF8(content []byte, opts ConvertOpts) []byte { + charsetLabel, _ := DetectEncoding(content) + if charsetLabel == "UTF-8" { + return maybeRemoveBOM(content, opts) } encoding, _ := charset.Lookup(charsetLabel) if encoding == nil { + setting.PanicInDevOrTesting("unsupported detected charset %q, it shouldn't happen", charsetLabel) return content } - // We ignore any non-decodable parts from the file. - // Some parts might be lost var decoded []byte decoder := encoding.NewDecoder() idx := 0 - for { + for idx < len(content) { result, n, err := transform.Bytes(decoder, content[idx:]) decoded = append(decoded, result...) if err == nil { break } - decoded = append(decoded, ' ') - idx = idx + n + 1 - if idx >= 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 cd2e3b9aaa4..0314abc347b 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/httplib/serve.go b/modules/httplib/serve.go index b4c5e7fe1eb..2d66a86a8b0 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) } diff --git a/modules/indexer/code/bleve/bleve.go b/modules/indexer/code/bleve/bleve.go index bdb477ce6e7..5f6a7f60827 100644 --- a/modules/indexer/code/bleve/bleve.go +++ b/modules/indexer/code/bleve/bleve.go @@ -203,7 +203,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(), }) diff --git a/modules/indexer/code/elasticsearch/elasticsearch.go b/modules/indexer/code/elasticsearch/elasticsearch.go index b2eb301a5dc..a7027051d26 100644 --- a/modules/indexer/code/elasticsearch/elasticsearch.go +++ b/modules/indexer/code/elasticsearch/elasticsearch.go @@ -191,7 +191,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(), diff --git a/modules/setting/setting.go b/modules/setting/setting.go index e14997801fe..dc60d99bd6e 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -240,4 +240,5 @@ func PanicInDevOrTesting(msg string, a ...any) { if !IsProd || IsInTesting { panic(fmt.Sprintf(msg, a...)) } + log.Error(msg, a...) } diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go index 983249a6d24..048c9f3d4a4 100644 --- a/routers/web/repo/editor.go +++ b/routers/web/repo/editor.go @@ -317,11 +317,7 @@ func EditFile(ctx *context.Context) { ctx.ServerError("ReadAll", err) return } - if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil { - ctx.Data["FileContent"] = string(buf) - } else { - ctx.Data["FileContent"] = content - } + ctx.Data["FileContent"] = string(charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true, ErrorReturnOrigin: true})) } } diff --git a/services/gitdiff/gitdiff.go b/services/gitdiff/gitdiff.go index 6e15f716095..f8fde6ab297 100644 --- a/services/gitdiff/gitdiff.go +++ b/services/gitdiff/gitdiff.go @@ -835,11 +835,11 @@ parsingLoop: if buffer.Len() == 0 { continue } - charsetLabel, err := charset.DetectEncoding(buffer.Bytes()) - if charsetLabel != "UTF-8" && err == nil { - encoding, _ := stdcharset.Lookup(charsetLabel) - if encoding != nil { - diffLineTypeDecoders[lineType] = encoding.NewDecoder() + charsetLabel, _ := charset.DetectEncoding(buffer.Bytes()) + if charsetLabel != "UTF-8" { + charsetEncoding, _ := stdcharset.Lookup(charsetLabel) + if charsetEncoding != nil { + diffLineTypeDecoders[lineType] = charsetEncoding.NewDecoder() } } } @@ -1325,10 +1325,10 @@ func GetDiffForRender(ctx context.Context, repoLink string, gitRepo *git.Reposit shouldFullFileHighlight := !setting.Git.DisableDiffHighlight && attrDiff.Value() == "" if shouldFullFileHighlight { if limitedContent.LeftContent != nil && limitedContent.LeftContent.buf.Len() < MaxDiffHighlightEntireFileSize { - diffFile.highlightedLeftLines = highlightCodeLines(diffFile, true /* left */, limitedContent.LeftContent.buf.String()) + diffFile.highlightedLeftLines = highlightCodeLines(diffFile, true /* left */, limitedContent.LeftContent.buf.Bytes()) } if limitedContent.RightContent != nil && limitedContent.RightContent.buf.Len() < MaxDiffHighlightEntireFileSize { - diffFile.highlightedRightLines = highlightCodeLines(diffFile, false /* right */, limitedContent.RightContent.buf.String()) + diffFile.highlightedRightLines = highlightCodeLines(diffFile, false /* right */, limitedContent.RightContent.buf.Bytes()) } } } @@ -1336,9 +1336,34 @@ func GetDiffForRender(ctx context.Context, repoLink string, gitRepo *git.Reposit return diff, nil } -func highlightCodeLines(diffFile *DiffFile, isLeft bool, content string) map[int]template.HTML { +func splitHighlightLines(buf []byte) (ret [][]byte) { + lineCount := bytes.Count(buf, []byte("\n")) + 1 + ret = make([][]byte, 0, lineCount) + nlTagClose := []byte("\n" 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:] + } +} + +func highlightCodeLines(diffFile *DiffFile, isLeft bool, rawContent []byte) map[int]template.HTML { + content := util.UnsafeBytesToString(charset.ToUTF8(rawContent, charset.ConvertOpts{})) highlightedNewContent, _ := highlight.Code(diffFile.Name, diffFile.Language, content) - splitLines := strings.Split(string(highlightedNewContent), "\n") + splitLines := splitHighlightLines([]byte(highlightedNewContent)) lines := make(map[int]template.HTML, len(splitLines)) // only save the highlighted lines we need, but not the whole file, to save memory for _, sec := range diffFile.Sections { diff --git a/services/gitdiff/gitdiff_test.go b/services/gitdiff/gitdiff_test.go index 721ae0dfc75..a94dad8b63e 100644 --- a/services/gitdiff/gitdiff_test.go +++ b/services/gitdiff/gitdiff_test.go @@ -5,6 +5,7 @@ package gitdiff import ( + "html/template" "strconv" "strings" "testing" @@ -1106,3 +1107,41 @@ func TestDiffLine_GetExpandDirection(t *testing.T) { assert.Equal(t, c.direction, c.diffLine.GetExpandDirection(), "case %s expected direction: %s", c.name, c.direction) } } + +func TestHighlightCodeLines(t *testing.T) { + t.Run("CharsetDetecting", func(t *testing.T) { + diffFile := &DiffFile{ + Name: "a.c", + Language: "c", + Sections: []*DiffSection{ + { + Lines: []*DiffLine{{LeftIdx: 1}}, + }, + }, + } + ret := highlightCodeLines(diffFile, true, []byte("// abc\xcc def\xcd")) // ISO-8859-1 bytes + assert.Equal(t, "// abcÌ defÍ\n", string(ret[0])) + }) + + t.Run("LeftLines", func(t *testing.T) { + diffFile := &DiffFile{ + Name: "a.c", + Language: "c", + Sections: []*DiffSection{ + { + Lines: []*DiffLine{ + {LeftIdx: 1}, + {LeftIdx: 2}, + {LeftIdx: 3}, + }, + }, + }, + } + const nl = "\n" + ret := highlightCodeLines(diffFile, true, []byte("a\nb\n")) + assert.Equal(t, map[int]template.HTML{ + 0: `a` + nl, + 1: `b`, + }, ret) + }) +} diff --git a/services/gitdiff/highlightdiff_test.go b/services/gitdiff/highlightdiff_test.go index aebe38ae7ca..0df2e29d13e 100644 --- a/services/gitdiff/highlightdiff_test.go +++ b/services/gitdiff/highlightdiff_test.go @@ -25,12 +25,12 @@ func TestDiffWithHighlight(t *testing.T) { t.Run("CleanUp", func(t *testing.T) { hcd := newHighlightCodeDiff() - codeA := template.HTML(`this is updated comment`) + codeA := template.HTML(`this is a comment`) + codeB := template.HTML(`this is updated comment`) outDel := hcd.diffLineWithHighlight(DiffLineDel, codeA, codeB) - assert.Equal(t, `a comment`, string(outDel)) + assert.Equal(t, `this is a comment`, string(outDel)) outAdd := hcd.diffLineWithHighlight(DiffLineAdd, codeA, codeB) - assert.Equal(t, `updated comment`, string(outAdd)) + assert.Equal(t, `this is updated comment`, string(outAdd)) }) t.Run("OpenCloseTags", func(t *testing.T) { diff --git a/tests/integration/migration-test/migration_test.go b/tests/integration/migration-test/migration_test.go index 5fa7cbbfb7f..2659c5c53db 100644 --- a/tests/integration/migration-test/migration_test.go +++ b/tests/integration/migration-test/migration_test.go @@ -4,6 +4,7 @@ package migrations import ( + "bytes" "compress/gzip" "context" "database/sql" @@ -21,7 +22,6 @@ import ( "code.gitea.io/gitea/models/migrations" migrate_base "code.gitea.io/gitea/models/migrations/base" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -108,11 +108,11 @@ func readSQLFromFile(version string) (string, error) { } defer gr.Close() - bytes, err := io.ReadAll(gr) + buf, err := io.ReadAll(gr) if err != nil { return "", err } - return string(charset.MaybeRemoveBOM(bytes, charset.ConvertOpts{})), nil + return string(bytes.TrimPrefix(buf, []byte{'\xef', '\xbb', '\xbf'})), nil } func restoreOldDB(t *testing.T, version string) { From 1f5237e0d7214294aceaa4487a98c88d183a243c Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 13 Dec 2025 18:14:18 -0800 Subject: [PATCH 08/28] Check user visibility when redirecting to a renamed user (#36148) Fix #34169 --- routers/api/v1/api.go | 4 +- routers/api/v1/user/helper.go | 2 +- services/context/context_response.go | 16 ++++++- services/context/org.go | 2 +- services/context/repo.go | 2 +- services/context/user.go | 2 +- tests/integration/user_test.go | 72 ++++++++++++++++++++++++++++ 7 files changed, 92 insertions(+), 8 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 9bce98ac025..fcf9e730571 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -152,7 +152,7 @@ func repoAssignment() func(ctx *context.APIContext) { if err != nil { if user_model.IsErrUserNotExist(err) { if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil { - context.RedirectToUser(ctx.Base, userName, redirectUserID) + context.RedirectToUser(ctx.Base, ctx.Doer, userName, redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { ctx.APIErrorNotFound("GetUserByName", err) } else { @@ -612,7 +612,7 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) { if organization.IsErrOrgNotExist(err) { redirectUserID, err := user_model.LookupUserRedirect(ctx, ctx.PathParam("org")) if err == nil { - context.RedirectToUser(ctx.Base, ctx.PathParam("org"), redirectUserID) + context.RedirectToUser(ctx.Base, ctx.Doer, ctx.PathParam("org"), redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { ctx.APIErrorNotFound("GetOrgByName", err) } else { diff --git a/routers/api/v1/user/helper.go b/routers/api/v1/user/helper.go index f49bbbd6db2..de3ec089df6 100644 --- a/routers/api/v1/user/helper.go +++ b/routers/api/v1/user/helper.go @@ -16,7 +16,7 @@ func GetUserByPathParam(ctx *context.APIContext, name string) *user_model.User { if err != nil { if user_model.IsErrUserNotExist(err) { if redirectUserID, err2 := user_model.LookupUserRedirect(ctx, username); err2 == nil { - context.RedirectToUser(ctx.Base, username, redirectUserID) + context.RedirectToUser(ctx.Base, ctx.Doer, username, redirectUserID) } else { ctx.APIErrorNotFound("GetUserByName", err) } diff --git a/services/context/context_response.go b/services/context/context_response.go index 3f64fc7352b..bb896024b1a 100644 --- a/services/context/context_response.go +++ b/services/context/context_response.go @@ -20,15 +20,27 @@ import ( "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/web/middleware" ) // RedirectToUser redirect to a differently-named user -func RedirectToUser(ctx *Base, userName string, redirectUserID int64) { +func RedirectToUser(ctx *Base, doer *user_model.User, userName string, redirectUserID int64) { user, err := user_model.GetUserByID(ctx, redirectUserID) if err != nil { - ctx.HTTPError(http.StatusInternalServerError, "unable to get user") + if user_model.IsErrUserNotExist(err) { + ctx.HTTPError(http.StatusNotFound, "user does not exist") + } else { + ctx.HTTPError(http.StatusInternalServerError, "unable to get user") + } + return + } + + // Handle Visibility + if user.Visibility != structs.VisibleTypePublic && doer == nil { + // We must be signed in to see limited or private organizations + ctx.HTTPError(http.StatusNotFound, "user does not exist") return } diff --git a/services/context/org.go b/services/context/org.go index 1cd89231785..d41bd5ea79f 100644 --- a/services/context/org.go +++ b/services/context/org.go @@ -49,7 +49,7 @@ func GetOrganizationByParams(ctx *Context) { if organization.IsErrOrgNotExist(err) { redirectUserID, err := user_model.LookupUserRedirect(ctx, orgName) if err == nil { - RedirectToUser(ctx.Base, orgName, redirectUserID) + RedirectToUser(ctx.Base, ctx.Doer, orgName, redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { ctx.NotFound(err) } else { diff --git a/services/context/repo.go b/services/context/repo.go index 64b86952364..5a313e6f150 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -443,7 +443,7 @@ func RepoAssignment(ctx *Context) { } if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil { - RedirectToUser(ctx.Base, userName, redirectUserID) + RedirectToUser(ctx.Base, ctx.Doer, userName, redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { ctx.NotFound(nil) } else { diff --git a/services/context/user.go b/services/context/user.go index f1a3035ee9b..19c055e2a30 100644 --- a/services/context/user.go +++ b/services/context/user.go @@ -69,7 +69,7 @@ func userAssignment(ctx *Base, doer *user_model.User, errCb func(int, any)) (con if err != nil { if user_model.IsErrUserNotExist(err) { if redirectUserID, err := user_model.LookupUserRedirect(ctx, username); err == nil { - RedirectToUser(ctx, username, redirectUserID) + RedirectToUser(ctx, doer, username, redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { errCb(http.StatusNotFound, err) } else { diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go index 34692d9cabf..54b372dd164 100644 --- a/tests/integration/user_test.go +++ b/tests/integration/user_test.go @@ -45,6 +45,78 @@ func TestRenameUsername(t *testing.T) { unittest.AssertNotExistsBean(t, &user_model.User{Name: "user2"}) } +func TestViewLimitedAndPrivateUserAndRename(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // user 22 is a limited visibility org + org22 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22}) + req := NewRequest(t, "GET", "/"+org22.Name) + MakeRequest(t, req, http.StatusNotFound) + + session := loginUser(t, "user1") + oldName := org22.Name + newName := "org22_renamed" + req = NewRequestWithValues(t, "POST", "/org/"+oldName+"/settings/rename", map[string]string{ + "_csrf": GetUserCSRFToken(t, session), + "org_name": oldName, + "new_org_name": newName, + }) + session.MakeRequest(t, req, http.StatusOK) + + unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: newName}) + unittest.AssertNotExistsBean(t, &user_model.User{Name: oldName}) + + req = NewRequest(t, "GET", "/"+oldName) + MakeRequest(t, req, http.StatusNotFound) // anonymous user cannot visit limited visibility org via old name + req = NewRequest(t, "GET", "/"+oldName) + session.MakeRequest(t, req, http.StatusTemporaryRedirect) // login user can visit limited visibility org via old name + + // org 23 is a private visibility org + org23 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23}) + req = NewRequest(t, "GET", "/"+org23.Name) + MakeRequest(t, req, http.StatusNotFound) + + oldName = org23.Name + newName = "org23_renamed" + req = NewRequestWithValues(t, "POST", "/org/"+oldName+"/settings/rename", map[string]string{ + "_csrf": GetUserCSRFToken(t, session), + "org_name": oldName, + "new_org_name": newName, + }) + session.MakeRequest(t, req, http.StatusOK) + + unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: newName}) + unittest.AssertNotExistsBean(t, &user_model.User{Name: oldName}) + + req = NewRequest(t, "GET", "/"+oldName) + MakeRequest(t, req, http.StatusNotFound) // anonymous user cannot visit limited visibility org via old name + req = NewRequest(t, "GET", "/"+oldName) + session.MakeRequest(t, req, http.StatusTemporaryRedirect) // login user can visit limited visibility org via old name + + // user 31 is a private visibility user + user31 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 31}) + req = NewRequest(t, "GET", "/"+user31.Name) + MakeRequest(t, req, http.StatusNotFound) + + oldName = user31.Name + newName = "user31_renamed" + session2 := loginUser(t, oldName) + req = NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ + "_csrf": GetUserCSRFToken(t, session2), + "name": newName, + "visibility": "2", // private + }) + session2.MakeRequest(t, req, http.StatusSeeOther) + + unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: newName}) + unittest.AssertNotExistsBean(t, &user_model.User{Name: oldName}) + + req = NewRequest(t, "GET", "/"+oldName) + MakeRequest(t, req, http.StatusNotFound) // anonymous user cannot visit private visibility user via old name + req = NewRequest(t, "GET", "/"+oldName) + session.MakeRequest(t, req, http.StatusTemporaryRedirect) // login user2 can visit private visibility user via old name +} + func TestRenameInvalidUsername(t *testing.T) { defer tests.PrepareTestEnv(t)() From 7190519fb33d26a55548398aa1a56aa4b4612a3c Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 14 Dec 2025 18:40:55 +0800 Subject: [PATCH 09/28] Fix code highlighting on blame page (#36157) 1. Full file highlighting (fix the legacy todo "we should instead highlight the whole file at once") * Fix #24383 2. Correctly covert file content encoding 3. Remove dead code, split large for-loop into small functions/blocks to make code maintainable --- modules/charset/escape.go | 7 +- modules/highlight/highlight.go | 34 +++++++- modules/highlight/highlight_test.go | 18 +++++ routers/web/repo/blame.go | 120 +++++++++++++--------------- services/gitdiff/gitdiff.go | 32 +------- templates/repo/blame.tmpl | 2 +- web_src/css/base.css | 2 +- 7 files changed, 116 insertions(+), 99 deletions(-) diff --git a/modules/charset/escape.go b/modules/charset/escape.go index 92e417d1f72..167683a298b 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/highlight/highlight.go b/modules/highlight/highlight.go index 77e47fdf486..2b13e9c4cef 100644 --- a/modules/highlight/highlight.go +++ b/modules/highlight/highlight.go @@ -56,7 +56,39 @@ func NewContext() { }) } -// Code returns a HTML version of code string with chroma syntax highlighting classes and the matched lexer name +// 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:] + } +} + +// Code returns an 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() diff --git a/modules/highlight/highlight_test.go b/modules/highlight/highlight_test.go index b36de98c5c2..52873427a82 100644 --- a/modules/highlight/highlight_test.go +++ b/modules/highlight/highlight_test.go @@ -181,3 +181,21 @@ c=2`), }) } } + +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/routers/web/repo/blame.go b/routers/web/repo/blame.go index 0eebff6aa88..6a4618a3c7d 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -4,8 +4,9 @@ package repo import ( + "bytes" "fmt" - gotemplate "html/template" + "html/template" "net/http" "net/url" "path" @@ -25,18 +26,17 @@ import ( ) type blameRow struct { - RowNumber int - Avatar gotemplate.HTML - RepoLink string - PartSha string + RowNumber int + + Avatar template.HTML PreviousSha string PreviousShaURL string - IsFirstCommit bool CommitURL string CommitMessage string - CommitSince gotemplate.HTML - Code gotemplate.HTML - EscapeStatus *charset.EscapeStatus + CommitSince template.HTML + + Code template.HTML + EscapeStatus *charset.EscapeStatus } // RefBlame render blame page @@ -220,76 +220,64 @@ func processBlameParts(ctx *context.Context, blameParts []*git.BlamePart) map[st return commitNames } -func renderBlame(ctx *context.Context, blameParts []*git.BlamePart, commitNames map[string]*user_model.UserCommit) { - repoLink := ctx.Repo.RepoLink +func renderBlameFillFirstBlameRow(repoLink string, avatarUtils *templates.AvatarUtils, part *git.BlamePart, commit *user_model.UserCommit, br *blameRow) { + if commit.User != nil { + br.Avatar = avatarUtils.Avatar(commit.User, 18) + } else { + br.Avatar = avatarUtils.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18) + } + br.PreviousSha = part.PreviousSha + br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath)) + br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha)) + br.CommitMessage = commit.CommitMessage + br.CommitSince = templates.TimeSince(commit.Author.When) +} + +func renderBlame(ctx *context.Context, blameParts []*git.BlamePart, commitNames map[string]*user_model.UserCommit) { language, err := languagestats.GetFileLanguage(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath) if err != nil { log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err) } - lines := make([]string, 0) + buf := &bytes.Buffer{} rows := make([]*blameRow, 0) + avatarUtils := templates.NewAvatarUtils(ctx) + rowNumber := 0 // will be 1-based + for _, part := range blameParts { + for partLineIdx, line := range part.Lines { + rowNumber++ + + br := &blameRow{RowNumber: rowNumber} + rows = append(rows, br) + + if int64(buf.Len()) < setting.UI.MaxDisplayFileSize { + buf.WriteString(line) + buf.WriteByte('\n') + } + + if partLineIdx == 0 { + renderBlameFillFirstBlameRow(ctx.Repo.RepoLink, avatarUtils, part, commitNames[part.Sha], br) + } + } + } + escapeStatus := &charset.EscapeStatus{} - var lexerName string - - avatarUtils := templates.NewAvatarUtils(ctx) - i := 0 - commitCnt := 0 - for _, part := range blameParts { - for index, line := range part.Lines { - i++ - lines = append(lines, line) - - br := &blameRow{ - RowNumber: i, - } - - commit := commitNames[part.Sha] - if index == 0 { - // Count commit number - commitCnt++ - - // User avatar image - commitSince := templates.TimeSince(commit.Author.When) - - var avatar string - if commit.User != nil { - avatar = string(avatarUtils.Avatar(commit.User, 18)) - } else { - avatar = string(avatarUtils.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18, "tw-mr-2")) - } - - br.Avatar = gotemplate.HTML(avatar) - br.RepoLink = repoLink - br.PartSha = part.Sha - br.PreviousSha = part.PreviousSha - br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath)) - br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha)) - br.CommitMessage = commit.CommitMessage - br.CommitSince = commitSince - } - - if i != len(lines)-1 { - line += "\n" - } - line, lexerNameForLine := highlight.Code(path.Base(ctx.Repo.TreePath), language, line) - - // set lexer name to the first detected lexer. this is certainly suboptimal and - // we should instead highlight the whole file at once - if lexerName == "" { - lexerName = lexerNameForLine - } - - br.EscapeStatus, br.Code = charset.EscapeControlHTML(line, ctx.Locale) - rows = append(rows, br) - escapeStatus = escapeStatus.Or(br.EscapeStatus) + bufContent := buf.Bytes() + bufContent = charset.ToUTF8(bufContent, charset.ConvertOpts{}) + highlighted, lexerName := highlight.Code(path.Base(ctx.Repo.TreePath), language, util.UnsafeBytesToString(bufContent)) + unsafeLines := highlight.UnsafeSplitHighlightedLines(highlighted) + for i, br := range rows { + var line template.HTML + if i < len(rows) { + line = template.HTML(util.UnsafeBytesToString(unsafeLines[i])) } + br.EscapeStatus, br.Code = charset.EscapeControlHTML(line, ctx.Locale) + escapeStatus = escapeStatus.Or(br.EscapeStatus) } ctx.Data["EscapeStatus"] = escapeStatus ctx.Data["BlameRows"] = rows - ctx.Data["CommitCnt"] = commitCnt ctx.Data["LexerName"] = lexerName } diff --git a/services/gitdiff/gitdiff.go b/services/gitdiff/gitdiff.go index f8fde6ab297..34e94671a25 100644 --- a/services/gitdiff/gitdiff.go +++ b/services/gitdiff/gitdiff.go @@ -1336,35 +1336,11 @@ func GetDiffForRender(ctx context.Context, repoLink string, gitRepo *git.Reposit return diff, nil } -func splitHighlightLines(buf []byte) (ret [][]byte) { - lineCount := bytes.Count(buf, []byte("\n")) + 1 - ret = make([][]byte, 0, lineCount) - nlTagClose := []byte("\n" 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:] - } -} - func highlightCodeLines(diffFile *DiffFile, isLeft bool, rawContent []byte) map[int]template.HTML { content := util.UnsafeBytesToString(charset.ToUTF8(rawContent, charset.ConvertOpts{})) highlightedNewContent, _ := highlight.Code(diffFile.Name, diffFile.Language, content) - splitLines := splitHighlightLines([]byte(highlightedNewContent)) - lines := make(map[int]template.HTML, len(splitLines)) + unsafeLines := highlight.UnsafeSplitHighlightedLines(highlightedNewContent) + lines := make(map[int]template.HTML, len(unsafeLines)) // only save the highlighted lines we need, but not the whole file, to save memory for _, sec := range diffFile.Sections { for _, ln := range sec.Lines { @@ -1374,8 +1350,8 @@ func highlightCodeLines(diffFile *DiffFile, isLeft bool, rawContent []byte) map[ } if lineIdx >= 1 { idx := lineIdx - 1 - if idx < len(splitLines) { - lines[idx] = template.HTML(splitLines[idx]) + if idx < len(unsafeLines) { + lines[idx] = template.HTML(util.UnsafeBytesToString(unsafeLines[idx])) } } } diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index c4d9f0741f2..9cd4b2a1223 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -38,7 +38,7 @@ {{range $row := .BlameRows}} - + - +
diff --git a/web_src/css/base.css b/web_src/css/base.css index 0e690a0265a..36b3d118ae5 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -919,7 +919,7 @@ overflow-menu .ui.label { .blame-avatar { display: flex; align-items: center; - margin-right: 4px; + margin-right: 6px; } tr.top-line-blame { From ff70ed6c677b744c95253374b3e98eda2378c045 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 14 Dec 2025 14:35:19 +0100 Subject: [PATCH 10/28] Enable gocheckcompilerdirectives linter (#36156) Enable [`gocheckcompilerdirectives`](https://github.com/leighmcculloch/gocheckcompilerdirectives) to validate compiler directives, no current violation. --- .golangci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.golangci.yml b/.golangci.yml index 2f1587a1e6d..699881d7ed7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,6 +10,7 @@ linters: - dupl - errcheck - forbidigo + - gocheckcompilerdirectives - gocritic - govet - ineffassign From 26602fd2070886a1e7e0545f11f5541a38396003 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sun, 14 Dec 2025 09:34:45 -0800 Subject: [PATCH 11/28] Remove undocumented support of signing key in the repository git configuration file (#36143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-repository signing keys have never been officially supported, as they would require users to modify the repository’s config file. At this point, it is clear that only global signing keys (GPG or SSH) should be allowed. If we want to introduce per-repository signing keys in the future, it will require a complete design proposal. The endpoint will not be removed for repository special signing key, but it will reference the global signing key. --------- Signed-off-by: Lunny Xiao Co-authored-by: delvh --- modules/git/commit.go | 8 -- modules/git/gpg.go | 102 +++++++++++++++++++++++++ modules/git/key.go | 12 +-- modules/git/repo.go | 10 --- modules/git/repo_base_gogit.go | 1 - modules/git/repo_base_nogogit.go | 2 - modules/git/repo_gpg.go | 71 ----------------- modules/gitrepo/signing.go | 4 +- routers/api/v1/misc/signing.go | 9 +-- routers/web/repo/setting/setting.go | 4 +- services/asymkey/commit.go | 2 +- services/asymkey/sign.go | 24 +++--- services/context/repo.go | 2 +- services/repository/files/patch.go | 2 +- services/repository/files/temp_repo.go | 4 +- services/repository/files/update.go | 2 +- services/repository/init.go | 2 +- 17 files changed, 133 insertions(+), 128 deletions(-) create mode 100644 modules/git/gpg.go delete mode 100644 modules/git/repo_gpg.go diff --git a/modules/git/commit.go b/modules/git/commit.go index af096970182..1917a72bbf6 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -323,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/gpg.go b/modules/git/gpg.go new file mode 100644 index 00000000000..dbc5569309c --- /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/key.go b/modules/git/key.go index 39e79ddbe08..9d51704595d 100644 --- a/modules/git/key.go +++ b/modules/git/key.go @@ -32,23 +32,23 @@ func (s *SigningKey) String() string { } // GetSigningKey returns the KeyID and git Signature for the repo -func GetSigningKey(ctx context.Context, repoPath string) (*SigningKey, *Signature) { +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", "--get", "commit.gpgsign").WithDir(repoPath).RunStdString(ctx) + 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", "--default", SigningKeyFormatOpenPGP, "--get", "gpg.format").WithDir(repoPath).RunStdString(ctx) - signingKey, _, _ := gitcmd.NewCommand("config", "--get", "user.signingkey").WithDir(repoPath).RunStdString(ctx) - signingName, _, _ := gitcmd.NewCommand("config", "--get", "user.name").WithDir(repoPath).RunStdString(ctx) - signingEmail, _, _ := gitcmd.NewCommand("config", "--get", "user.email").WithDir(repoPath).RunStdString(ctx) + 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 diff --git a/modules/git/repo.go b/modules/git/repo.go index baf29432ec9..579accf92e2 100644 --- a/modules/git/repo.go +++ b/modules/git/repo.go @@ -20,16 +20,6 @@ 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` func (repo *Repository) ShowPrettyFormatLogToList(ctx context.Context, revisionRange string) ([]*Commit, error) { diff --git a/modules/git/repo_base_gogit.go b/modules/git/repo_base_gogit.go index e0d0b45372b..986264fd936 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 4091e708465..17c71da5efd 100644 --- a/modules/git/repo_base_nogogit.go +++ b/modules/git/repo_base_nogogit.go @@ -23,8 +23,6 @@ type Repository struct { tagCache *ObjectCache[*Tag] - gpgSettings *GPGSettings - batchInUse bool batch *Batch diff --git a/modules/git/repo_gpg.go b/modules/git/repo_gpg.go deleted file mode 100644 index eb1e71e30a5..00000000000 --- 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/gitrepo/signing.go b/modules/gitrepo/signing.go index c50978d15a6..2f77758d8c8 100644 --- a/modules/gitrepo/signing.go +++ b/modules/gitrepo/signing.go @@ -9,6 +9,6 @@ import ( "code.gitea.io/gitea/modules/git" ) -func GetSigningKey(ctx context.Context, repo Repository) (*git.SigningKey, *git.Signature) { - return git.GetSigningKey(ctx, repoPath(repo)) +func GetSigningKey(ctx context.Context) (*git.SigningKey, *git.Signature) { + return git.GetSigningKey(ctx) } diff --git a/routers/api/v1/misc/signing.go b/routers/api/v1/misc/signing.go index db70e04b8f6..6e1a9a09b24 100644 --- a/routers/api/v1/misc/signing.go +++ b/routers/api/v1/misc/signing.go @@ -10,13 +10,8 @@ import ( ) func getSigningKey(ctx *context.APIContext, expectedFormat string) { - // if the handler is in the repo's route group, get the repo's signing key - // otherwise, get the global signing key - path := "" - if ctx.Repo != nil && ctx.Repo.Repository != nil { - path = ctx.Repo.Repository.RepoPath() - } - content, format, err := asymkey_service.PublicSigningKey(ctx, path) + // get the global signing key + content, format, err := asymkey_service.PublicSigningKey(ctx) if err != nil { ctx.APIErrorInternal(err) return diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 60eb35f56d1..0c73c1490fb 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -61,7 +61,7 @@ func SettingsCtxData(ctx *context.Context) { ctx.Data["MinimumMirrorInterval"] = setting.Mirror.MinInterval ctx.Data["CanConvertFork"] = ctx.Repo.Repository.IsFork && ctx.Doer.CanCreateRepoIn(ctx.Repo.Repository.Owner) - signing, _ := gitrepo.GetSigningKey(ctx, ctx.Repo.Repository) + signing, _ := gitrepo.GetSigningKey(ctx) ctx.Data["SigningKeyAvailable"] = signing != nil ctx.Data["SigningSettings"] = setting.Repository.Signing ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled @@ -104,7 +104,7 @@ func SettingsPost(ctx *context.Context) { ctx.Data["DefaultMirrorInterval"] = setting.Mirror.DefaultInterval ctx.Data["MinimumMirrorInterval"] = setting.Mirror.MinInterval - signing, _ := gitrepo.GetSigningKey(ctx, ctx.Repo.Repository) + signing, _ := gitrepo.GetSigningKey(ctx) ctx.Data["SigningKeyAvailable"] = signing != nil ctx.Data["SigningSettings"] = setting.Repository.Signing ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled diff --git a/services/asymkey/commit.go b/services/asymkey/commit.go index 54ef052a507..6286588a608 100644 --- a/services/asymkey/commit.go +++ b/services/asymkey/commit.go @@ -162,7 +162,7 @@ func parseCommitWithGPGSignature(ctx context.Context, c *git.Commit, committer * } } - defaultGPGSettings, err := c.GetRepositoryDefaultPublicGPGKey(false) + defaultGPGSettings, err := git.GetDefaultPublicGPGKey(ctx, false) if err != nil { log.Error("Error getting default public gpg key: %v", err) } else if defaultGPGSettings == nil { diff --git a/services/asymkey/sign.go b/services/asymkey/sign.go index 1ed05ba287e..eb6e461346b 100644 --- a/services/asymkey/sign.go +++ b/services/asymkey/sign.go @@ -108,34 +108,34 @@ func IsErrWontSign(err error) bool { return ok } -// PublicSigningKey gets the public signing key within a provided repository directory -func PublicSigningKey(ctx context.Context, repoPath string) (content, format string, err error) { - signingKey, _ := git.GetSigningKey(ctx, repoPath) +// PublicSigningKey gets the public signing key of the entire instance +func PublicSigningKey(ctx context.Context) (content, format string, err error) { + signingKey, _ := git.GetSigningKey(ctx) if signingKey == nil { return "", "", nil } if signingKey.Format == git.SigningKeyFormatSSH { content, err := os.ReadFile(signingKey.KeyID) if err != nil { - log.Error("Unable to read SSH public key file in %s: %s, %v", repoPath, signingKey, err) + log.Error("Unable to read SSH public key file: %s, %v", signingKey, err) return "", signingKey.Format, err } return string(content), signingKey.Format, nil } - content, stderr, err := process.GetManager().ExecDir(ctx, -1, repoPath, + content, stderr, err := process.GetManager().ExecDir(ctx, -1, setting.Git.HomePath, "gpg --export -a", "gpg", "--export", "-a", signingKey.KeyID) if err != nil { - log.Error("Unable to get default signing key in %s: %s, %s, %v", repoPath, signingKey, stderr, err) + log.Error("Unable to get default signing key: %s, %s, %v", signingKey, stderr, err) return "", signingKey.Format, err } return content, signingKey.Format, nil } // SignInitialCommit determines if we should sign the initial commit to this repository -func SignInitialCommit(ctx context.Context, repoPath string, u *user_model.User) (bool, *git.SigningKey, *git.Signature, error) { +func SignInitialCommit(ctx context.Context, u *user_model.User) (bool, *git.SigningKey, *git.Signature, error) { rules := signingModeFromStrings(setting.Repository.Signing.InitialCommit) - signingKey, sig := git.GetSigningKey(ctx, repoPath) + signingKey, sig := git.GetSigningKey(ctx) if signingKey == nil { return false, nil, nil, &ErrWontSign{noKey} } @@ -171,7 +171,7 @@ Loop: // SignWikiCommit determines if we should sign the commits to this repository wiki func SignWikiCommit(ctx context.Context, repo *repo_model.Repository, u *user_model.User) (bool, *git.SigningKey, *git.Signature, error) { rules := signingModeFromStrings(setting.Repository.Signing.Wiki) - signingKey, sig := gitrepo.GetSigningKey(ctx, repo.WikiStorageRepo()) + signingKey, sig := gitrepo.GetSigningKey(ctx) if signingKey == nil { return false, nil, nil, &ErrWontSign{noKey} } @@ -222,9 +222,9 @@ Loop: } // SignCRUDAction determines if we should sign a CRUD commit to this repository -func SignCRUDAction(ctx context.Context, repoPath string, u *user_model.User, tmpBasePath, parentCommit string) (bool, *git.SigningKey, *git.Signature, error) { +func SignCRUDAction(ctx context.Context, u *user_model.User, tmpBasePath, parentCommit string) (bool, *git.SigningKey, *git.Signature, error) { rules := signingModeFromStrings(setting.Repository.Signing.CRUDActions) - signingKey, sig := git.GetSigningKey(ctx, repoPath) + signingKey, sig := git.GetSigningKey(ctx) if signingKey == nil { return false, nil, nil, &ErrWontSign{noKey} } @@ -288,7 +288,7 @@ func SignMerge(ctx context.Context, pr *issues_model.PullRequest, u *user_model. } repo := pr.BaseRepo - signingKey, signer := gitrepo.GetSigningKey(ctx, repo) + signingKey, signer := gitrepo.GetSigningKey(ctx) if signingKey == nil { return false, nil, nil, &ErrWontSign{noKey} } diff --git a/services/context/repo.go b/services/context/repo.go index 5a313e6f150..e70e83e2339 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -140,7 +140,7 @@ func PrepareCommitFormOptions(ctx *Context, doer *user_model.User, targetRepo *r protectionRequireSigned = protectedBranch.RequireSignedCommits } - willSign, signKey, _, err := asymkey_service.SignCRUDAction(ctx, targetRepo.RepoPath(), doer, targetRepo.RepoPath(), refName.String()) + willSign, signKey, _, err := asymkey_service.SignCRUDAction(ctx, doer, targetRepo.RepoPath(), refName.String()) wontSignReason := "" if asymkey_service.IsErrWontSign(err) { wontSignReason = string(err.(*asymkey_service.ErrWontSign).Reason) diff --git a/services/repository/files/patch.go b/services/repository/files/patch.go index 8fe6bb917ba..5361091c902 100644 --- a/services/repository/files/patch.go +++ b/services/repository/files/patch.go @@ -95,7 +95,7 @@ func (opts *ApplyDiffPatchOptions) Validate(ctx context.Context, repo *repo_mode } } if protectedBranch != nil && protectedBranch.RequireSignedCommits { - _, _, _, err := asymkey_service.SignCRUDAction(ctx, repo.RepoPath(), doer, repo.RepoPath(), opts.OldBranch) + _, _, _, err := asymkey_service.SignCRUDAction(ctx, doer, repo.RepoPath(), opts.OldBranch) if err != nil { if !asymkey_service.IsErrWontSign(err) { return err diff --git a/services/repository/files/temp_repo.go b/services/repository/files/temp_repo.go index aaf9566aec8..cb39abfd6e8 100644 --- a/services/repository/files/temp_repo.go +++ b/services/repository/files/temp_repo.go @@ -303,9 +303,9 @@ func (t *TemporaryUploadRepository) CommitTree(ctx context.Context, opts *Commit var key *git.SigningKey var signer *git.Signature if opts.ParentCommitID != "" { - sign, key, signer, _ = asymkey_service.SignCRUDAction(ctx, t.repo.RepoPath(), opts.DoerUser, t.basePath, opts.ParentCommitID) + sign, key, signer, _ = asymkey_service.SignCRUDAction(ctx, opts.DoerUser, t.basePath, opts.ParentCommitID) } else { - sign, key, signer, _ = asymkey_service.SignInitialCommit(ctx, t.repo.RepoPath(), opts.DoerUser) + sign, key, signer, _ = asymkey_service.SignInitialCommit(ctx, opts.DoerUser) } if sign { if key.Format != "" { diff --git a/services/repository/files/update.go b/services/repository/files/update.go index 4830f711fc2..967c4d928ec 100644 --- a/services/repository/files/update.go +++ b/services/repository/files/update.go @@ -686,7 +686,7 @@ func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, do } } if protectedBranch.RequireSignedCommits { - _, _, _, err := asymkey_service.SignCRUDAction(ctx, repo.RepoPath(), doer, repo.RepoPath(), branchName) + _, _, _, err := asymkey_service.SignCRUDAction(ctx, doer, repo.RepoPath(), branchName) if err != nil { if !asymkey_service.IsErrWontSign(err) { return err diff --git a/services/repository/init.go b/services/repository/init.go index 8d9decf811b..51cc113d63a 100644 --- a/services/repository/init.go +++ b/services/repository/init.go @@ -41,7 +41,7 @@ func initRepoCommit(ctx context.Context, tmpPath string, repo *repo_model.Reposi cmd := gitcmd.NewCommand("commit", "--message=Initial commit"). AddOptionFormat("--author='%s <%s>'", sig.Name, sig.Email) - sign, key, signer, _ := asymkey_service.SignInitialCommit(ctx, tmpPath, u) + sign, key, signer, _ := asymkey_service.SignInitialCommit(ctx, u) if sign { if key.Format != "" { cmd.AddConfig("gpg.format", key.Format) From da087270ff18b4ac3acf1ce657efa27b74dba89c Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Mon, 15 Dec 2025 11:55:44 -0800 Subject: [PATCH 12/28] Some small refactors (#36163) --- modules/gitrepo/gitrepo.go | 4 ++-- services/doctor/checkOldArchives.go | 14 ++++++-------- services/doctor/misc.go | 2 +- services/pull/compare.go | 2 +- services/repository/branch.go | 1 - services/repository/repository.go | 2 +- 6 files changed, 11 insertions(+), 14 deletions(-) diff --git a/modules/gitrepo/gitrepo.go b/modules/gitrepo/gitrepo.go index c78d2c767d1..3a9b0a1c898 100644 --- a/modules/gitrepo/gitrepo.go +++ b/modules/gitrepo/gitrepo.go @@ -109,8 +109,8 @@ func IsRepoDirExist(ctx context.Context, repo Repository, relativeDirPath string return util.IsDir(absoluteDirPath) } -func RemoveRepoFile(ctx context.Context, repo Repository, relativeFilePath string) error { - absoluteFilePath := filepath.Join(repoPath(repo), relativeFilePath) +func RemoveRepoFileOrDir(ctx context.Context, repo Repository, relativeFileOrDirPath string) error { + absoluteFilePath := filepath.Join(repoPath(repo), relativeFileOrDirPath) return util.Remove(absoluteFilePath) } diff --git a/services/doctor/checkOldArchives.go b/services/doctor/checkOldArchives.go index 390dfb43aa8..fa1a6ccb1d1 100644 --- a/services/doctor/checkOldArchives.go +++ b/services/doctor/checkOldArchives.go @@ -5,12 +5,10 @@ package doctor import ( "context" - "os" - "path/filepath" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/util" ) func checkOldArchives(ctx context.Context, logger log.Logger, autofix bool) error { @@ -21,18 +19,18 @@ func checkOldArchives(ctx context.Context, logger log.Logger, autofix bool) erro return nil } - p := filepath.Join(repo.RepoPath(), "archives") - isDir, err := util.IsDir(p) + isDir, err := gitrepo.IsRepoDirExist(ctx, repo, "archives") if err != nil { - log.Warn("check if %s is directory failed: %v", p, err) + log.Warn("check if %s is directory failed: %v", repo.FullName(), err) } if isDir { numRepos++ if autofix { - if err := os.RemoveAll(p); err == nil { + err := gitrepo.RemoveRepoFileOrDir(ctx, repo, "archives") + if err == nil { numReposUpdated++ } else { - log.Warn("remove %s failed: %v", p, err) + log.Warn("remove %s failed: %v", repo.FullName(), err) } } } diff --git a/services/doctor/misc.go b/services/doctor/misc.go index 89f3a63df20..8765cfa0250 100644 --- a/services/doctor/misc.go +++ b/services/doctor/misc.go @@ -151,7 +151,7 @@ func checkDaemonExport(ctx context.Context, logger log.Logger, autofix bool) err numNeedUpdate++ if autofix { if !isPublic && isExist { - if err = gitrepo.RemoveRepoFile(ctx, repo, daemonExportFile); err != nil { + if err = gitrepo.RemoveRepoFileOrDir(ctx, repo, daemonExportFile); err != nil { log.Error("Failed to remove %s:%s: %v", repo.FullName(), daemonExportFile, err) } } else if isPublic && !isExist { diff --git a/services/pull/compare.go b/services/pull/compare.go index c2d39752e86..fbdb17cfdd9 100644 --- a/services/pull/compare.go +++ b/services/pull/compare.go @@ -33,7 +33,7 @@ func GetCompareInfo(ctx context.Context, baseRepo, headRepo *repo_model.Reposito ) // We don't need a temporary remote for same repository. - if headGitRepo.Path != baseRepo.RepoPath() { + if baseRepo.ID != headRepo.ID { // Add a temporary remote tmpRemote = strconv.FormatInt(time.Now().UnixNano(), 10) if err = gitrepo.GitRemoteAdd(ctx, headRepo, tmpRemote, baseRepo.RepoPath()); err != nil { diff --git a/services/repository/branch.go b/services/repository/branch.go index fd1e7d04149..142073eabe7 100644 --- a/services/repository/branch.go +++ b/services/repository/branch.go @@ -526,7 +526,6 @@ func UpdateBranch(ctx context.Context, repo *repo_model.Repository, gitRepo *git } pushOpts := git.PushOptions{ - Remote: repo.RepoPath(), Branch: fmt.Sprintf("%s:%s%s", newCommit.ID.String(), git.BranchPrefix, branchName), Env: repo_module.PushingEnvironment(doer, repo), Force: isForcePush || force, diff --git a/services/repository/repository.go b/services/repository/repository.go index a4d82140c6b..4d07cb0e38d 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -257,7 +257,7 @@ func CheckDaemonExportOK(ctx context.Context, repo *repo_model.Repository) error isPublic := !repo.IsPrivate && repo.Owner.Visibility == structs.VisibleTypePublic if !isPublic && isExist { - if err = gitrepo.RemoveRepoFile(ctx, repo, daemonExportFile); err != nil { + if err = gitrepo.RemoveRepoFileOrDir(ctx, repo, daemonExportFile); err != nil { log.Error("Failed to remove %s: %v", daemonExportFile, err) } } else if isPublic && !isExist { From 822ee60baea681f13f2d23bb9607ea679b0f779d Mon Sep 17 00:00:00 2001 From: a1012112796 <1012112796@qq.com> Date: Tue, 16 Dec 2025 04:03:52 +0800 Subject: [PATCH 13/28] fix webAuthn insecure error view (#36165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit as you seen, in cureent status `initUserAuthWebAuthn` will prcheck `window.isSecureContext`, if not ok, will hide the `passkey` btton and return directly. I think it's not right, first, not show any error message looks not a good ui, and it's looks will make an empty container was show if the registion button was disabled also (maybe f-i-x #36115), then initUserAuthWebAuthn has `window.isSecureContext` check also which looks duplcate ref: https://github.com/go-gitea/gitea/blob/26602fd2070886a1e7e0545f11f5541a38396003/web_src/js/features/user-auth-webauthn.ts#L202-L206 so I'd like move hideElem(elSignInPasskeyBtn); to `detectWebAuthnSupport` failed routs to make it simple and show insecure error corectly. ![联想截图_20251215184757](https://github.com/user-attachments/assets/0eff43a0-18a6-4978-aa27-b4574fcf2601) Signed-off-by: a1012112796 <1012112796@qq.com> Co-authored-by: Lunny Xiao --- web_src/js/features/user-auth-webauthn.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/web_src/js/features/user-auth-webauthn.ts b/web_src/js/features/user-auth-webauthn.ts index 50165c33770..6de84e94e0d 100644 --- a/web_src/js/features/user-auth-webauthn.ts +++ b/web_src/js/features/user-auth-webauthn.ts @@ -11,13 +11,8 @@ export async function initUserAuthWebAuthn() { return; } - // webauthn is only supported on secure contexts - if (!window.isSecureContext) { - if (elSignInPasskeyBtn) hideElem(elSignInPasskeyBtn); - return; - } - if (!detectWebAuthnSupport()) { + if (elSignInPasskeyBtn) hideElem(elSignInPasskeyBtn); return; } From 3bb077016004a008e237000e5ca30b27a633ac76 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Tue, 16 Dec 2025 03:16:58 +0100 Subject: [PATCH 14/28] fix nilnil in onedev downloader (#36154) onedev migration never used the migration transport, it now uses it the same way gogs one does --- cuts 3 nilnils for https://github.com/go-gitea/gitea/issues/36152 --- cmd/admin_auth_ldap_test.go | 4 ++-- services/migrations/onedev.go | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/admin_auth_ldap_test.go b/cmd/admin_auth_ldap_test.go index 2da7ebc573b..0e3e465e81e 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/services/migrations/onedev.go b/services/migrations/onedev.go index 9917bdae3c1..a30e36c8b83 100644 --- a/services/migrations/onedev.go +++ b/services/migrations/onedev.go @@ -77,19 +77,19 @@ type OneDevDownloader struct { } // NewOneDevDownloader creates a new downloader -func NewOneDevDownloader(_ context.Context, baseURL *url.URL, username, password, repoPath string) *OneDevDownloader { +func NewOneDevDownloader(ctx context.Context, baseURL *url.URL, username, password, repoPath string) *OneDevDownloader { + httpTransport := NewMigrationHTTPTransport() downloader := &OneDevDownloader{ baseURL: baseURL, repoPath: repoPath, client: &http.Client{ - Transport: &http.Transport{ - Proxy: func(req *http.Request) (*url.URL, error) { - if len(username) > 0 && len(password) > 0 { + Transport: roundTripperFunc( + func(req *http.Request) (*http.Response, error) { + if username != "" && password != "" { req.SetBasicAuth(username, password) } - return nil, nil - }, - }, + return httpTransport.RoundTrip(req.WithContext(ctx)) + }), }, userMap: make(map[int64]*onedevUser), milestoneMap: make(map[int64]string), From 84b74d7c3e2b5068a924f749b9ea5ca5cf4a1f14 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 16 Dec 2025 14:08:43 +0100 Subject: [PATCH 15/28] Enable `bodyclose` linter (#36168) Enabe [`bodyclose`](https://golangci-lint.run/docs/linters/configuration/#bodyclose). The only issue in non-test code (`services/migrations/dump.go`) was a false-positive and I think there are a number of undetected cases, but I guess it's still better than not having it. Co-authored-by: Lunny Xiao --- .golangci.yml | 1 + modules/indexer/issues/elasticsearch/elasticsearch_test.go | 6 +++++- modules/indexer/issues/meilisearch/meilisearch_test.go | 6 +++++- services/migrations/dump.go | 3 ++- services/migrations/gitea_downloader_test.go | 1 + services/migrations/gitlab_test.go | 1 + services/migrations/gogs_test.go | 1 + services/migrations/onedev_test.go | 1 + tests/integration/api_activitypub_person_test.go | 1 + tests/integration/api_packages_generic_test.go | 1 + 10 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 699881d7ed7..45083d5fd2a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -6,6 +6,7 @@ linters: default: none enable: - bidichk + - bodyclose - depguard - dupl - errcheck diff --git a/modules/indexer/issues/elasticsearch/elasticsearch_test.go b/modules/indexer/issues/elasticsearch/elasticsearch_test.go index dc329c07dd9..cb9ed3889d4 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_test.go b/modules/indexer/issues/meilisearch/meilisearch_test.go index a32cbdd6de1..81a27487bb8 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/services/migrations/dump.go b/services/migrations/dump.go index f9309e5e6a2..b1c5695854e 100644 --- a/services/migrations/dump.go +++ b/services/migrations/dump.go @@ -306,14 +306,15 @@ func (g *RepositoryDumper) CreateReleases(_ context.Context, releases ...*base.R if err != nil { return err } + defer rc.Close() } else { resp, err := http.Get(*asset.DownloadURL) if err != nil { return err } + defer resp.Body.Close() rc = resp.Body } - defer rc.Close() fw, err := os.Create(attachPath) if err != nil { diff --git a/services/migrations/gitea_downloader_test.go b/services/migrations/gitea_downloader_test.go index bb1760e8897..fb985ee9d5e 100644 --- a/services/migrations/gitea_downloader_test.go +++ b/services/migrations/gitea_downloader_test.go @@ -27,6 +27,7 @@ func TestGiteaDownloadRepo(t *testing.T) { if err != nil || resp.StatusCode != http.StatusOK { t.Skipf("Can't reach https://gitea.com, skipping %s", t.Name()) } + defer resp.Body.Close() ctx := t.Context() downloader, err := NewGiteaDownloader(ctx, "https://gitea.com", "gitea/test_repo", "", "", giteaToken) require.NoError(t, err, "NewGiteaDownloader error occur") diff --git a/services/migrations/gitlab_test.go b/services/migrations/gitlab_test.go index a9ae89a2a30..fef1053ec84 100644 --- a/services/migrations/gitlab_test.go +++ b/services/migrations/gitlab_test.go @@ -30,6 +30,7 @@ func TestGitlabDownloadRepo(t *testing.T) { if err != nil || resp.StatusCode != http.StatusOK { t.Skipf("Can't access test repo, skipping %s", t.Name()) } + defer resp.Body.Close() ctx := t.Context() downloader, err := NewGitlabDownloader(ctx, "https://gitlab.com", "gitea/test_repo", gitlabPersonalAccessToken) if err != nil { diff --git a/services/migrations/gogs_test.go b/services/migrations/gogs_test.go index 503b669f8ee..de7351b5bfe 100644 --- a/services/migrations/gogs_test.go +++ b/services/migrations/gogs_test.go @@ -27,6 +27,7 @@ func TestGogsDownloadRepo(t *testing.T) { t.Skipf("visit test repo failed, ignored") return } + defer resp.Body.Close() ctx := t.Context() downloader := NewGogsDownloader(ctx, "https://try.gogs.io", "", "", gogsPersonalAccessToken, "lunnytest", "TESTREPO") repo, err := downloader.GetRepoInfo(ctx) diff --git a/services/migrations/onedev_test.go b/services/migrations/onedev_test.go index 3319e198510..9e93272d385 100644 --- a/services/migrations/onedev_test.go +++ b/services/migrations/onedev_test.go @@ -19,6 +19,7 @@ func TestOneDevDownloadRepo(t *testing.T) { if err != nil || resp.StatusCode != http.StatusOK { t.Skipf("Can't access test repo, skipping %s", t.Name()) } + defer resp.Body.Close() u, _ := url.Parse("https://code.onedev.io") ctx := t.Context() diff --git a/tests/integration/api_activitypub_person_test.go b/tests/integration/api_activitypub_person_test.go index 6dd219ab2ea..3f1bd4823a3 100644 --- a/tests/integration/api_activitypub_person_test.go +++ b/tests/integration/api_activitypub_person_test.go @@ -78,6 +78,7 @@ func TestActivityPubPerson(t *testing.T) { // Signed request succeeds resp, err := c.Post([]byte{}, user2inboxurl) + defer resp.Body.Close() assert.NoError(t, err) assert.Equal(t, http.StatusNoContent, resp.StatusCode) diff --git a/tests/integration/api_packages_generic_test.go b/tests/integration/api_packages_generic_test.go index ae0506d48ba..5e368967eef 100644 --- a/tests/integration/api_packages_generic_test.go +++ b/tests/integration/api_packages_generic_test.go @@ -163,6 +163,7 @@ func TestPackageGeneric(t *testing.T) { resp2, err := (&http.Client{}).Get(location) assert.NoError(t, err) + defer resp2.Body.Close() assert.Equal(t, http.StatusOK, resp2.StatusCode, location) body, err := io.ReadAll(resp2.Body) From 4c67aac23b9cda6b0e4364c483b837d8860f6131 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Tue, 16 Dec 2025 16:14:14 -0800 Subject: [PATCH 16/28] Move blame to gitrepo (#36161) --- modules/git/blame.go | 218 ------------------ modules/gitrepo/blame.go | 208 +++++++++++++++++ modules/{git => gitrepo}/blame_sha256_test.go | 15 +- modules/{git => gitrepo}/blame_test.go | 14 +- routers/web/repo/blame.go | 19 +- 5 files changed, 235 insertions(+), 239 deletions(-) delete mode 100644 modules/git/blame.go rename modules/{git => gitrepo}/blame_sha256_test.go (88%) rename modules/{git => gitrepo}/blame_test.go (89%) diff --git a/modules/git/blame.go b/modules/git/blame.go deleted file mode 100644 index 601be96f05e..00000000000 --- 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/gitrepo/blame.go b/modules/gitrepo/blame.go index 3ce808d9b3c..bd64c748d44 100644 --- a/modules/gitrepo/blame.go +++ b/modules/gitrepo/blame.go @@ -4,9 +4,16 @@ package gitrepo import ( + "bufio" + "bytes" "context" + "io" + "os" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" ) func LineBlame(ctx context.Context, repo Repository, revision, file string, line uint) (string, error) { @@ -16,3 +23,204 @@ func LineBlame(ctx context.Context, repo Repository, revision, file string, line AddOptionValues("-p", revision). AddDashesAndList(file)) } + +// 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 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.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 git.ObjectFormat, repo Repository, commit *git.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 git.DefaultFeatures().CheckVersionAtLeast("2.23") && !bypassBlameIgnore { + ignoreRevsFileName, ignoreRevsFileCleanup, err = tryCreateBlameIgnoreRevsFile(commit) + if err != nil && !git.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 := RunCmd(ctx, repo, cmd.WithUseContextTimeout(true). + WithStdout(stdout). + WithStderr(&stderr), + ) + 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 *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 c0a97bed3bd..e92931d596a 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 809d6fbcf73..0307a5fd33c 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/routers/web/repo/blame.go b/routers/web/repo/blame.go index 6a4618a3c7d..0e95a9d023b 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/languagestats" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/highlight" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -99,7 +100,7 @@ func RefBlame(ctx *context.Context) { } type blameResult struct { - Parts []*git.BlamePart + Parts []*gitrepo.BlamePart UsesIgnoreRevs bool FaultyIgnoreRevsFile bool } @@ -107,7 +108,7 @@ type blameResult struct { func performBlame(ctx *context.Context, repo *repo_model.Repository, commit *git.Commit, file string, bypassBlameIgnore bool) (*blameResult, error) { objectFormat := ctx.Repo.GetObjectFormat() - blameReader, err := git.CreateBlameReader(ctx, objectFormat, repo.RepoPath(), commit, file, bypassBlameIgnore) + blameReader, err := gitrepo.CreateBlameReader(ctx, objectFormat, repo, commit, file, bypassBlameIgnore) if err != nil { return nil, err } @@ -123,7 +124,7 @@ func performBlame(ctx *context.Context, repo *repo_model.Repository, commit *git if len(r.Parts) == 0 && r.UsesIgnoreRevs { // try again without ignored revs - blameReader, err = git.CreateBlameReader(ctx, objectFormat, repo.RepoPath(), commit, file, true) + blameReader, err = gitrepo.CreateBlameReader(ctx, objectFormat, repo, commit, file, true) if err != nil { return nil, err } @@ -143,12 +144,12 @@ func performBlame(ctx *context.Context, repo *repo_model.Repository, commit *git return r, nil } -func fillBlameResult(br *git.BlameReader, r *blameResult) error { +func fillBlameResult(br *gitrepo.BlameReader, r *blameResult) error { r.UsesIgnoreRevs = br.UsesIgnoreRevs() - previousHelper := make(map[string]*git.BlamePart) + previousHelper := make(map[string]*gitrepo.BlamePart) - r.Parts = make([]*git.BlamePart, 0, 5) + r.Parts = make([]*gitrepo.BlamePart, 0, 5) for { blamePart, err := br.NextPart() if err != nil { @@ -173,7 +174,7 @@ func fillBlameResult(br *git.BlameReader, r *blameResult) error { return nil } -func processBlameParts(ctx *context.Context, blameParts []*git.BlamePart) map[string]*user_model.UserCommit { +func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) map[string]*user_model.UserCommit { // store commit data by SHA to look up avatar info etc commitNames := make(map[string]*user_model.UserCommit) // and as blameParts can reference the same commits multiple @@ -220,7 +221,7 @@ func processBlameParts(ctx *context.Context, blameParts []*git.BlamePart) map[st return commitNames } -func renderBlameFillFirstBlameRow(repoLink string, avatarUtils *templates.AvatarUtils, part *git.BlamePart, commit *user_model.UserCommit, br *blameRow) { +func renderBlameFillFirstBlameRow(repoLink string, avatarUtils *templates.AvatarUtils, part *gitrepo.BlamePart, commit *user_model.UserCommit, br *blameRow) { if commit.User != nil { br.Avatar = avatarUtils.Avatar(commit.User, 18) } else { @@ -234,7 +235,7 @@ func renderBlameFillFirstBlameRow(repoLink string, avatarUtils *templates.Avatar br.CommitSince = templates.TimeSince(commit.Author.When) } -func renderBlame(ctx *context.Context, blameParts []*git.BlamePart, commitNames map[string]*user_model.UserCommit) { +func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNames map[string]*user_model.UserCommit) { language, err := languagestats.GetFileLanguage(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath) if err != nil { log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err) From d19db18ee737f4801126bea4a717e050c31bed2f Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Wed, 17 Dec 2025 00:37:08 +0000 Subject: [PATCH 17/28] [skip ci] Updated translations via Crowdin --- options/locale/locale_zh-CN.ini | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini index 73e5b41f4e2..99fe329cbd1 100644 --- a/options/locale/locale_zh-CN.ini +++ b/options/locale/locale_zh-CN.ini @@ -215,6 +215,7 @@ more=更多的 buttons.heading.tooltip=添加标题 buttons.bold.tooltip=添加粗体文本 buttons.italic.tooltip=添加斜体文本 +buttons.strikethrough.tooltip=添加划线文本 buttons.quote.tooltip=引用文本 buttons.code.tooltip=添加代码 buttons.link.tooltip=添加链接 @@ -1355,8 +1356,11 @@ editor.this_file_locked=文件已锁定 editor.must_be_on_a_branch=您必须在某个分支上才能对此文件进行修改操作。 editor.fork_before_edit=您必须派生这个仓库才能对此文件进行修改操作。 editor.delete_this_file=删除文件 +editor.delete_this_directory=删除目录 editor.must_have_write_access=您必须具有写权限才能对此文件进行修改操作。 editor.file_delete_success=文件「%s」已删除。 +editor.directory_delete_success=目录「%s」已被删除。 +editor.delete_directory=删除目录「%s」 editor.name_your_file=命名文件… editor.filename_help=通过键入名称后跟斜线 ("/") 来添加目录。通过在输入框的开头键入「退格」来删除目录。 editor.or=或 From 14911d4293d9aac096c5a35bccd24a5bde65bd73 Mon Sep 17 00:00:00 2001 From: Sebastian Ertz Date: Wed, 17 Dec 2025 02:07:44 +0100 Subject: [PATCH 18/28] Update chroma to v2.21.0 (#36171) https://github.com/alecthomas/chroma/releases/tag/v2.21.0 --- go.mod | 2 +- go.sum | 8 ++++---- services/markup/renderhelper_codepreview_test.go | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 6806e76ffc5..130820a55c8 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/ProtonMail/go-crypto v1.3.0 github.com/PuerkitoBio/goquery v1.10.3 github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.0 - github.com/alecthomas/chroma/v2 v2.20.0 + github.com/alecthomas/chroma/v2 v2.21.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/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb diff --git a/go.sum b/go.sum index 86fe782ae7b..92013e195a9 100644 --- a/go.sum +++ b/go.sum @@ -98,11 +98,11 @@ github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.0/go.mod h1:1HmmMEVsr+0R 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.21.0 h1:YVW9qQAFnQm2OFPPFQg6G/TpMxKSsUr/KUPDi/BEqtY= +github.com/alecthomas/chroma/v2 v2.21.0/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/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-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= diff --git a/services/markup/renderhelper_codepreview_test.go b/services/markup/renderhelper_codepreview_test.go index 63e7f4d3bdf..6665f0d0098 100644 --- a/services/markup/renderhelper_codepreview_test.go +++ b/services/markup/renderhelper_codepreview_test.go @@ -40,7 +40,7 @@ func TestRenderHelperCodePreview(t *testing.T) {
# repo1
From 0e916c67cc19d27d8c9db8d95c7b548e7df2d9d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20G=C3=B3ra?= <108656216+dawidgora@users.noreply.github.com> Date: Wed, 17 Dec 2025 03:01:19 +0100 Subject: [PATCH 19/28] Automatic generation of release notes (#35977) Similar to GitHub, release notes can now be generated automatically. The generator is server-side and gathers the merged PRs and contributors and returns the corresponding Markdown text. --------- Co-authored-by: wxiaoguang --- models/issues/pull_list.go | 19 +- options/locale/locale_en-US.ini | 8 + package.json | 1 + pnpm-lock.yaml | 8 + routers/web/repo/release.go | 41 +++- routers/web/web.go | 1 + services/forms/repo_form.go | 13 ++ services/release/notes.go | 188 ++++++++++++++++ services/release/notes_test.go | 97 ++++++++ templates/repo/release/new.tmpl | 210 ++++++++++-------- web_src/css/repo/release-tag.css | 43 ---- .../js/features/comp/ComboMarkdownEditor.ts | 2 +- web_src/js/features/repo-issue-edit.ts | 4 +- web_src/js/features/repo-release.test.ts | 9 + web_src/js/features/repo-release.ts | 153 ++++++++++--- web_src/js/index-domready.ts | 3 +- web_src/js/modules/fomantic/form.ts | 2 +- 17 files changed, 629 insertions(+), 173 deletions(-) create mode 100644 services/release/notes.go create mode 100644 services/release/notes_test.go create mode 100644 web_src/js/features/repo-release.test.ts diff --git a/models/issues/pull_list.go b/models/issues/pull_list.go index 84f9f6166d1..19d727ecbdb 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/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 981d9de2f86..b740a400a47 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1294,6 +1294,7 @@ commit = Commit release = Release releases = Releases tag = Tag +git_tag = Git Tag released_this = released this tagged_this = tagged this file.title = %s at %s @@ -2755,6 +2756,13 @@ release.add_tag_msg = Use the title and content of release as tag message. release.add_tag = Create Tag Only release.releases_for = Releases for %s release.tags_for = Tags for %s +release.notes = Release notes +release.generate_notes = Generate release notes +release.generate_notes_desc = Automatically add merged pull requests and a changelog link for this release. +release.previous_tag = Previous tag +release.generate_notes_tag_not_found = Tag "%s" does not exist in this repository. +release.generate_notes_target_not_found = The release target "%s" cannot be found. +release.generate_notes_missing_tag = Enter a tag name to generate release notes. branch.name = Branch Name branch.already_exists = A branch named "%s" already exists. diff --git a/package.json b/package.json index 9f848ac3ce0..9566e42e88e 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "chartjs-adapter-dayjs-4": "1.0.4", "chartjs-plugin-zoom": "2.2.0", "clippie": "4.1.9", + "compare-versions": "6.1.1", "cropperjs": "1.6.2", "css-loader": "7.1.2", "dayjs": "1.11.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6c00cd0816..7d6b00f6754 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: clippie: specifier: 4.1.9 version: 4.1.9 + compare-versions: + specifier: 6.1.1 + version: 6.1.1 cropperjs: specifier: 1.6.2 version: 1.6.2 @@ -1870,6 +1873,9 @@ packages: resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} engines: {node: '>= 12.0.0'} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -5704,6 +5710,8 @@ snapshots: comment-parser@1.4.1: {} + compare-versions@6.1.1: {} + concat-map@0.0.1: {} confbox@0.1.8: {} diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 33e0dc58894..1b36dc4d442 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -392,6 +392,32 @@ func NewRelease(ctx *context.Context) { ctx.HTML(http.StatusOK, tplReleaseNew) } +// GenerateReleaseNotes builds release notes content for the given tag and base. +func GenerateReleaseNotes(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.GenerateReleaseNotesForm) + + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + content, err := release_service.GenerateReleaseNotes(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, release_service.GenerateReleaseNotesOptions{ + TagName: form.TagName, + TagTarget: form.TagTarget, + PreviousTag: form.PreviousTag, + }) + if err != nil { + if errTr := util.ErrorAsTranslatable(err); errTr != nil { + ctx.JSONError(errTr.Translate(ctx.Locale)) + } else { + ctx.ServerError("GenerateReleaseNotes", err) + } + return + } + + ctx.JSON(http.StatusOK, map[string]any{"content": content}) +} + // NewReleasePost response for creating a release func NewReleasePost(ctx *context.Context) { newReleaseCommon(ctx) @@ -520,11 +546,13 @@ func NewReleasePost(ctx *context.Context) { // EditRelease render release edit page func EditRelease(ctx *context.Context) { + newReleaseCommon(ctx) + if ctx.Written() { + return + } + ctx.Data["Title"] = ctx.Tr("repo.release.edit_release") - ctx.Data["PageIsReleaseList"] = true ctx.Data["PageIsEditRelease"] = true - ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled - upload.AddUploadContext(ctx, "release") tagName := ctx.PathParam("*") rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) @@ -565,8 +593,13 @@ func EditRelease(ctx *context.Context) { // EditReleasePost response for edit release func EditReleasePost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.EditReleaseForm) + + newReleaseCommon(ctx) + if ctx.Written() { + return + } + ctx.Data["Title"] = ctx.Tr("repo.release.edit_release") - ctx.Data["PageIsReleaseList"] = true ctx.Data["PageIsEditRelease"] = true tagName := ctx.PathParam("*") diff --git a/routers/web/web.go b/routers/web/web.go index 68907893215..86e51d607e2 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1403,6 +1403,7 @@ func registerWebRoutes(m *web.Router) { m.Group("/releases", func() { m.Get("/new", repo.NewRelease) m.Post("/new", web.Bind(forms.NewReleaseForm{}), repo.NewReleasePost) + m.Post("/generate-notes", web.Bind(forms.GenerateReleaseNotesForm{}), repo.GenerateReleaseNotes) m.Post("/delete", repo.DeleteRelease) m.Post("/attachments", repo.UploadReleaseAttachment) m.Post("/attachments/remove", repo.DeleteAttachment) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 6820521ba3d..2d33d2b42ba 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -638,6 +638,19 @@ func (f *NewReleaseForm) Validate(req *http.Request, errs binding.Errors) bindin return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } +// GenerateReleaseNotesForm retrieves release notes recommendations. +type GenerateReleaseNotesForm struct { + TagName string `form:"tag_name" binding:"Required;GitRefName;MaxSize(255)"` + TagTarget string `form:"tag_target" binding:"MaxSize(255)"` + PreviousTag string `form:"previous_tag" binding:"MaxSize(255)"` +} + +// Validate validates the fields +func (f *GenerateReleaseNotesForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + // EditReleaseForm form for changing release type EditReleaseForm struct { Title string `form:"title" binding:"Required;MaxSize(255)"` diff --git a/services/release/notes.go b/services/release/notes.go new file mode 100644 index 00000000000..c9dc75af70d --- /dev/null +++ b/services/release/notes.go @@ -0,0 +1,188 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package release + +import ( + "cmp" + "context" + "fmt" + "slices" + "strings" + + issues_model "code.gitea.io/gitea/models/issues" + 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/git" + "code.gitea.io/gitea/modules/util" +) + +// GenerateReleaseNotesOptions describes how to build release notes content. +type GenerateReleaseNotesOptions struct { + TagName string + TagTarget string + PreviousTag string +} + +// GenerateReleaseNotes builds the markdown snippet for release notes. +func GenerateReleaseNotes(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts GenerateReleaseNotesOptions) (string, error) { + headCommit, err := resolveHeadCommit(gitRepo, opts.TagName, opts.TagTarget) + if err != nil { + return "", err + } + + if opts.PreviousTag == "" { + // no previous tag, usually due to there is no tag in the repo, use the same content as GitHub + content := fmt.Sprintf("**Full Changelog**: %s/commits/tag/%s\n", repo.HTMLURL(ctx), util.PathEscapeSegments(opts.TagName)) + return content, nil + } + + baseCommit, err := gitRepo.GetCommit(opts.PreviousTag) + if err != nil { + return "", util.ErrorWrapTranslatable(util.ErrNotExist, "repo.release.generate_notes_tag_not_found", opts.TagName) + } + + commits, err := gitRepo.CommitsBetweenIDs(headCommit.ID.String(), baseCommit.ID.String()) + if err != nil { + return "", fmt.Errorf("CommitsBetweenIDs: %w", err) + } + + prs, err := collectPullRequestsFromCommits(ctx, repo.ID, commits) + if err != nil { + return "", err + } + + contributors, newContributors, err := collectContributors(ctx, repo.ID, prs) + if err != nil { + return "", err + } + + content := buildReleaseNotesContent(ctx, repo, opts.TagName, opts.PreviousTag, prs, contributors, newContributors) + return content, nil +} + +func resolveHeadCommit(gitRepo *git.Repository, tagName, tagTarget string) (*git.Commit, error) { + ref := tagName + if !gitRepo.IsTagExist(tagName) { + ref = tagTarget + } + + commit, err := gitRepo.GetCommit(ref) + if err != nil { + return nil, util.ErrorWrapTranslatable(util.ErrNotExist, "repo.release.generate_notes_target_not_found", ref) + } + return commit, nil +} + +func collectPullRequestsFromCommits(ctx context.Context, repoID int64, commits []*git.Commit) ([]*issues_model.PullRequest, error) { + prs := make([]*issues_model.PullRequest, 0, len(commits)) + + for _, commit := range commits { + pr, err := issues_model.GetPullRequestByMergedCommit(ctx, repoID, commit.ID.String()) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + continue + } + return nil, fmt.Errorf("GetPullRequestByMergedCommit: %w", err) + } + + if err = pr.LoadIssue(ctx); err != nil { + return nil, fmt.Errorf("LoadIssue: %w", err) + } + if err = pr.Issue.LoadAttributes(ctx); err != nil { + return nil, fmt.Errorf("LoadIssueAttributes: %w", err) + } + + prs = append(prs, pr) + } + + slices.SortFunc(prs, func(a, b *issues_model.PullRequest) int { + if cmpRes := cmp.Compare(b.MergedUnix, a.MergedUnix); cmpRes != 0 { + return cmpRes + } + return cmp.Compare(b.Issue.Index, a.Issue.Index) + }) + + return prs, nil +} + +func buildReleaseNotesContent(ctx context.Context, repo *repo_model.Repository, tagName, baseRef string, prs []*issues_model.PullRequest, contributors []*user_model.User, newContributors []*issues_model.PullRequest) string { + var builder strings.Builder + builder.WriteString("## What's Changed\n") + + for _, pr := range prs { + prURL := pr.Issue.HTMLURL(ctx) + builder.WriteString(fmt.Sprintf("* %s in [#%d](%s)\n", pr.Issue.Title, pr.Issue.Index, prURL)) + } + + builder.WriteString("\n") + + if len(contributors) > 0 { + builder.WriteString("## Contributors\n") + for _, contributor := range contributors { + builder.WriteString(fmt.Sprintf("* @%s\n", contributor.Name)) + } + builder.WriteString("\n") + } + + if len(newContributors) > 0 { + builder.WriteString("## New Contributors\n") + for _, contributor := range newContributors { + prURL := contributor.Issue.HTMLURL(ctx) + builder.WriteString(fmt.Sprintf("* @%s made their first contribution in [#%d](%s)\n", contributor.Issue.Poster.Name, contributor.Issue.Index, prURL)) + } + builder.WriteString("\n") + } + + builder.WriteString("**Full Changelog**: ") + compareURL := fmt.Sprintf("%s/compare/%s...%s", repo.HTMLURL(ctx), util.PathEscapeSegments(baseRef), util.PathEscapeSegments(tagName)) + builder.WriteString(fmt.Sprintf("[%s...%s](%s)", baseRef, tagName, compareURL)) + builder.WriteByte('\n') + return builder.String() +} + +func collectContributors(ctx context.Context, repoID int64, prs []*issues_model.PullRequest) ([]*user_model.User, []*issues_model.PullRequest, error) { + contributors := make([]*user_model.User, 0, len(prs)) + newContributors := make([]*issues_model.PullRequest, 0, len(prs)) + seenContributors := container.Set[int64]{} + seenNew := container.Set[int64]{} + + for _, pr := range prs { + poster := pr.Issue.Poster + posterID := poster.ID + + if posterID == 0 { + // Migrated PRs may not have a linked local user (PosterID == 0). Skip them for now. + continue + } + + if !seenContributors.Contains(posterID) { + contributors = append(contributors, poster) + seenContributors.Add(posterID) + } + + if seenNew.Contains(posterID) { + continue + } + + isFirst, err := isFirstContribution(ctx, repoID, posterID, pr) + if err != nil { + return nil, nil, err + } + if isFirst { + seenNew.Add(posterID) + newContributors = append(newContributors, pr) + } + } + + return contributors, newContributors, nil +} + +func isFirstContribution(ctx context.Context, repoID, posterID int64, pr *issues_model.PullRequest) (bool, error) { + hasMergedBefore, err := issues_model.HasMergedPullRequestInRepoBefore(ctx, repoID, posterID, pr.MergedUnix, pr.ID) + if err != nil { + return false, fmt.Errorf("check merged PRs for contributor: %w", err) + } + return !hasMergedBefore, nil +} diff --git a/services/release/notes_test.go b/services/release/notes_test.go new file mode 100644 index 00000000000..211c364debf --- /dev/null +++ b/services/release/notes_test.go @@ -0,0 +1,97 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package release + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + 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/gitrepo" + "code.gitea.io/gitea/modules/timeutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateReleaseNotes(t *testing.T) { + unittest.PrepareTestEnv(t) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + gitRepo, err := gitrepo.OpenRepository(t.Context(), repo) + require.NoError(t, err) + + t.Run("ChangeLogsWithPRs", func(t *testing.T) { + mergedCommit := "90c1019714259b24fb81711d4416ac0f18667dfa" + createMergedPullRequest(t, repo, mergedCommit, 5) + + content, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{ + TagName: "v1.2.0", + TagTarget: "DefaultBranch", + PreviousTag: "v1.1", + }) + require.NoError(t, err) + + assert.Equal(t, `## What's Changed +* Release notes test pull request in [#6](https://try.gitea.io/user2/repo1/pulls/6) + +## Contributors +* @user5 + +## New Contributors +* @user5 made their first contribution in [#6](https://try.gitea.io/user2/repo1/pulls/6) + +**Full Changelog**: [v1.1...v1.2.0](https://try.gitea.io/user2/repo1/compare/v1.1...v1.2.0) +`, content) + }) + + t.Run("NoPreviousTag", func(t *testing.T) { + content, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{ + TagName: "v1.2.0", + TagTarget: "DefaultBranch", + }) + require.NoError(t, err) + assert.Equal(t, "**Full Changelog**: https://try.gitea.io/user2/repo1/commits/tag/v1.2.0\n", content) + }) +} + +func createMergedPullRequest(t *testing.T, repo *repo_model.Repository, mergeCommit string, posterID int64) *issues_model.PullRequest { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: posterID}) + + issue := &issues_model.Issue{ + RepoID: repo.ID, + Repo: repo, + Poster: user, + PosterID: user.ID, + Title: "Release notes test pull request", + Content: "content", + } + + pr := &issues_model.PullRequest{ + HeadRepoID: repo.ID, + BaseRepoID: repo.ID, + HeadBranch: repo.DefaultBranch, + BaseBranch: repo.DefaultBranch, + Status: issues_model.PullRequestStatusMergeable, + Flow: issues_model.PullRequestFlowGithub, + } + + require.NoError(t, issues_model.NewPullRequest(t.Context(), repo, issue, nil, nil, pr)) + + pr.HasMerged = true + pr.MergedCommitID = mergeCommit + pr.MergedUnix = timeutil.TimeStampNow() + _, err := db.GetEngine(t.Context()). + ID(pr.ID). + Cols("has_merged", "merged_commit_id", "merged_unix"). + Update(pr) + require.NoError(t, err) + + require.NoError(t, pr.LoadIssue(t.Context())) + require.NoError(t, pr.Issue.LoadAttributes(t.Context())) + return pr +} diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl index 109a18fa0e2..44f496e73d0 100644 --- a/templates/repo/release/new.tmpl +++ b/templates/repo/release/new.tmpl @@ -12,17 +12,25 @@ {{end}} {{template "base/alert" .}} -
+ + {{.CsrfTokenHtml}} -
-
- {{if .PageIsEditRelease}} - {{.tag_name}}@{{.tag_target}} - {{else}} - - -
- @ +
+ + {{if .PageIsEditRelease}} + + + {{.tag_name}} @ {{.tag_target}} + {{else}} +
+ +
+ @
-
- {{ctx.Locale.Tr "repo.release.tag_helper"}} -
- {{end}} -
+
+
+ {{ctx.Locale.Tr "repo.release.tag_helper"}} +
+ {{end}}
-
-
- + +
+ + +
+ +
+ + +
+
+ {{template "shared/combomarkdowneditor" (dict + "MarkdownPreviewInRepo" $.Repository + "MarkdownPreviewMode" "comment" + "TextareaName" "content" + "TextareaContent" .content + "TextareaPlaceholder" (ctx.Locale.Tr "repo.release.message") + "DropzoneParentContainer" "form" + )}} +
+ + {{range .attachments}} +
+
+ + + {{.Size | FileSize}} + + {{svg "octicon-info"}} + +
+ + {{ctx.Locale.Tr "remove"}} +
+ {{end}} + {{if .IsAttachmentEnabled}}
- {{template "shared/combomarkdowneditor" (dict - "MarkdownPreviewInRepo" $.Repository - "MarkdownPreviewMode" "comment" - "TextareaName" "content" - "TextareaContent" .content - "TextareaPlaceholder" (ctx.Locale.Tr "repo.release.message") - "DropzoneParentContainer" "form" - )}} + {{template "repo/upload" .}}
- {{range .attachments}} -
-
- - - {{.Size | FileSize}} - - {{svg "octicon-info"}} - -
- - {{ctx.Locale.Tr "remove"}} - + {{end}} + + {{if not .PageIsEditRelease}} +
+
+ +
- {{end}} - {{if .IsAttachmentEnabled}} -
- {{template "repo/upload" .}} -
- {{end}} +
+ {{else}} + + {{end}} + +
+
+ + +
+
{{ctx.Locale.Tr "repo.release.prerelease_helper"}}
-
-
-
- {{if not .PageIsEditRelease}} -
-
- - -
-
+ +
+ {{if .PageIsEditRelease}} + + {{ctx.Locale.Tr "repo.release.cancel"}} + + + {{ctx.Locale.Tr "repo.release.delete_release"}} + + {{if .IsDraft}} + + {{else}} - + {{end}} -
-
- - -
-
- {{ctx.Locale.Tr "repo.release.prerelease_helper"}} -
-
- {{if .PageIsEditRelease}} - - {{ctx.Locale.Tr "repo.release.cancel"}} - - - {{ctx.Locale.Tr "repo.release.delete_release"}} - - {{if .IsDraft}} - - - {{else}} - - {{end}} - {{else}} - {{if .ShowCreateTagOnlyButton}} - - {{end}} - - - {{end}} -
-
+ {{else}} + {{if .ShowCreateTagOnlyButton}} + + {{end}} + + + {{end}}
-{{if .PageIsEditRelease}} -