Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andtechknowlogick e28e79a648 fix(acme): avoid nilnil lint finding
Co-authored-by: techknowlogick <164197+techknowlogick@users.noreply.github.com>
2026-10-01 12:39:42 +00:00
copilot-swe-agent[bot]andtechknowlogick 6a0e125018 feat(acme): support EAB credentials
Co-authored-by: techknowlogick <164197+techknowlogick@users.noreply.github.com>
2026-09-30 23:11:43 +00:00
copilot-swe-agent[bot] 1c19e2ba43 Initial plan 2026-09-30 23:02:33 +00:00
Zain Qureshiandsilverwind b0d6cc1d22 docs: correct three stale defaults in app.example.ini (#39500)
Three commented defaults in `custom/conf/app.example.ini` differ from
what Gitea actually uses, so the file says they default to something
else:

- `MINIMUM_KEY_SIZE_CHECK`: example `false`, code `true`
(`modules/setting/ssh.go:55`, read at `:152`).
- `SSH_SERVER_HOST_KEYS`: example lists `ssh/gitea.rsa, ssh/gogs.rsa`,
code also loads `ssh/gitea.ed25519` and `ssh/gitea.ecdsa`
(`modules/setting/ssh.go:57`).
- `[queue] DATADIR`: example `queues/`, code `queues/common`
(`modules/setting/queue.go:33`), which the comment above it already
states.

Co-authored-by: silverwind <me@silverwind.io>
2026-09-30 15:49:22 -07:00
silverwindandbircni 8936303510 fix: add missing checks to several API and web handlers (#39501)
Several handlers skipped checks that their sibling routes or settings already enforce. This brings them in line.

- Push mirror API honors `DISABLE_NEW_PUSH` and checks the caller's permission
- Media API serves small files with the usual content headers
- Issue attachment API ignores comment attachments
- Push-to-create respects `FORCE_PRIVATE`
- Profile feeds and follow actions respect `ENABLE_FEED` and owner visibility
- Tag delete route refuses release tags
- Refresh token grant only accepts refresh tokens
- Gitea migrations bound the source's page size

Co-authored-by: bircni <bircni@icloud.com>
2026-09-30 20:25:14 +02:00
Zettat123 3d085dbaf1 feat(admin): show and filter users by authentication source (#38900) 2026-09-30 11:28:35 -06:00
Nico Schlömerandwxiaoguang 9b2a3c267b fix(markup): don't escape ambiguous characters in MathML (#39493)
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-30 16:37:30 +00:00
silverwind 590d2984d9 fix(ui): ignore code line anchors below 1, show JS errors in vite dev mode (#39432) 2026-09-30 15:03:40 +00:00
Nico Schlömerandwxiaoguang 61e0343580 fix(markup): skip post-processing inside MathML (#39497)
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-30 16:41:41 +02:00
wxiaoguangandsilverwind 0b43bde974 fix: copy new access token to clipboard (#39496)
Co-authored-by: silverwind <me@silverwind.io>
2026-09-30 21:41:55 +08:00
wxiaoguang cc95f141f8 fix: npm route (#39488) 2026-09-30 19:49:50 +08:00
a25fbd43c4 docs: update app.example.ini for defaults changed in #39400 (#39456)
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-30 11:28:59 +00:00
JerryLienandwxiaoguang f365a6b9c8 fix(actions): keep runs order after auto refresh (#39479)
On a repository's Actions tab, runs are sorted newest first on initial
page load. After the first auto refresh (added in #38329, every 3
seconds while runs are active and every 12 seconds otherwise), the same
runs may appear in a different order and move again as their status
changes.

Example with four runs (Gitea 28.0.0, SQLite):

```
page load:     #10 success, #9 failure, #8 success, #7 running
after refresh: #8 success, #10 success, #9 failure, #7 running
```

To reproduce, open the Actions tab of a repository with runs in
different statuses and wait for an auto refresh. On SQLite, the runs may
be regrouped by status, with each group ordered oldest first.

`preparePartialRefreshRuns` reloads the runs currently shown on the page
using `GetRunsByRepoAndID`. That query has no `ORDER BY`, while the
initial page load uses `FindRunOptions.ToOrders` and sorts by index
descending.

With SQLite, the query planner used the `(repo_id, status)` index, so
the returned row order differed from the original page order. Since the
query has no explicit ordering, this behavior is database-dependent. I
have not tested MySQL or PostgreSQL.

This change orders `GetRunsByRepoAndID` by index descending, the same
order `FindRunOptions.ToOrders` uses for the initial page load. The
refresh only reloads the runs already on the page, so they come back in
the original order, with or without filters and on any page.

The other caller of `GetRunsByRepoAndID`, run approval, does not depend
on result ordering.

Testing:

- Added `TestPreparePartialRefreshRunsKeepsRequestedOrder`. Without the
fix, runs 794, 793, 792, 791 are returned as 791, 792, 794, 793; with
the fix, the test passes.
- `go test` passes for `./routers/web/repo/actions/`,
`./models/actions/` and `./services/actions/`.
- `go vet` and `golangci-lint v2.13.2` pass for the changed packages.
- Manually tested by building Gitea 28.0.0 with this patch and running
it on our SQLite instance. The runs list keeps its newest-first order
across auto refreshes. The official 28.0.0 binary reproduces the
reordering.

AI-assisted: drafted with Claude Code (claude-opus-5-5), reviewed by me.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-30 09:17:04 +02:00
bircni e0095af8c3 ci: Also release for other versions than 1 majors (#39475) 2026-09-29 21:47:12 +02:00
279 changed files with 13693 additions and 3649 deletions

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
# The full repository name
repo: go-gitea/gitea
# Service type (gitea or github)
service: github
# Base URL for Gitea instance if using gitea service type (optional)
# Default: https://gitea.com
base-url:
# Changelog groups and which labeled PRs to add to each group
groups:
-
name: BREAKING
labels:
- pr/breaking
-
name: SECURITY
labels:
- topic/security
-
name: FEATURES
labels:
- type/feature
-
name: ENHANCEMENTS
labels:
- type/enhancement
-
name: PERFORMANCE
labels:
- performance/memory
- performance/speed
- performance/bigrepo
- performance/cpu
-
name: BUGFIXES
labels:
- type/bug
-
name: TESTING
labels:
- type/testing
-
name: BUILD
labels:
- topic/build
- topic/code-linting
-
name: DOCS
labels:
- type/docs
-
name: MISC
default: true
# regex indicating which labels to skip for the changelog
skip-labels: skip-changelog|backport\/.+
-59
View File
@@ -1,59 +0,0 @@
name: Release
description: Track a Gitea release (for release managers).
title: "Release Gitea "
body:
- type: markdown
attributes:
value: |
Follow the [release management guide](https://github.com/go-gitea/gitea/blob/main/docs/release-management.md).
Set the issue title and milestone to the version being released. Replace the examples below and mark inapplicable tasks as such.
CI signs the tag, generates release notes, and publishes binaries and containers. Track verification here; no manual changelog PR or release upload is needed.
- type: input
id: version
attributes:
label: Version
placeholder: "28.0.1"
validations:
required: true
- type: input
id: branch
attributes:
label: Release branch
placeholder: "release/v28"
validations:
required: true
- type: textarea
id: checklist
attributes:
label: Release checklist
description: Keep workflow runs, release URLs, and follow-up PRs alongside the relevant tasks.
value: |
### Preparation
- [ ] Resolve release blockers and confirm milestone issues and PRs are resolved or deferred.
- [ ] Confirm required backports are merged and release branch CI passes.
- [ ] For a new release line, create the release branch and tag its fork point on main with the next version's -dev tag.
### Release
- [ ] Run https://github.com/go-gitea/gitea/actions/workflows/release-create-tag.yml on the release branch with the selected version and obtain maintainer approval.
- [ ] Confirm https://github.com/go-gitea/gitea/actions/workflows/release-tag-version.yml succeeds for the new tag (binaries and containers).
- [ ] Verify the public GitHub release, generated notes, binary attachments, and signatures at https://github.com/go-gitea/gitea/releases.
- [ ] Verify binaries and signatures at https://dl.gitea.com/gitea/ for this version.
- [ ] Verify versioned regular and rootless images on Docker Hub and GHCR, and smoke-test the release.
### Follow-up
- [ ] Verify the automated https://dl.gitea.com/gitea/version.json update, where applicable to this release line.
- [ ] Verify automated Helm chart and Terraform provider update PRs and follow up if needed.
- [ ] Check Homebrew and Snap availability; record any outstanding packaging follow-up.
- [ ] Confirm documentation reflects the release, where applicable.
- [ ] Confirm and merge the release blog post, if planned: https://gitea.com/gitea/blog.
- [ ] Announce the release in Discord #announcements.
validations:
required: true
- type: textarea
id: notes
attributes:
label: Blockers and notes
description: Link outstanding work or release-specific checks using full URLs. Do not include undisclosed security details.
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0 - uses: go-gitea/giteabot@4c9d4d3fd913b5c35f59dcc0b004a3d11d5b22bf # v1.0.7
with: with:
github_token: ${{ secrets.GITEABOT_TOKEN }} github_token: ${{ secrets.GITEABOT_TOKEN }}
gitea_fork: giteabot/gitea gitea_fork: giteabot/gitea
-19
View File
@@ -1,19 +0,0 @@
name: giteabot-review
# Relays PR reviews to giteabot.yml through its workflow_run trigger, because review
# runs on fork PRs get no secrets and a read-only token. The job itself does nothing.
on:
pull_request_review:
types:
- submitted
- edited
- dismissed
permissions: {}
jobs:
relay:
runs-on: ubuntu-latest
steps:
- run: "true"
+12 -9
View File
@@ -20,12 +20,13 @@ on:
- closed - closed
- review_requested - review_requested
- review_request_removed - review_request_removed
# Reviews arrive through giteabot-review because fork PR review runs get no secrets # Review events keep review-derived state such as lgtm labels and status checks
workflow_run: # in sync after approvals, edits, or dismissals.
workflows: pull_request_review:
- giteabot-review
types: types:
- requested - submitted
- edited
- dismissed
# Periodic maintenance is still useful as a backstop for queue cleanup and # Periodic maintenance is still useful as a backstop for queue cleanup and
# other housekeeping, even though main pushes now trigger it promptly. # other housekeeping, even though main pushes now trigger it promptly.
schedule: schedule:
@@ -42,12 +43,12 @@ on:
permissions: {} permissions: {}
concurrency: concurrency:
group: ${{ format('{0}-{1}', github.workflow, github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || github.event_name == 'workflow_run' && format('review-{0}', github.event.workflow_run.head_sha) || 'maintenance') }} group: ${{ format('{0}-{1}', github.workflow, (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && format('pr-{0}', github.event.pull_request.number) || 'maintenance') }}
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
giteabot: giteabot:
if: github.repository == 'go-gitea/gitea' && (github.event_name != 'workflow_run' || github.event.workflow_run.event == 'pull_request_review') if: github.repository == 'go-gitea/gitea'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
permissions: permissions:
@@ -56,7 +57,9 @@ jobs:
pull-requests: write pull-requests: write
statuses: write statuses: write
steps: steps:
- uses: go-gitea/giteabot@8033358e118c0bd97f5e9902dc5d6633eadc6aeb # v1.2.1 # pull_request_review runs without repository secrets on fork PRs, so fall
# back to the workflow token for the non-backport checks handled here.
- uses: go-gitea/giteabot@4c9d4d3fd913b5c35f59dcc0b004a3d11d5b22bf # v1.0.7
with: with:
github_token: ${{ secrets.GITEABOT_TOKEN }} github_token: ${{ secrets.GITEABOT_TOKEN || github.token }}
checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }} checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
-32
View File
@@ -1,32 +0,0 @@
name: release-create-tag
run-name: Release v${{ inputs.version }} from ${{ github.ref_name }}
on:
workflow_dispatch:
inputs:
version:
description: Version to release, for example 28.0.1
required: true
permissions: {}
jobs:
tag:
runs-on: ubuntu-latest
environment: release-signing
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
token: ${{ secrets.RELEASE_TOKEN }}
- uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
with:
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
git_user_signingkey: true
git_committer_email: teabot@gitea.io
- env:
VERSION: ${{ inputs.version }}
run: |
[[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
git tag -s -m "v$VERSION" "v$VERSION"
git push origin tag "v$VERSION"
@@ -37,15 +37,8 @@ jobs:
# retry snapcraft uploads which can be flaky # retry snapcraft uploads which can be flaky
- name: Publish snap - name: Publish snap
run: | run: |
# part-gitea-pull.sh decides what is built, see the comments there:
# * grade devel (main or a prerelease tag) goes to latest/edge
# * grade stable (a new release tag) goes to latest/stable, and to latest/candidate which marks it as released
channel=latest/edge
if unsquashfs -cat "$SNAP" meta/snap.yaml | grep -qx 'grade: stable'; then
channel=latest/stable,latest/candidate
fi
for attempt in 1 2 3 4 5; do for attempt in 1 2 3 4 5; do
snapcraft upload "$SNAP" --release "$channel" && exit 0 snapcraft upload "$SNAP" --release latest/edge && exit 0
echo "::warning::snap upload attempt $attempt failed, retrying in 15s" echo "::warning::snap upload attempt $attempt failed, retrying in 15s"
sleep 15 sleep 15
done done
+149
View File
@@ -0,0 +1,149 @@
name: release-tag-rc
on:
push:
tags:
- "v[0-9]*-rc*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions: {}
jobs:
binary:
runs-on: namespace-profile-gitea-release-binary
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
- run: git fetch --unshallow --quiet --tags --force
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
check-latest: true
cache: false
- uses: ./.github/actions/node-setup
- run: make deps-frontend deps-backend
- run: make release
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: import gpg key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
with:
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
- name: sign binaries
env:
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
GPG_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
run: |
for f in dist/release/*; do
cosign sign-blob "$f" --bundle "$f.sigstore.json" --yes
echo "$GPG_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u "$GPG_FINGERPRINT" --output "$f.asc" "$f"
done
# clean branch name to get the folder name in the object storage
- name: Get cleaned branch name
id: clean_name
env:
REF: ${{ github.ref }}
run: |
REF_NAME=$(echo "$REF" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\/v//' -e 's/release\/v//')
echo "Cleaned name is ${REF_NAME}"
echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT"
- name: upload binaries to cloudflare r2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
BRANCH: ${{ steps.clean_name.outputs.branch }}
run: |
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
- name: Install GH CLI
uses: dev-hanz-ops/install-gh-cli-action@6089bdde54118ad7ca3d22053eb2d69387fd2779 # v0.3.0
with:
gh-cli-version: 2.39.1
- name: create github release
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
gh release create "$TAG" --title "$TAG" --draft --notes-from-tag dist/release/*
container:
runs-on: namespace-profile-gitea-release-docker
permissions:
contents: read
packages: write # to publish to ghcr.io
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
- run: git fetch --unshallow --quiet --tags --force
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with:
cache-image: false
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
id: meta
with:
images: |-
gitea/gitea
ghcr.io/go-gitea/gitea
flavor: |
latest=false
# 1.2.3-rc0
tags: |
type=semver,pattern={{version}}
annotations: |
org.opencontainers.image.authors="maintainers@gitea.io"
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
id: meta_rootless
with:
images: |-
gitea/gitea
ghcr.io/go-gitea/gitea
# each tag below will have the suffix of -rootless
flavor: |
latest=false
suffix=-rootless
# 1.2.3-rc0
tags: |
type=semver,pattern={{version}}
annotations: |
org.opencontainers.image.authors="maintainers@gitea.io"
- name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR using PAT
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: build regular container image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/riscv64
push: true
tags: ${{ steps.meta.outputs.tags }}
annotations: ${{ steps.meta.outputs.annotations }}
- name: build rootless container image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/riscv64
push: true
file: Dockerfile.rootless
tags: ${{ steps.meta_rootless.outputs.tags }}
annotations: ${{ steps.meta_rootless.outputs.annotations }}
+3 -13
View File
@@ -4,7 +4,8 @@ on:
push: push:
tags: tags:
- "v[0-9]*" - "v[0-9]*"
- "!v[0-9]*-*" - "!v[0-9]*-rc*"
- "!v[0-9]*-dev"
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@@ -72,23 +73,12 @@ jobs:
uses: dev-hanz-ops/install-gh-cli-action@6089bdde54118ad7ca3d22053eb2d69387fd2779 # v0.3.0 uses: dev-hanz-ops/install-gh-cli-action@6089bdde54118ad7ca3d22053eb2d69387fd2779 # v0.3.0
with: with:
gh-cli-version: 2.39.1 gh-cli-version: 2.39.1
- id: range
run: |
previous=$(git tag --list --sort=-v:refname | grep -xE 'v[0-9]+\.[0-9]+\.[0-9]+' | grep -A1 -xF "$GITHUB_REF_NAME" | tail -1) # highest stable version below this one
echo "range=$previous..$GITHUB_SHA" >> "$GITHUB_OUTPUT"
- name: skip reverts and reverted commits in changelog
env:
RANGE: ${{ steps.range.outputs.range }}
run: ./tools/generate-cliffignore.sh "$RANGE" | sort -u > .cliffignore
- uses: orhun/git-cliff-action@a9a95522b26fe6403f7bb24031f21fb573d0f5ff # v4.9.1
with:
args: --tag ${{ github.ref_name }} ${{ steps.range.outputs.range }}
- name: create github release - name: create github release
env: env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
run: | run: |
gh release create "$TAG" --title "$TAG" --notes-file git-cliff/CHANGELOG.md dist/release/* gh release create "$TAG" --title "$TAG" --notes-from-tag dist/release/*
container: container:
runs-on: namespace-profile-gitea-release-docker runs-on: namespace-profile-gitea-release-docker
File diff suppressed because it is too large. Load diff
+6614
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -169,7 +169,7 @@ In the PR title, describe the problem you are fixing, not how you are fixing it.
Use the first comment as a summary of your PR. \ Use the first comment as a summary of your PR. \
In the PR summary, you can describe exactly how you are fixing this problem. In the PR summary, you can describe exactly how you are fixing this problem.
PR titles must follow the [Conventional Commits](https://www.conventionalcommits.org/) format, because PRs are squash-merged and the PR title becomes the resulting commit message and release notes entry: PR titles must follow the [Conventional Commits](https://www.conventionalcommits.org/) format, because PRs are squash-merged and the PR title becomes the resulting commit message:
```text ```text
type(scope)!: subject type(scope)!: subject
+2 -2
View File
@@ -6,7 +6,7 @@ SHASUM ?= shasum -a 256
AIR_PACKAGE ?= github.com/air-verse/air@v1.67.4 # renovate: datasource=go AIR_PACKAGE ?= github.com/air-verse/air@v1.67.4 # renovate: datasource=go
EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v4/cmd/editorconfig-checker@v4.0.2 # renovate: datasource=go EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v4/cmd/editorconfig-checker@v4.0.2 # renovate: datasource=go
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 # renovate: datasource=go GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 # renovate: datasource=go
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.17 # renovate: datasource=go GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.17 # renovate: datasource=go
MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # renovate: datasource=go MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # renovate: datasource=go
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.36.6 # renovate: datasource=go SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.36.6 # renovate: datasource=go
@@ -136,7 +136,7 @@ WEB_DIRS := web_src/js web_src/css
ESLINT_FILES := web_src/js tools *.ts tests/e2e ESLINT_FILES := web_src/js tools *.ts tests/e2e
STYLELINT_FILES := web_src/css web_src/js/components/*.vue STYLELINT_FILES := web_src/css web_src/js/components/*.vue
SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) templates options/locale/locale_en-US.json .github $(wildcard *.go *.md *.yml *.yaml *.toml) SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) templates options/locale/locale_en-US.json .github $(filter-out CHANGELOG.md, $(wildcard *.go *.md *.yml *.yaml *.toml))
EDITORCONFIG_FILES := templates .github/workflows options/locale/locale_en-US.json EDITORCONFIG_FILES := templates .github/workflows options/locale/locale_en-US.json
GO_SOURCES := $(wildcard *.go) GO_SOURCES := $(wildcard *.go)
+1 -1
View File
@@ -120,7 +120,7 @@ See [app.example.ini](https://github.com/go-gitea/gitea/blob/main/custom/conf/ap
**Where can I find the security patches?** **Where can I find the security patches?**
Check the [release notes](https://github.com/go-gitea/gitea/releases) and [security advisories](https://github.com/go-gitea/gitea/security/advisories) for security patches. In the [release log](https://github.com/go-gitea/gitea/releases) or the [change log](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md), search for the keyword `SECURITY` to find the security patches.
(more FAQs are listed in [FAQ documentation](https://docs.gitea.com/help/faq)) (more FAQs are listed in [FAQ documentation](https://docs.gitea.com/help/faq))
+1 -1
View File
@@ -125,7 +125,7 @@ Gitea 的发音是 [/ɡɪ’ti:/](https://youtu.be/EM71-2uDAoY),就像 "gi-tea
**在哪里可以找到安全补丁?** **在哪里可以找到安全补丁?**
在 [发布日志](https://github.com/go-gitea/gitea/releases) 中,搜索关键词 `SECURITY` 以找到安全补丁。 在 [发布日志](https://github.com/go-gitea/gitea/releases) 或 [变更日志](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md) 中,搜索关键词 `SECURITY` 以找到安全补丁。
## 许可证 ## 许可证
+1 -1
View File
@@ -125,7 +125,7 @@ Gitea 的發音是 [/ɡɪ’ti:/](https://youtu.be/EM71-2uDAoY),就像 "gi-tea
**在哪裡可以找到安全補丁?** **在哪裡可以找到安全補丁?**
在 [發佈日誌](https://github.com/go-gitea/gitea/releases) 中,搜索關鍵詞 `SECURITY` 以找到安全補丁。 在 [發佈日誌](https://github.com/go-gitea/gitea/releases) 或 [變更日誌](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md) 中,搜索關鍵詞 `SECURITY` 以找到安全補丁。
## 許可證 ## 許可證
-10
View File
@@ -1,10 +0,0 @@
[git]
commit_parsers = [
{ message = "^(chore|ci)(\\([\\w/.-]+\\))?!?: ", skip = true },
{ message = "^feat", group = "Features" },
{ message = "^enhance", group = "Enhancements" },
{ message = "^perf", group = "Performance" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^docs", group = "Documentation" },
{ message = ".*", group = "Miscellaneous" },
]
+21 -1
View File
@@ -21,8 +21,19 @@ import (
"gitea.dev/modules/util" "gitea.dev/modules/util"
"github.com/caddyserver/certmagic" "github.com/caddyserver/certmagic"
"github.com/mholt/acmez/v3/acme"
) )
func acmeExternalAccountBinding() (acme.EAB, bool, error) {
if setting.AcmeEABKID == "" && setting.AcmeEABHMAC == "" {
return acme.EAB{}, false, nil
}
if setting.AcmeEABKID == "" || setting.AcmeEABHMAC == "" {
return acme.EAB{}, false, errors.New("both ACME_EAB_KID and ACME_EAB_HMAC must be set")
}
return acme.EAB{KeyID: setting.AcmeEABKID, MACKey: setting.AcmeEABHMAC}, true, nil
}
func getCARoot(path string) (*x509.CertPool, error) { func getCARoot(path string) (*x509.CertPool, error) {
r, err := os.ReadFile(path) r, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -66,6 +77,14 @@ func runACME(listenAddr string, m http.Handler) error {
log.Warn("Failed to parse CA Root certificate, using default CA trust: %v", err) log.Warn("Failed to parse CA Root certificate, using default CA trust: %v", err)
} }
} }
externalAccountBinding, hasExternalAccount, err := acmeExternalAccountBinding()
if err != nil {
return err
}
var externalAccount *acme.EAB
if hasExternalAccount {
externalAccount = &externalAccountBinding
}
// FIXME: this path is not right, it uses "AppWorkPath" incorrectly, and writes the data into "AppWorkPath/https" // FIXME: this path is not right, it uses "AppWorkPath" incorrectly, and writes the data into "AppWorkPath/https"
// Ideally it should migrate to AppDataPath write to "AppDataPath/https" // Ideally it should migrate to AppDataPath write to "AppDataPath/https"
// And one more thing, no idea why we should set the global default variables here // And one more thing, no idea why we should set the global default variables here
@@ -84,6 +103,7 @@ func runACME(listenAddr string, m http.Handler) error {
Email: setting.AcmeEmail, Email: setting.AcmeEmail,
Agreed: setting.AcmeTOS, Agreed: setting.AcmeTOS,
Profile: setting.AcmeProfile, Profile: setting.AcmeProfile,
ExternalAccount: externalAccount,
DisableHTTPChallenge: !enableHTTPChallenge, DisableHTTPChallenge: !enableHTTPChallenge,
DisableTLSALPNChallenge: !enableTLSALPNChallenge, DisableTLSALPNChallenge: !enableTLSALPNChallenge,
ListenHost: setting.HTTPAddr, ListenHost: setting.HTTPAddr,
@@ -100,7 +120,7 @@ func runACME(listenAddr string, m http.Handler) error {
// takes HTTPS down on restart (https://github.com/go-gitea/gitea/issues/38519). // takes HTTPS down on restart (https://github.com/go-gitea/gitea/issues/38519).
// Prefer keeping the existing cert and retrying renewals asynchronously. // Prefer keeping the existing cert and retrying renewals asynchronously.
ctx := graceful.GetManager().ShutdownContext() ctx := graceful.GetManager().ShutdownContext()
err := magic.ManageSync(ctx, []string{setting.AppDomain}) err = magic.ManageSync(ctx, []string{setting.AppDomain})
if err != nil { if err != nil {
cert, cacheErr := magic.CacheManagedCertificate(ctx, setting.AppDomain) cert, cacheErr := magic.CacheManagedCertificate(ctx, setting.AppDomain)
if cacheErr != nil || cert.Expired() { if cacheErr != nil || cert.Expired() {
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"testing"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/mholt/acmez/v3/acme"
"github.com/stretchr/testify/assert"
)
func TestAcmeExternalAccountBinding(t *testing.T) {
t.Cleanup(test.MockVariableValue(&setting.AcmeEABKID, ""))
t.Cleanup(test.MockVariableValue(&setting.AcmeEABHMAC, ""))
binding, configured, err := acmeExternalAccountBinding()
assert.NoError(t, err)
assert.False(t, configured)
assert.Empty(t, binding)
setting.AcmeEABKID = "kid"
_, _, err = acmeExternalAccountBinding()
assert.ErrorContains(t, err, "both ACME_EAB_KID and ACME_EAB_HMAC must be set")
setting.AcmeEABHMAC = "hmac"
binding, configured, err = acmeExternalAccountBinding()
assert.NoError(t, err)
assert.True(t, configured)
assert.Equal(t, acme.EAB{KeyID: "kid", MACKey: "hmac"}, binding)
}
+1 -1
View File
@@ -87,7 +87,7 @@ echo "Checking currently installed version..."
current=$(giteacmd --version | cut -d ' ' -f 3) current=$(giteacmd --version | cut -d ' ' -f 3)
[[ "$current" == "$giteaversion" ]] && echo "$current is already installed, stopping." && exit 0 [[ "$current" == "$giteaversion" ]] && echo "$current is already installed, stopping." && exit 0
if [[ -z "${no_confirm:-}" ]]; then if [[ -z "${no_confirm:-}" ]]; then
echo "Make sure to read the changelog first: https://github.com/go-gitea/gitea/releases" echo "Make sure to read the changelog first: https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md"
echo "Are you ready to update Gitea from ${current} to ${giteaversion}? (y/N)" echo "Are you ready to update Gitea from ${current} to ${giteaversion}? (y/N)"
read -r confirm read -r confirm
[[ "$confirm" == "y" ]] || [[ "$confirm" == "Y" ]] || exit 1 [[ "$confirm" == "y" ]] || [[ "$confirm" == "Y" ]] || exit 1
+29 -29
View File
@@ -155,7 +155,7 @@
;; Username to use for the builtin SSH server. If blank, then it is the value of RUN_USER. ;; Username to use for the builtin SSH server. If blank, then it is the value of RUN_USER.
;BUILTIN_SSH_SERVER_USER = ;BUILTIN_SSH_SERVER_USER =
;; ;;
;; Domain name to be exposed in clone URL, defaults to DOMAIN or the domain part of ROOT_URL ;; Domain name to be exposed in clone URL, defaults to the domain part of ROOT_URL
;SSH_DOMAIN = ;SSH_DOMAIN =
;; ;;
;; Port number to be exposed in clone URL. ;; Port number to be exposed in clone URL.
@@ -198,7 +198,7 @@
;; For the built-in SSH server, choose the keypair to offer as the host key ;; For the built-in SSH server, choose the keypair to offer as the host key
;; The private key should be at SSH_SERVER_HOST_KEY and the public SSH_SERVER_HOST_KEY.pub ;; The private key should be at SSH_SERVER_HOST_KEY and the public SSH_SERVER_HOST_KEY.pub
;; relative paths are made absolute relative to the APP_DATA_PATH ;; relative paths are made absolute relative to the APP_DATA_PATH
;SSH_SERVER_HOST_KEYS=ssh/gitea.rsa, ssh/gogs.rsa ;SSH_SERVER_HOST_KEYS=ssh/gitea.rsa, ssh/gitea.ed25519, ssh/gitea.ecdsa, ssh/gogs.rsa
;; ;;
;; Enable SSH Authorized Key Backup when rewriting all keys, default is false ;; Enable SSH Authorized Key Backup when rewriting all keys, default is false
;SSH_AUTHORIZED_KEYS_BACKUP = false ;SSH_AUTHORIZED_KEYS_BACKUP = false
@@ -237,7 +237,7 @@
;SSH_PER_WRITE_PER_KB_TIMEOUT = 30s ;SSH_PER_WRITE_PER_KB_TIMEOUT = 30s
;; ;;
;; Indicate whether to check minimum key size with corresponding type ;; Indicate whether to check minimum key size with corresponding type
;MINIMUM_KEY_SIZE_CHECK = false ;MINIMUM_KEY_SIZE_CHECK = true
;; ;;
;; TLS Settings: Either ACME or manual ;; TLS Settings: Either ACME or manual
;; (Other common TLS configuration are found before) ;; (Other common TLS configuration are found before)
@@ -264,6 +264,11 @@
;; ACME profile to request from the CA (e.g. "shortlived" for raw-IP certificates) ;; ACME profile to request from the CA (e.g. "shortlived" for raw-IP certificates)
;ACME_PROFILE = ;ACME_PROFILE =
;; ;;
;; External account binding credentials; set both to enable EAB
;; ACME_EAB_HMAC should be a base64url-encoded MAC key
;ACME_EAB_KID =
;ACME_EAB_HMAC =
;;
;; ACME live directory (not to be confused with ACME directory URL: ACME_URL) ;; ACME live directory (not to be confused with ACME directory URL: ACME_URL)
;; (Refer to caddy's ACME manager https://github.com/caddyserver/certmagic) ;; (Refer to caddy's ACME manager https://github.com/caddyserver/certmagic)
;ACME_DIRECTORY = https ;ACME_DIRECTORY = https
@@ -536,7 +541,7 @@ INTERNAL_TOKEN =
;CONTENT_SECURITY_POLICY_GENERAL = ;CONTENT_SECURITY_POLICY_GENERAL =
;; ;;
;; Egress mode toggles between strictness of outgoing requests: ;; Egress mode toggles between strictness of outgoing requests:
;; Lax requires non-public targets (private, loopback, link-local, CGNAT and special-use ranges) to be allowed, it allows all public ones ;; Lax requires addresses to be allowed only if they are in private ranges, it allows all public ones
;; Strict requires an explicit allow of all addresses ;; Strict requires an explicit allow of all addresses
; EGRESS_MODE = lax ; EGRESS_MODE = lax
;; ;;
@@ -551,12 +556,10 @@ INTERNAL_TOKEN =
;; a bracketed set of ports and ranges, | separated: *.mydomain.com:[80|443|3000-3010] ;; a bracketed set of ports and ranges, | separated: *.mydomain.com:[80|443|3000-3010]
;; all ports: *.mydomain.com:* ;; all ports: *.mydomain.com:*
;; A portless entry covers all ports in Lax mode, only 80 and 443 in Strict mode ;; A portless entry covers all ports in Lax mode, only 80 and 443 in Strict mode
;; Port specs apply only where the list is consulted: in Lax mode that is non-public targets alone, ;; Port specs apply only where the list is consulted: in Lax mode that is private, loopback and CGNAT
;; public targets are allowed on every port whatever the list says. In Strict mode every ;; targets alone, public targets are allowed on every port whatever the list says. In Strict mode every
;; target is checked, so ports restrict public hosts too. ;; target is checked, so ports restrict public hosts too.
;; Non-public targets need an IP or built-in entry, a host name entry alone never covers them. ;; Reserved addresses like link-local and cloud metadata are denied
;; Reserved addresses (the IPv4-embedding NAT64, Teredo and 6to4 ranges, this-network, multicast and
;; broadcast) are denied whatever the list says. To reach them configure an HTTP proxy
;; This list is enforced on direct connections only. When an HTTP proxy is configured, restricting the proxied target is the proxy server's responsibility. ;; This list is enforced on direct connections only. When an HTTP proxy is configured, restricting the proxied target is the proxy server's responsibility.
;ALLOWED_HOST_LIST = ;ALLOWED_HOST_LIST =
@@ -638,8 +641,7 @@ ENABLED = true
;; * https://github.com/git-ecosystem/git-credential-manager ;; * https://github.com/git-ecosystem/git-credential-manager
;; * https://gitea.com/gitea/tea ;; * https://gitea.com/gitea/tea
;; * Gitea App (the official Gitea mobile app) ;; * Gitea App (the official Gitea mobile app)
;; * https://gitea.com/gitea/gitea-vscode ;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea, gitea-app
;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea, gitea-app, gitea-vscode
;; ;;
;; By default, OAuth2 applications can only use "http" and "https" as their redirect URI schemes. ;; By default, OAuth2 applications can only use "http" and "https" as their redirect URI schemes.
;; If you need to use other schemes (e.g. for desktop applications), you can specify them here as a comma-separated list. ;; If you need to use other schemes (e.g. for desktop applications), you can specify them here as a comma-separated list.
@@ -811,6 +813,7 @@ LEVEL = Info
;[git.config] ;[git.config]
;diff.algorithm = histogram ;diff.algorithm = histogram
;core.logAllRefUpdates = true ;core.logAllRefUpdates = true
;gc.reflogExpire = 90
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -838,7 +841,7 @@ LEVEL = Info
;EMAIL_DOMAIN_BLOCKLIST = ;EMAIL_DOMAIN_BLOCKLIST =
;; ;;
;; Disallow registration, only allow admins to create accounts. ;; Disallow registration, only allow admins to create accounts.
;DISABLE_REGISTRATION = false ;DISABLE_REGISTRATION = true
;; ;;
;; Allow registration only using gitea itself, it works only when DISABLE_REGISTRATION is false ;; Allow registration only using gitea itself, it works only when DISABLE_REGISTRATION is false
;ALLOW_ONLY_INTERNAL_REGISTRATION = false ;ALLOW_ONLY_INTERNAL_REGISTRATION = false
@@ -970,12 +973,11 @@ LEVEL = Info
;; Value for the domain part of the user's email address in the git log if user ;; Value for the domain part of the user's email address in the git log if user
;; has set KeepEmailPrivate to true. The user's email will be replaced with a ;; has set KeepEmailPrivate to true. The user's email will be replaced with a
;; concatenation of the user name in lower case, "@" and NO_REPLY_ADDRESS. Default ;; concatenation of the user name in lower case, "@" and NO_REPLY_ADDRESS. Default
;; value is "noreply." + DOMAIN, where DOMAIN resolves to the value from server.DOMAIN ;; value is "noreply." + the domain part of ROOT_URL
;; Note: do not use the <DOMAIN> notation below ;NO_REPLY_ADDRESS =
;NO_REPLY_ADDRESS = ; noreply.<DOMAIN>
;; ;;
;; Show Registration button ;; Show Registration button, defaults to true only if both DISABLE_REGISTRATION and ALLOW_ONLY_EXTERNAL_REGISTRATION are false
;SHOW_REGISTRATION_BUTTON = true ;SHOW_REGISTRATION_BUTTON = false
;; ;;
;; Show milestones dashboard page - a view of all the user's milestones ;; Show milestones dashboard page - a view of all the user's milestones
;SHOW_MILESTONES_DASHBOARD_PAGE = true ;SHOW_MILESTONES_DASHBOARD_PAGE = true
@@ -1098,9 +1100,8 @@ LEVEL = Info
;; Force ssh:// clone url instead of scp-style uri when default SSH port is used ;; Force ssh:// clone url instead of scp-style uri when default SSH port is used
;USE_COMPAT_SSH_URI = false ;USE_COMPAT_SSH_URI = false
;; ;;
;; Scheme of the returned URL for the "go get" response. ;; Value for the "go get" request returns the repository url as https or ssh, default is https
;; Default is "https" if DISABLE_HTTP_GIT=false or SSH is disabled, otherwise "ssh". ;GO_GET_CLONE_URL_PROTOCOL = https
;GO_GET_CLONE_URL_PROTOCOL =
;; ;;
;; Close issues as long as a commit on any branch marks it as fixed ;; Close issues as long as a commit on any branch marks it as fixed
;DEFAULT_CLOSE_ISSUES_VIA_COMMITS_IN_ANY_BRANCH = false ;DEFAULT_CLOSE_ISSUES_VIA_COMMITS_IN_ANY_BRANCH = false
@@ -1673,13 +1674,13 @@ LEVEL = Info
;; ;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ;;
;; General queue queue type, currently support: persistable-channel, channel, level, redis, dummy ;; General queue type, currently support: level, channel, redis, dummy
;; default to persistable-channel ;; default to level
;TYPE = persistable-channel ;TYPE = level
;; ;;
;; data-dir for storing persistable queues and level queues, individual queues will default to `queues/common` meaning the queue is shared. ;; data-dir for storing level queues, individual queues will default to `queues/common` meaning the queue is shared.
;; Relative paths will be made absolute against "APP_DATA_PATH" ;; Relative paths will be made absolute against "APP_DATA_PATH"
;DATADIR = queues/ ;DATADIR = queues/common
;; ;;
;; Default queue length before a channel queue will block ;; Default queue length before a channel queue will block
;LENGTH = 100000 ;LENGTH = 100000
@@ -1687,7 +1688,7 @@ LEVEL = Info
;; Batch size to send for batched queues ;; Batch size to send for batched queues
;BATCH_LENGTH = 20 ;BATCH_LENGTH = 20
;; ;;
;; When `TYPE` is `persistable-channel`, this provides a directory for the underlying leveldb ;; When `TYPE` is `level`, this provides a directory for the underlying leveldb
;; or additional options of the form `leveldb://path/to/db?option=value&....`, and will override `DATADIR`. ;; or additional options of the form `leveldb://path/to/db?option=value&....`, and will override `DATADIR`.
;; When `TYPE` is `redis` and this is left empty, it falls back to the shared [redis] CONN_STR. ;; When `TYPE` is `redis` and this is left empty, it falls back to the shared [redis] CONN_STR.
;CONN_STR = ;CONN_STR =
@@ -1755,7 +1756,6 @@ LEVEL = Info
;ENABLE_OPENID_SIGNIN = false ;ENABLE_OPENID_SIGNIN = false
;; ;;
;; Whether to allow registering via OpenID ;; Whether to allow registering via OpenID
;; Do not include to rely on rhw DISABLE_REGISTRATION setting
;;ENABLE_OPENID_SIGNUP = false ;;ENABLE_OPENID_SIGNUP = false
;; ;;
;; Allowed URI patterns (POSIX regexp). ;; Allowed URI patterns (POSIX regexp).
@@ -2018,8 +2018,8 @@ LEVEL = Info
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ;;
;; Either "memory", "file", "redis", "db", "mysql", "couchbase", "memcache" or "postgres" ;; Either "memory", "file", "redis", "db", "mysql", "couchbase", "memcache" or "postgres"
;; Default is "memory". "db" will reuse the configuration in [database] ;; Default is "file". "db" will reuse the configuration in [database]
;PROVIDER = memory ;PROVIDER = file
;; ;;
;; Provider config options ;; Provider config options
;; memory: doesn't have any config yet ;; memory: doesn't have any config yet
-4
View File
@@ -62,10 +62,6 @@ Operations that must roll back together should run inside `db.WithTx()` (or
Functions that participate in a transaction take a `context.Context` as their first Functions that participate in a transaction take a `context.Context` as their first
parameter so the transaction can be propagated. parameter so the transaction can be propagated.
PostgreSQL, MySQL and MSSQL (via `READ_COMMITTED_SNAPSHOT`) read the last committed
row version, so reads never wait for writers. Guard read-then-write logic with a
conditional `UPDATE` or a lock.
### XORM gotchas ### XORM gotchas
- Never call `x.Update(exemplar)` without an explicit `WHERE` clause — it updates - Never call `x.Update(exemplar)` without an explicit `WHERE` clause — it updates
+26 -10
View File
@@ -8,9 +8,10 @@ This document describes the release cycle, backports, versioning, and the releas
We backport PRs given the following circumstances: We backport PRs given the following circumstances:
1. We backport bug- and security-fixes and small enhancements. Large changes such as refactors are not backported. 1. Feature freeze is active, but `<version>-rc0` has not been released yet. Here, we backport as much as possible. <!-- TODO: Is that our definition with the new backport bot? -->
2. We never backport new features. 2. `rc0` has been released. Here, we only backport bug- and security-fixes, and small enhancements. Large PRs such as refactors are not backported anymore. <!-- TODO: Is that our definition with the new backport bot? -->
3. We never backport breaking changes except when 3. We never backport new features.
4. We never backport breaking changes except when
1. The breaking change has no effect on the vast majority of users 1. The breaking change has no effect on the vast majority of users
2. The component triggering the breaking change is marked as experimental 2. The component triggering the breaking change is marked as experimental
@@ -52,6 +53,7 @@ We use a release schedule so work, stabilization, and releases stay predictable.
### Cadence ### Cadence
- Aim for a major release about every three or four months. - Aim for a major release about every three or four months.
- Roughly two or three months of general development, then about one month of testing and polish called the **release freeze**.
- *Starting with v1.26 the release cycle will be more predictable and follow a more regular schedule.* - *Starting with v1.26 the release cycle will be more predictable and follow a more regular schedule.*
### Release schedule ### Release schedule
@@ -63,6 +65,16 @@ We will try to publish a new major version every three months:
- v1.28.0 in September 2026 - v1.28.0 in September 2026
- v1.29.0 in December 2026 - v1.29.0 in December 2026
#### How is the release handled?
- The release manager will tag the release candidate (e.g. `v1.26.0-rc0`) and publish it for testing in the **first week of the release month**.
- If there are no major issues, the release manager will check with the other maintainers and then tag the final release (e.g. `v1.26.0`) in the **one or two weeks following the release candidate**.
### Feature freeze
- Merge feature PRs before the freeze when you can.
- Feature PRs still open at the freeze move to the next milestone. Watch Discord for the freeze announcement.
- During the freeze, a **release branch** takes fixes backported from `main`. Release candidates ship for testing; the final release for that line is maintained from that branch.
### Patch releases ### Patch releases
During a cycle we may ship patch releases for an older line. For example, if the latest release is v1.2, we can still publish v1.1.1 after v1.1.0. During a cycle we may ship patch releases for an older line. For example, if the latest release is v1.2, we can still publish v1.1.1 after v1.1.0.
@@ -87,13 +99,17 @@ be reviewed by two maintainers and must pass the automatic tests.
## Releasing Gitea ## Releasing Gitea
Track each release using the [release issue template](https://github.com/go-gitea/gitea/issues/new?template=release.yaml). - Let MAJOR, MINOR and PATCH be Major, Minor and Patch version numbers, PATCH should be rc1, rc2, 0, 1, ...... MAJOR.MINOR will be kept the same as milestones on github or gitea in future.
- Before releasing, confirm all the version's milestone issues or PRs has been resolved. Then discuss the release on Discord channel #maintainers and get agreed with almost all the owners and mergers. Or you can declare the version and if nobody is against it in about several hours.
- Before releasing, confirm all the version's milestone issues or PRs have been resolved. Then discuss the release on Discord channel #maintainers and get agreed with almost all the owners and mergers. Or you can declare the version and if nobody is against it in about several hours. - If this is a big version first you have to create PR for changelog on branch `main` with PRs with label `changelog` and after it has been merged do following steps:
- When creating a release branch, tag its fork point on `main` as the next version's `-dev` tag, e.g. `v30.0.0-dev` for `release/v29`. - Create `-dev` tag as `git tag -s -F release.notes vMAJOR.MINOR.0-dev` and push the tag as `git push origin vMAJOR.MINOR.0-dev`.
- In the GitHub Actions tab, open the `release-create-tag` workflow, click "Run workflow", select the release branch and enter a version such as `28.0.1`. After maintainer approval, it pushes a signed tag and CI publishes the release with generated notes. - When CI has finished building tag then you have to create a new branch named `release/vMAJOR.MINOR`
- Optionally send a PR to the [blog repository](https://gitea.com/gitea/blog) announcing the release. - If it is bugfix version create PR for changelog on branch `release/vMAJOR.MINOR` and wait till it is reviewed and merged.
- Add a tag as `git tag -s -F release.notes vMAJOR.MINOR.PATCH`, release.notes file could be a temporary file to only include the changelog this version which you added to `CHANGELOG.md`.
- And then push the tag as `git push origin vMAJOR.MINOR.$`. CI will automatically create a release and upload all the compiled binary. (But currently it doesn't add the release notes automatically. Maybe we should fix that.)
- If needed send a frontport PR for the changelog to branch `main` and update the version in `docs/config.yaml` to refer to the new version.
- Send PR to [blog repository](https://gitea.com/gitea/blog) announcing the release.
- Verify all release assets were correctly published through CI on dl.gitea.com and GitHub releases. Once ACKed: - Verify all release assets were correctly published through CI on dl.gitea.com and GitHub releases. Once ACKed:
- verify the automated update of https://dl.gitea.com/gitea/version.json, where applicable to the release line - bump the version of https://dl.gitea.com/gitea/version.json
- merge the blog post PR - merge the blog post PR
- announce the release in discord `#announcements` - announce the release in discord `#announcements`
+5 -5
View File
@@ -2,7 +2,7 @@ module gitea.dev
go 1.27 go 1.27
toolchain go1.27.2 toolchain go1.27.1
require ( require (
connectrpc.com/connect v1.21.0 connectrpc.com/connect v1.21.0
@@ -68,6 +68,7 @@ require (
github.com/mattn/go-isatty v0.0.24 github.com/mattn/go-isatty v0.0.24
github.com/mattn/go-sqlite3 v1.14.52 github.com/mattn/go-sqlite3 v1.14.52
github.com/meilisearch/meilisearch-go v0.36.3 github.com/meilisearch/meilisearch-go v0.36.3
github.com/mholt/acmez/v3 v3.1.6
github.com/mholt/archives v0.1.5 github.com/mholt/archives v0.1.5
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/microsoft/go-mssqldb v1.11.2 github.com/microsoft/go-mssqldb v1.11.2
@@ -94,11 +95,11 @@ require (
github.com/yuin/goldmark v1.8.6 github.com/yuin/goldmark v1.8.6
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
gitlab.com/gitlab-org/api/client-go/v3 v3.13.0 gitlab.com/gitlab-org/api/client-go/v3 v3.13.0
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525 go.yaml.in/yaml/v4 v4.0.0-rc.6
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0 golang.org/x/image v0.46.0
golang.org/x/mod v0.41.0 golang.org/x/mod v0.41.0
golang.org/x/net v0.60.0 golang.org/x/net v0.59.0
golang.org/x/oauth2 v0.37.0 golang.org/x/oauth2 v0.37.0
golang.org/x/sync v0.23.0 golang.org/x/sync v0.23.0
golang.org/x/sys v0.48.0 golang.org/x/sys v0.48.0
@@ -109,7 +110,7 @@ require (
modernc.org/sqlite v1.59.0 modernc.org/sqlite v1.59.0
mvdan.cc/xurls/v2 v2.6.0 mvdan.cc/xurls/v2 v2.6.0
xorm.io/builder v0.3.13 xorm.io/builder v0.3.13
xorm.io/xorm v1.4.3 xorm.io/xorm v1.4.1
) )
require ( require (
@@ -199,7 +200,6 @@ require (
github.com/markbates/going v1.0.3 // indirect github.com/markbates/going v1.0.3 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mholt/acmez/v3 v3.1.6 // indirect
github.com/miekg/dns v1.1.72 // indirect github.com/miekg/dns v1.1.72 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/crc64nvme v1.1.1 // indirect
+6 -6
View File
@@ -672,8 +672,8 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525 h1:FtzHH3s777Nx50YcPAcodAPm1mNakc228nMYbr3f+90= go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -714,8 +714,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -862,5 +862,5 @@ pgregory.net/rapid v0.4.2 h1:lsi9jhvZTYvzVpeG93WWgimPRmiJQfGFRNTEZh1dtY0=
pgregory.net/rapid v0.4.2/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU= pgregory.net/rapid v0.4.2/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU=
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo= xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.4.3 h1:MwWFWzVr+/6D07qGCDhBAfABcuT0gvqY3XmTy1215BM= xorm.io/xorm v1.4.1 h1:m7QlNd0eBGb31IV4Q/ow0Du83rtdC1CiwlvJZGvYde8=
xorm.io/xorm v1.4.3/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q= xorm.io/xorm v1.4.1/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
-5
View File
@@ -33,7 +33,6 @@ import (
"gitea.dev/modelmigration/v1_8" "gitea.dev/modelmigration/v1_8"
"gitea.dev/modelmigration/v1_9" "gitea.dev/modelmigration/v1_9"
"gitea.dev/modelmigration/v28" "gitea.dev/modelmigration/v28"
"gitea.dev/modelmigration/v29"
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
@@ -430,10 +429,6 @@ func prepareMigrationTasks() []*migration {
newMigration(353, "Add audit event table", v28.AddAuditEventTable), newMigration(353, "Add audit event table", v28.AddAuditEventTable),
newMigration(354, "Add Actions job queue indexes", v28.AddActionQueueIndexes), newMigration(354, "Add Actions job queue indexes", v28.AddActionQueueIndexes),
newMigration(355, "Add AutoMerge merged_commit_id column", v28.AddAutoMergeMergedCommitID), newMigration(355, "Add AutoMerge merged_commit_id column", v28.AddAutoMergeMergedCommitID),
// Gitea 28.0.0 ends at migration ID number 355 (database version 356)
// HERE: 2 migrations from 29 are added since they don't change database structure
newMigration(356, "Add index on action_run commit_sha", v29.AddActionRunCommitSHAIndex),
newMigration(357, "Normalize legacy team authorize values", v29.NormalizeLegacyTeamAuthorize),
} }
return preparedMigrations return preparedMigrations
} }
-14
View File
@@ -1,14 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v29
import (
"testing"
"gitea.dev/modelmigration/migrationtest"
)
func TestMain(m *testing.M) {
migrationtest.MainTest(m)
}
-25
View File
@@ -1,25 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v29
import (
"context"
"gitea.dev/modelmigration/base"
"xorm.io/xorm"
)
// AddActionRunCommitSHAIndex indexes the runs lookup by commit, which the API `head_sha` filter uses.
func AddActionRunCommitSHAIndex(_ context.Context, x base.EngineMigration) error {
type ActionRun struct {
CommitSHA string `xorm:"index"`
}
_, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreDropIndices: true,
IgnoreConstrains: true,
}, new(ActionRun))
return err
}
-25
View File
@@ -1,25 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v29
import (
"context"
"gitea.dev/modelmigration/base"
)
// NormalizeLegacyTeamAuthorize sets leftover read/write authorize values to none.
// https://github.com/go-gitea/gitea/pull/34128 made non-admin teams use team_unit (authorize=none).
// authorize>=write now means blanket access on every unit; migrating legacy read/write
// to none preserves their existing team_unit-scoped access.
func NormalizeLegacyTeamAuthorize(_ context.Context, x base.EngineMigration) error {
// AccessModeNone=0, AccessModeRead=1, AccessModeWrite=2, AccessModeAdmin=3
_, err := x.Exec(`
UPDATE team SET authorize = 0
WHERE authorize > 0 AND authorize < 3
AND EXISTS (
SELECT 1 FROM team_unit WHERE team_unit.team_id = team.id
);`)
return err
}
-55
View File
@@ -1,55 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v29
import (
"testing"
"gitea.dev/modelmigration/migrationtest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNormalizeLegacyTeamAuthorize(t *testing.T) {
type Team struct {
ID int64 `xorm:"pk"`
Authorize int
}
type TeamUnit struct {
ID int64 `xorm:"pk"`
TeamID int64 `xorm:"INDEX"`
}
x, deferrable := migrationtest.PrepareTestEnv(t, 0, new(Team), new(TeamUnit))
defer deferrable()
if x == nil || t.Failed() {
return
}
_, err := x.Insert(
&Team{ID: 1, Authorize: 4},
&Team{ID: 2, Authorize: 3},
&Team{ID: 3, Authorize: 2},
&Team{ID: 4, Authorize: 1},
&Team{ID: 5, Authorize: 0},
&TeamUnit{TeamID: 3},
)
require.NoError(t, err)
require.NoError(t, NormalizeLegacyTeamAuthorize(t.Context(), x))
get := func(id int64) int {
tBean := &Team{ID: id}
has, err := x.Get(tBean)
require.NoError(t, err)
require.True(t, has)
return tBean.Authorize
}
assert.Equal(t, 4, get(1))
assert.Equal(t, 3, get(2))
assert.Equal(t, 0, get(3)) // has team unit, reset to none
assert.Equal(t, 1, get(4)) // no team unit, kept
assert.Equal(t, 0, get(5))
}
+3 -3
View File
@@ -39,9 +39,9 @@ type ActionRun struct {
TriggerUserID int64 `xorm:"index"` TriggerUserID int64 `xorm:"index"`
TriggerUser *user_model.User `xorm:"-"` TriggerUser *user_model.User `xorm:"-"`
ScheduleID int64 ScheduleID int64
Ref string `xorm:"index"` // the commit/tag/… that caused the run Ref string `xorm:"index"` // the commit/tag/… that caused the run
IsRefDeleted bool `xorm:"-"` IsRefDeleted bool `xorm:"-"`
CommitSHA string `xorm:"index"` CommitSHA string
IsForkPullRequest bool // If this is triggered by a PR from a forked repository or an untrusted user, we need to check if it is approved and limit permissions when running the workflow. IsForkPullRequest bool // If this is triggered by a PR from a forked repository or an untrusted user, we need to check if it is approved and limit permissions when running the workflow.
NeedApproval bool // may need approval if it's a fork pull request NeedApproval bool // may need approval if it's a fork pull request
ApprovedBy int64 `xorm:"index"` // who approved ApprovedBy int64 `xorm:"index"` // who approved
+11 -20
View File
@@ -853,34 +853,25 @@ func cancelReusableCaller(ctx context.Context, caller *ActionRunJob, force bool)
} }
// Cancel descendants deepest-first, then the caller: a caller's status is aggregated from its children, // Cancel descendants deepest-first, then the caller: a caller's status is aggregated from its children,
// so each child must be cancelled before its parent caller is re-read. // so each child must reach its final state before its parent caller is re-aggregated.
// A child's ID always exceeds its parent's, so descending ID is a valid deepest-first order. // A child's ID always exceeds its parent's, so descending ID is a valid deepest-first order.
descendants := CollectAllDescendantJobs(caller, attemptJobs) descendants := CollectAllDescendantJobs(caller, attemptJobs)
slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) }) slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) })
callersWithChildren := make(container.Set[int64])
for _, d := range descendants {
callersWithChildren.Add(d.ParentJobID)
}
for _, job := range append(descendants, caller) { for _, c := range descendants {
if !callersWithChildren.Contains(job.ID) { cancelled, err := cancelOneJob(ctx, c, force)
cancelled, err := cancelOneJob(ctx, job, force)
if err != nil {
return cancelledJobs, err
}
if cancelled != nil {
cancelledJobs = append(cancelledJobs, cancelled)
}
continue
}
// the job is a caller and its children's cascade already re-aggregated it
reloaded, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID)
if err != nil { if err != nil {
return cancelledJobs, err return cancelledJobs, err
} }
if reloaded.Status != job.Status { if cancelled != nil {
cancelledJobs = append(cancelledJobs, reloaded) cancelledJobs = append(cancelledJobs, cancelled)
} }
} }
if c, err := cancelOneJob(ctx, caller, force); err != nil {
return cancelledJobs, err
} else if c != nil {
cancelledJobs = append(cancelledJobs, c)
}
return cancelledJobs, nil return cancelledJobs, nil
} }
-70
View File
@@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"testing" "testing"
runnerv1 "gitea.dev/actionslib/runner/v1"
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
@@ -398,72 +397,3 @@ func TestForceCancelJobs(t *testing.T) {
assert.Equal(t, StatusCancelled, callerAfter.Status) assert.Equal(t, StatusCancelled, callerAfter.Status)
}) })
} }
func TestCancelJobs_CallerWaitsForCancellingChild(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
run := &ActionRun{
Title: "caller-cancelling-child",
RepoID: 4,
Index: 9811,
OwnerID: 1,
WorkflowID: "test.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
Status: StatusRunning,
}
require.NoError(t, db.Insert(ctx, run))
attempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: StatusRunning}
require.NoError(t, db.Insert(ctx, attempt))
run.LatestAttemptID = attempt.ID
require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id"))
newJob := func(name string, parentID int64, isCaller bool) *ActionRunJob {
job := &ActionRunJob{
RunID: run.ID,
RunAttemptID: attempt.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: name,
JobID: name,
Attempt: 1,
Status: StatusRunning,
ParentJobID: parentID,
IsReusableCaller: isCaller,
IsExpanded: isCaller,
}
require.NoError(t, db.Insert(ctx, job))
return job
}
outer := newJob("outer", 0, true)
inner := newJob("inner", outer.ID, true)
child := newJob("child", inner.ID, false)
runner := &ActionRunner{UUID: "caller-cancelling-child", Name: "caller-cancelling-child", HasCancellingSupport: true}
require.NoError(t, db.Insert(ctx, runner))
task := &ActionTask{JobID: child.ID, Attempt: 1, RunnerID: runner.ID, Status: StatusRunning, Started: timeutil.TimeStampNow(), RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA}
require.NoError(t, db.Insert(ctx, task))
child.TaskID = task.ID
_, err := UpdateRunJob(ctx, child, nil, "task_id")
require.NoError(t, err)
cancelled, err := CancelJobs(ctx, []*ActionRunJob{outer}, false)
require.NoError(t, err)
assert.Len(t, cancelled, 3)
for _, job := range []*ActionRunJob{outer, inner, child} {
assert.Equal(t, StatusCancelling, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status, job.Name)
}
_, err = UpdateTaskByState(ctx, runner.ID, &runnerv1.TaskState{Id: task.ID, Result: runnerv1.Result_RESULT_CANCELLED})
require.NoError(t, err)
for _, job := range []*ActionRunJob{outer, inner, child} {
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status, job.Name)
}
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID}).Status)
}
+1 -2
View File
@@ -11,7 +11,6 @@ import (
"gitea.dev/models/db" "gitea.dev/models/db"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
"gitea.dev/modules/util" "gitea.dev/modules/util"
webhook_module "gitea.dev/modules/webhook" webhook_module "gitea.dev/modules/webhook"
@@ -111,7 +110,7 @@ func CleanRepoScheduleTasks(ctx context.Context, repo *repo_model.Repository) ([
jobs, err := CancelPreviousJobs( jobs, err := CancelPreviousJobs(
ctx, ctx,
repo.ID, repo.ID,
git.RefNameFromBranch(repo.DefaultBranch).String(), repo.DefaultBranch,
"", "",
webhook_module.HookEventSchedule, webhook_module.HookEventSchedule,
) )
+6 -9
View File
@@ -236,13 +236,13 @@ func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, erro
func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) { func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) {
if step.Name != "" { if step.Name != "" {
name = step.Name // the step has an explicit name name = string(step.Name) // the step has an explicit name
} else { } else {
// for unnamed step, its "String()" method tries to get a display name by its "name", "uses", // for unnamed step, its "String()" method tries to get a display name by its "name", "uses",
// "run" or "id" (last fallback), we add the "Run " prefix for unnamed steps for better display // "run" or "id" (last fallback), we add the "Run " prefix for unnamed steps for better display
// for multi-line "run" scripts, only use the first line to match GitHub's behavior // for multi-line "run" scripts, only use the first line to match GitHub's behavior
// https://github.com/actions/runner/blob/66800900843747f37591b077091dd2c8cf2c1796/src/Runner.Worker/Handlers/ScriptHandler.cs#L45-L58 // https://github.com/actions/runner/blob/66800900843747f37591b077091dd2c8cf2c1796/src/Runner.Worker/Handlers/ScriptHandler.cs#L45-L58
runStr, _, _ := strings.Cut(strings.TrimSpace(step.Run), "\n") runStr, _, _ := strings.Cut(strings.TrimSpace(string(step.Run)), "\n")
name = "Run " + util.IfZero(strings.TrimSpace(runStr), step.String()) name = "Run " + util.IfZero(strings.TrimSpace(runStr), step.String())
} }
return util.EllipsisDisplayString(name, limit) // database column has a length limit return util.EllipsisDisplayString(name, limit) // database column has a length limit
@@ -298,12 +298,6 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil { if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
return nil, false, err return nil, false, err
} }
// A short page means no waiting jobs remain beyond it.
isLastPage := len(jobs) < pickTaskBatchSize
if !isLastPage {
last := jobs[len(jobs)-1] // read before a lost claim bumps Updated
cursorUpdated, cursorID = last.Updated, last.ID
}
for _, v := range jobs { for _, v := range jobs {
if !runner.CanMatchLabels(v.RunsOn) { if !runner.CanMatchLabels(v.RunsOn) {
@@ -319,9 +313,12 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
// Another runner claimed this job concurrently; try the next one. // Another runner claimed this job concurrently; try the next one.
} }
if isLastPage { // A short page means no waiting jobs remain beyond it.
if len(jobs) < pickTaskBatchSize {
return nil, false, nil return nil, false, nil
} }
last := jobs[len(jobs)-1]
cursorUpdated, cursorID = last.Updated, last.ID
} }
} }
+2 -2
View File
@@ -94,14 +94,14 @@ func TestMakeTaskStepDisplayName(t *testing.T) {
{ {
name: "very long name truncated", name: "very long name truncated",
jobStep: &jobparser.Step{ jobStep: &jobparser.Step{
Name: strings.Repeat("a", 300), Name: jobparser.BlockSafeString(strings.Repeat("a", 300)),
}, },
expected: strings.Repeat("a", 252) + "…", expected: strings.Repeat("a", 252) + "…",
}, },
{ {
name: "very long run truncated", name: "very long run truncated",
jobStep: &jobparser.Step{ jobStep: &jobparser.Step{
Run: strings.Repeat("a", 300), Run: jobparser.BlockSafeString(strings.Repeat("a", 300)),
}, },
expected: "Run " + strings.Repeat("a", 248) + "…", expected: "Run " + strings.Repeat("a", 248) + "…",
}, },
+1 -1
View File
@@ -199,7 +199,7 @@ func SearchPrincipalKey(ctx context.Context, principalKey string) (*PublicKey, e
// In the future, the existing principal keys should be migrated to use the fingerprint ("principal:{name}") instead of the content // In the future, the existing principal keys should be migrated to use the fingerprint ("principal:{name}") instead of the content
key := new(PublicKey) key := new(PublicKey)
has, err := db.GetEngine(ctx). has, err := db.GetEngine(ctx).
Where("content = ? AND type = ?", principalKey, KeyTypePrincipal). Where("content = ?", principalKey).
Get(key) Get(key)
if err != nil { if err != nil {
return nil, err return nil, err
-16
View File
@@ -12,13 +12,11 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.dev/models/db"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"github.com/42wim/sshsig" "github.com/42wim/sshsig"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func Test_SSHParsePublicKey(t *testing.T) { func Test_SSHParsePublicKey(t *testing.T) {
@@ -483,20 +481,6 @@ func TestPublicKeysAreExternallyManaged(t *testing.T) {
assert.False(t, externals[0]) assert.False(t, externals[0])
} }
func TestSearchPrincipalKeyIgnoresOtherKeyTypes(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
userKey := unittest.AssertExistsAndLoadBean(t, &PublicKey{ID: 1})
_, err := SearchPrincipalKey(t.Context(), userKey.Content)
assert.True(t, IsErrKeyNotExist(err))
principal := &PublicKey{OwnerID: 1, Name: userKey.Content, Content: userKey.Content, Type: KeyTypePrincipal}
require.NoError(t, db.Insert(t.Context(), principal))
key, err := SearchPrincipalKey(t.Context(), userKey.Content)
require.NoError(t, err)
assert.Equal(t, principal.ID, key.ID)
}
// TestCheckPublicKeyStringOversized tests if oversized SSH2 public key strings are rejected before triggering costly operations. // TestCheckPublicKeyStringOversized tests if oversized SSH2 public key strings are rejected before triggering costly operations.
func TestCheckPublicKeyStringOversized(t *testing.T) { func TestCheckPublicKeyStringOversized(t *testing.T) {
_, err := parseKeyString(strings.Repeat("a", maxKeyContentBytes+1)) _, err := parseKeyString(strings.Repeat("a", maxKeyContentBytes+1))
+3 -4
View File
@@ -44,10 +44,9 @@ func GetAuthTokenByID(ctx context.Context, id string) (*AuthToken, error) {
return at, nil return at, nil
} }
func UpdateAuthTokenByID(ctx context.Context, t *AuthToken, oldHash string) (bool, error) { func UpdateAuthTokenByID(ctx context.Context, t *AuthToken) error {
affected, err := db.GetEngine(ctx).ID(t.ID).Where("token_hash = ?", oldHash). _, err := db.GetEngine(ctx).ID(t.ID).Cols("token_hash", "expires_unix").Update(t)
Cols("token_hash", "expires_unix").Update(t) return err
return affected == 1, err
} }
func DeleteAuthTokenByID(ctx context.Context, id string) error { func DeleteAuthTokenByID(ctx context.Context, id string) error {
-16
View File
@@ -88,16 +88,6 @@ func BuiltinApplications() map[string]*BuiltinOAuth2Application {
DisplayName: "Gitea App", DisplayName: "Gitea App",
RedirectURIs: []string{"com.gitea.app://oauth/callback"}, RedirectURIs: []string{"com.gitea.app://oauth/callback"},
} }
m["75a9ec3e-42b5-40cb-9144-9ea96ecd56a6"] = &BuiltinOAuth2Application{
ConfigName: "gitea-vscode",
DisplayName: "gitea-vscode",
RedirectURIs: []string{
"http://127.0.0.1",
"https://127.0.0.1",
"vscode://gitea.gitea-for-vscode/auth/callback",
"vscode-insiders://gitea.gitea-for-vscode/auth/callback",
},
}
return m return m
} }
@@ -574,12 +564,6 @@ func (grant *OAuth2Grant) SetNonce(ctx context.Context, nonce string) error {
return nil return nil
} }
func UpdateGrantScope(ctx context.Context, grant *OAuth2Grant, newScope string) error {
grant.Scope = newScope
_, err := db.GetEngine(ctx).ID(grant.ID).Cols("scope").Update(grant)
return err
}
// GetOAuth2GrantByID returns the grant with the given ID // GetOAuth2GrantByID returns the grant with the given ID
func GetOAuth2GrantByID(ctx context.Context, id int64) (grant *OAuth2Grant, err error) { func GetOAuth2GrantByID(ctx context.Context, id int64) (grant *OAuth2Grant, err error) {
grant = new(OAuth2Grant) grant = new(OAuth2Grant)
-20
View File
@@ -5,9 +5,7 @@ package db
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"time"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
@@ -61,11 +59,6 @@ func InitEngine(ctx context.Context) error {
xe.SetMaxIdleConns(setting.Database.MaxIdleConns) xe.SetMaxIdleConns(setting.Database.MaxIdleConns)
xe.SetConnMaxLifetime(setting.Database.ConnMaxLifetime) xe.SetConnMaxLifetime(setting.Database.ConnMaxLifetime)
if setting.Database.Type.IsMySQL() {
// like PostgreSQL and MSSQL, avoids MariaDB snapshot isolation errors
xe.SetDefaultTxOptions(&sql.TxOptions{Isolation: sql.LevelReadCommitted})
}
if setting.Database.SlowQueryThreshold > 0 { if setting.Database.SlowQueryThreshold > 0 {
xe.AddHook(&EngineHook{ xe.AddHook(&EngineHook{
Threshold: setting.Database.SlowQueryThreshold, Threshold: setting.Database.SlowQueryThreshold,
@@ -110,10 +103,6 @@ func InitEngineWithMigration(ctx context.Context, migrateFunc func(context.Conte
preprocessDatabaseCollation(xormEngine) preprocessDatabaseCollation(xormEngine)
if setting.Database.Type.IsMSSQL() {
enableMSSQLReadCommittedSnapshot(ctx, xormEngine)
}
// We have to run migrateFunc here in case the user is re-running installation on a previously created DB. // We have to run migrateFunc here in case the user is re-running installation on a previously created DB.
// If we do not then table schemas will be changed and there will be conflicts when the migrations run properly. // If we do not then table schemas will be changed and there will be conflicts when the migrations run properly.
// //
@@ -136,12 +125,3 @@ func InitEngineWithMigration(ctx context.Context, migrateFunc func(context.Conte
return nil return nil
} }
// enableMSSQLReadCommittedSnapshot stops MSSQL reads waiting on writers, like PostgreSQL and MySQL
func enableMSSQLReadCommittedSnapshot(ctx context.Context, engine EngineMigration) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) // ALTER waits for all other connections to close
defer cancel()
if _, err := engine.Context(ctx).Exec("IF (SELECT is_read_committed_snapshot_on FROM sys.databases WHERE database_id = DB_ID()) = 0 ALTER DATABASE CURRENT SET READ_COMMITTED_SNAPSHOT ON"); err != nil {
log.Error("Unable to set READ_COMMITTED_SNAPSHOT=ON: %v", err)
}
}
+34 -37
View File
@@ -9,7 +9,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -36,11 +35,11 @@ import (
// CommitStatus holds a single Status of a single Commit // CommitStatus holds a single Status of a single Commit
type CommitStatus struct { type CommitStatus struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE(repo_sha_index)"`
SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"`
Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"`
RepoID int64 `xorm:"INDEX UNIQUE(repo_sha_index)"`
Repo *repo_model.Repository `xorm:"-"` Repo *repo_model.Repository `xorm:"-"`
State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"` State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"`
SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"`
// TargetURL points to the commit status page reported by a CI system // TargetURL points to the commit status page reported by a CI system
// If Gitea Actions is used, it is a relative link like "{RepoLink}/actions/runs/{RunID}/jobs{JobID}" // If Gitea Actions is used, it is a relative link like "{RepoLink}/actions/runs/{RunID}/jobs{JobID}"
@@ -312,17 +311,17 @@ func (opts *CommitStatusOptions) ToConds() builder.Cond {
func (opts *CommitStatusOptions) ToOrders() string { func (opts *CommitStatusOptions) ToOrders() string {
switch opts.SortType { switch opts.SortType {
case "oldest": case "oldest":
return "created_unix ASC, `index` ASC" return "created_unix ASC"
case "recentupdate": case "recentupdate":
return "updated_unix DESC, `index` DESC" return "updated_unix DESC"
case "leastupdate": case "leastupdate":
return "updated_unix ASC, `index` ASC" return "updated_unix ASC"
case "leastindex": case "leastindex":
return "`index` DESC" return "`index` DESC"
case "highestindex": case "highestindex":
return "`index` ASC" return "`index` ASC"
default: default:
return "created_unix DESC, `index` DESC" // timestamps have 1s resolution, `index` keeps paging stable return "created_unix DESC"
} }
} }
@@ -421,48 +420,46 @@ func GetLatestCommitStatusForPairs(ctx context.Context, repoSHAs []RepoSHA) (map
return repoStatuses, nil return repoStatuses, nil
} }
// GetLatestCommitStatusForRepoCommitIDs returns the commit statuses with a unique context for a given list of repo-sha pairs // GetLatestCommitStatusForRepoCommitIDs returns all statuses with a unique context for a given list of repo-sha pairs
// If the provided commit IDs are too many, only the first part and the last part of the commit IDs will be queried. func GetLatestCommitStatusForRepoCommitIDs(ctx context.Context, repoID int64, commitIDs []string) (map[string][]*CommitStatus, error) {
func GetLatestCommitStatusForRepoCommitIDs(ctx context.Context, repoID int64, allCommitIDs []string) (map[string][]*CommitStatus, error) { type result struct {
const maxCommitIDs = 500
const maxBatchSize = 200
queryCommitIDs := allCommitIDs
if len(allCommitIDs) > maxCommitIDs {
// The commit IDs are usually from "commits list" or "compare" page (create a PR or compare commits), nobody can read so many commits at once.
// The commit IDs are usually sorted by time, so we can take the first half and the last half of the commit IDs to get the latest statuses.
log.Warn("GetLatestCommitStatusForRepoCommitIDs: too many commit IDs (%d) for repo %d, truncating to %d", len(allCommitIDs), repoID, maxCommitIDs)
queryCommitIDs = allCommitIDs[:maxCommitIDs/2]
queryCommitIDs = append(queryCommitIDs, allCommitIDs[len(allCommitIDs)-maxCommitIDs/2:]...)
}
baseSql := func() db.Session {
return db.GetEngine(ctx).Table(&CommitStatus{}).Where("repo_id = ?", repoID)
}
type shaMaxIndexResult struct {
Index int64 Index int64
SHA string SHA string
} }
shaMaxIndexResults := make([]*shaMaxIndexResult, 0, len(allCommitIDs))
err := baseSql().And(builder.In("sha", queryCommitIDs)). getBase := func() db.Session {
Select("max(`index`) as `index`, sha"). return db.GetEngine(ctx).Table(&CommitStatus{}).Where("repo_id = ?", repoID)
GroupBy("context_hash, sha"). }
Find(&shaMaxIndexResults) results := make([]result, 0, len(commitIDs))
conds := make([]builder.Cond, 0, len(commitIDs))
for _, sha := range commitIDs {
conds = append(conds, builder.Eq{"sha": sha})
}
sess := getBase().And(builder.Or(conds...)).
Select("max( `index` ) as `index`, sha").
GroupBy("context_hash, sha").OrderBy("max( `index` ) desc")
err := sess.Find(&results)
if err != nil { if err != nil {
return nil, err return nil, err
} }
repoStatuses := make(map[string][]*CommitStatus) repoStatuses := make(map[string][]*CommitStatus)
for chunk := range slices.Chunk(shaMaxIndexResults, maxBatchSize) {
statuses := make([]*CommitStatus, 0, len(chunk)) if len(results) > 0 {
condIndexSha := make([]builder.Cond, 0, len(chunk)) statuses := make([]*CommitStatus, 0, len(results))
for _, res := range chunk {
condIndexSha = append(condIndexSha, builder.Eq{"`index`": res.Index, "sha": res.SHA}) conds = make([]builder.Cond, 0, len(results))
for _, result := range results {
conds = append(conds, builder.Eq{"`index`": result.Index, "sha": result.SHA})
} }
err = baseSql().And(builder.Or(condIndexSha...)).Find(&statuses) err = getBase().And(builder.Or(conds...)).Find(&statuses)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Group the statuses by commit
for _, status := range statuses { for _, status := range statuses {
repoStatuses[status.SHA] = append(repoStatuses[status.SHA], status) repoStatuses[status.SHA] = append(repoStatuses[status.SHA], status)
} }
+57 -60
View File
@@ -4,7 +4,6 @@
package git_test package git_test
import ( import (
"fmt"
"testing" "testing"
"time" "time"
@@ -23,71 +22,46 @@ func TestGetCommitStatuses(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
sha1 := "1234123412341234123412341234123412341234" // the mocked commit ID in test fixtures sha1 := "1234123412341234123412341234123412341234" // the mocked commit ID in test fixtures
t.Run("CommitStatusOptions", func(t *testing.T) { statuses, maxResults, err := db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{
statuses, maxResults, err := db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{ ListOptions: db.ListOptions{Page: 1, PageSize: 50},
ListOptions: db.ListOptions{Page: 1, PageSize: 50}, RepoID: repo1.ID,
RepoID: repo1.ID, SHA: sha1,
SHA: sha1,
})
assert.NoError(t, err)
assert.Equal(t, 5, int(maxResults))
var indexes []int64
for _, status := range statuses {
indexes = append(indexes, status.Index)
}
assert.Equal(t, []int64{5, 4, 3, 2, 1}, indexes)
assert.Equal(t, "deploy/awesomeness", statuses[0].Context)
assert.Equal(t, commitstatus.CommitStatusError, statuses[0].State)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[0].APIURL(t.Context()))
statuses, maxResults, err = db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{
ListOptions: db.ListOptions{Page: 2, PageSize: 50},
RepoID: repo1.ID,
SHA: sha1,
})
assert.NoError(t, err)
assert.Equal(t, 5, int(maxResults))
assert.Empty(t, statuses)
}) })
assert.NoError(t, err)
assert.Equal(t, 5, int(maxResults))
assert.Len(t, statuses, 5)
t.Run("GetCountLatestCommitStatus", func(t *testing.T) { assert.Equal(t, "ci/awesomeness", statuses[0].Context)
commitStatuses, err := git_model.GetLatestCommitStatus(t.Context(), repo1.ID, sha1, db.ListOptions{ assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
Page: 1, assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[0].APIURL(t.Context()))
PageSize: 2,
})
assert.NoError(t, err)
assert.Len(t, commitStatuses, 2)
assert.Equal(t, commitstatus.CommitStatusFailure, commitStatuses[0].State)
assert.Equal(t, "ci/awesomeness", commitStatuses[0].Context)
assert.Equal(t, commitstatus.CommitStatusError, commitStatuses[1].State)
assert.Equal(t, "deploy/awesomeness", commitStatuses[1].Context)
count, err := git_model.CountLatestCommitStatus(t.Context(), repo1.ID, sha1) assert.Equal(t, "cov/awesomeness", statuses[1].Context)
assert.NoError(t, err) assert.Equal(t, commitstatus.CommitStatusWarning, statuses[1].State)
assert.EqualValues(t, 3, count) assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[1].APIURL(t.Context()))
})
assert.Equal(t, "cov/awesomeness", statuses[2].Context)
t.Run("GetLatestCommitStatusForRepoCommitIDs", func(t *testing.T) { assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
commitIDs := []string{sha1} assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[2].APIURL(t.Context()))
// SQLite has a limit of 1000 for WHERE expression variables per query, use a larger slice to test
for i := range 2000 { assert.Equal(t, "ci/awesomeness", statuses[3].Context)
commitIDs = append(commitIDs, fmt.Sprintf("%040x", i+1)) assert.Equal(t, commitstatus.CommitStatusFailure, statuses[3].State)
} assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[3].APIURL(t.Context()))
statuses, err := git_model.GetLatestCommitStatusForRepoCommitIDs(t.Context(), repo1.ID, commitIDs)
assert.NoError(t, err) assert.Equal(t, "deploy/awesomeness", statuses[4].Context)
assert.Len(t, statuses[sha1], 3) assert.Equal(t, commitstatus.CommitStatusError, statuses[4].State)
latestIndexes := make(map[string]int64) assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[4].APIURL(t.Context()))
for _, status := range statuses[sha1] {
latestIndexes[status.Context] = status.Index statuses, maxResults, err = db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{
} ListOptions: db.ListOptions{Page: 2, PageSize: 50},
assert.Equal(t, map[string]int64{ RepoID: repo1.ID,
"ci/awesomeness": 4, SHA: sha1,
"cov/awesomeness": 3,
"deploy/awesomeness": 5,
}, latestIndexes)
}) })
assert.NoError(t, err)
assert.Equal(t, 5, int(maxResults))
assert.Empty(t, statuses)
} }
func Test_CalcCommitStatus(t *testing.T) { func Test_CalcCommitStatus(t *testing.T) {
@@ -286,3 +260,26 @@ func TestCommitStatusesApplyDoerPermission(t *testing.T) {
assert.Empty(t, statuses[1].TargetURL) assert.Empty(t, statuses[1].TargetURL)
assert.Equal(t, "https://mycicd.org/1", statuses[2].TargetURL) assert.Equal(t, "https://mycicd.org/1", statuses[2].TargetURL)
} }
func TestGetCountLatestCommitStatus(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
sha1 := "1234123412341234123412341234123412341234" // the mocked commit ID in test fixtures
commitStatuses, err := git_model.GetLatestCommitStatus(t.Context(), repo1.ID, sha1, db.ListOptions{
Page: 1,
PageSize: 2,
})
assert.NoError(t, err)
assert.Len(t, commitStatuses, 2)
assert.Equal(t, commitstatus.CommitStatusFailure, commitStatuses[0].State)
assert.Equal(t, "ci/awesomeness", commitStatuses[0].Context)
assert.Equal(t, commitstatus.CommitStatusError, commitStatuses[1].State)
assert.Equal(t, "deploy/awesomeness", commitStatuses[1].Context)
count, err := git_model.CountLatestCommitStatus(t.Context(), repo1.ID, sha1)
assert.NoError(t, err)
assert.EqualValues(t, 3, count)
}
+19 -11
View File
@@ -99,8 +99,8 @@ func (protectBranch *ProtectedBranch) loadGlob() {
var err error var err error
protectBranch.globRule, err = glob.Compile(protectBranch.RuleName, '/') protectBranch.globRule, err = glob.Compile(protectBranch.RuleName, '/')
if err != nil { if err != nil {
log.Debug("Invalid glob rule for ProtectedBranch[%d]: %s %v", protectBranch.ID, protectBranch.RuleName, err) log.Warn("Invalid glob rule for ProtectedBranch[%d]: %s %v", protectBranch.ID, protectBranch.RuleName, err)
protectBranch.globRule, _ = glob.Compile(glob.QuoteMeta(protectBranch.RuleName), '/') protectBranch.globRule = glob.MustCompile(glob.QuoteMeta(protectBranch.RuleName), '/')
} }
} }
@@ -110,9 +110,7 @@ func (protectBranch *ProtectedBranch) Match(branchName string) bool {
if protectBranch.isPlainName { if protectBranch.isPlainName {
return strings.EqualFold(protectBranch.RuleName, branchName) return strings.EqualFold(protectBranch.RuleName, branchName)
} }
if protectBranch.globRule == nil {
return false // in case of invalid glob rule, we don't match anything
}
return protectBranch.globRule.Match(branchName) return protectBranch.globRule.Match(branchName)
} }
@@ -125,13 +123,23 @@ func (protectBranch *ProtectedBranch) LoadRepo(ctx context.Context) (err error)
} }
// CanUserPush returns if some user could push to this protected branch // CanUserPush returns if some user could push to this protected branch
func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *user_model.User, permissionInRepo access_model.Permission) bool { func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *user_model.User) bool {
if !protectBranch.CanPush { if !protectBranch.CanPush {
return false return false
} }
if !protectBranch.EnableWhitelist { if !protectBranch.EnableWhitelist {
return permissionInRepo.CanWrite(unit.TypeCode) if err := protectBranch.LoadRepo(ctx); err != nil {
log.Error("LoadRepo: %v", err)
return false
}
writeAccess, err := access_model.HasAccessUnit(ctx, user, protectBranch.Repo, unit.TypeCode, perm.AccessModeWrite)
if err != nil {
log.Error("HasAccessUnit: %v", err)
return false
}
return writeAccess
} }
if slices.Contains(protectBranch.WhitelistUserIDs, user.ID) { if slices.Contains(protectBranch.WhitelistUserIDs, user.ID) {
@@ -152,17 +160,17 @@ func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *use
// CanUserForcePush returns if some user could force push to this protected branch // CanUserForcePush returns if some user could force push to this protected branch
// Since force-push extends normal push, we also check if user has regular push access // Since force-push extends normal push, we also check if user has regular push access
func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user *user_model.User, permissionInRepo access_model.Permission) bool { func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user *user_model.User) bool {
if !protectBranch.CanForcePush { if !protectBranch.CanForcePush {
return false return false
} }
if !protectBranch.EnableForcePushAllowlist { if !protectBranch.EnableForcePushAllowlist {
return protectBranch.CanUserPush(ctx, user, permissionInRepo) return protectBranch.CanUserPush(ctx, user)
} }
if slices.Contains(protectBranch.ForcePushAllowlistUserIDs, user.ID) { if slices.Contains(protectBranch.ForcePushAllowlistUserIDs, user.ID) {
return protectBranch.CanUserPush(ctx, user, permissionInRepo) return protectBranch.CanUserPush(ctx, user)
} }
if len(protectBranch.ForcePushAllowlistTeamIDs) == 0 { if len(protectBranch.ForcePushAllowlistTeamIDs) == 0 {
@@ -174,7 +182,7 @@ func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user
log.Error("IsUserInTeams: %v", err) log.Error("IsUserInTeams: %v", err)
return false return false
} }
return in && protectBranch.CanUserPush(ctx, user, permissionInRepo) return in && protectBranch.CanUserPush(ctx, user)
} }
// IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch // IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch
+6 -12
View File
@@ -9,7 +9,6 @@ import (
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/modules/glob" "gitea.dev/modules/glob"
"gitea.dev/modules/log"
"gitea.dev/modules/optional" "gitea.dev/modules/optional"
) )
@@ -56,17 +55,10 @@ func FindRepoProtectedBranchRules(ctx context.Context, repoID int64) (ProtectedB
// FindAllMatchedBranches find all matched branches // FindAllMatchedBranches find all matched branches
func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string) ([]string, error) { func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string) ([]string, error) {
results := make([]string, 0, 10) results := make([]string, 0, 10)
rule, err := glob.Compile(ruleName)
if err != nil {
log.Debug("Failed to compile rule %s: %v", ruleName, err)
return results, nil
}
const pageSize = 100
for page := 1; ; page++ { for page := 1; ; page++ {
branchNames, err := FindBranchNames(ctx, FindBranchOptions{ brancheNames, err := FindBranchNames(ctx, FindBranchOptions{
ListOptions: db.ListOptions{ ListOptions: db.ListOptions{
PageSize: pageSize, PageSize: 100,
Page: page, Page: page,
}, },
RepoID: repoID, RepoID: repoID,
@@ -75,12 +67,14 @@ func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, branch := range branchNames { rule := glob.MustCompile(ruleName)
for _, branch := range brancheNames {
if rule.Match(branch) { if rule.Match(branch) {
results = append(results, branch) results = append(results, branch)
} }
} }
if len(branchNames) < pageSize { if len(brancheNames) < 100 {
break break
} }
} }
+1 -1
View File
@@ -81,7 +81,7 @@ type Issue struct {
PullRequest *PullRequest `xorm:"-"` PullRequest *PullRequest `xorm:"-"`
NumComments int NumComments int
// TODO: ISSUE-SIDEBAR-REF-SELECTOR: see "repo/issue/branch_selector_field.tmpl" // TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl"
Ref string Ref string
PinOrder int `xorm:"-"` // 0 means not loaded, -1 means loaded but not pinned PinOrder int `xorm:"-"` // 0 means not loaded, -1 means loaded but not pinned
+1 -1
View File
@@ -477,7 +477,7 @@ func applySubscribedCondition(sess db.Session, subscriberID int64) {
Select("repo_id"). Select("repo_id").
From("watch"). From("watch").
Where(builder.And(builder.Eq{"user_id": subscriberID}, Where(builder.And(builder.Eq{"user_id": subscriberID},
builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto, repo_model.WatchModeCustom))), builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto))),
), ),
), ),
) )
+20 -16
View File
@@ -269,23 +269,27 @@ func UpdateLabel(ctx context.Context, l *Label) error {
return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "exclusive_order", "archived_unix") return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "exclusive_order", "archived_unix")
} }
// DeleteRepoLabel deletes a repository label. // DeleteLabel delete a label
func DeleteRepoLabel(ctx context.Context, repoID, labelID int64) error { func DeleteLabel(ctx context.Context, id, labelID int64) error {
return deleteLabel(ctx, labelID, builder.Eq{"repo_id": repoID, "org_id": 0}) l, err := GetLabelByID(ctx, labelID)
} if err != nil {
if IsErrLabelNotExist(err) {
// DeleteOrgLabel deletes an organization label. return nil
func DeleteOrgLabel(ctx context.Context, orgID, labelID int64) error {
return deleteLabel(ctx, labelID, builder.Eq{"org_id": orgID, "repo_id": 0})
}
func deleteLabel(ctx context.Context, labelID int64, ownerCond builder.Cond) error {
return db.WithTx(ctx, func(ctx context.Context) error {
deleted, err := db.GetEngine(ctx).ID(labelID).Where(ownerCond).Delete(new(Label))
if err != nil || deleted == 0 {
return err
} }
if _, err = db.GetEngine(ctx). return err
}
return db.WithTx(ctx, func(ctx context.Context) error {
if l.BelongsToOrg() && l.OrgID != id {
return nil
}
if l.BelongsToRepo() && l.RepoID != id {
return nil
}
if _, err = db.DeleteByID[Label](ctx, labelID); err != nil {
return err
} else if _, err = db.GetEngine(ctx).
Where("label_id = ?", labelID). Where("label_id = ?", labelID).
Delete(new(IssueLabel)); err != nil { Delete(new(IssueLabel)); err != nil {
return err return err
+3 -19
View File
@@ -320,29 +320,13 @@ func TestUpdateLabel(t *testing.T) {
func TestDeleteLabel(t *testing.T) { func TestDeleteLabel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}) label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
orgLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 3}) assert.NoError(t, issues_model.DeleteLabel(t.Context(), label.RepoID, label.ID))
repoLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 10, RepoID: orgLabel.OrgID})
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), repoLabel.RepoID, repoLabel.ID))
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: repoLabel.ID})
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), orgLabel.OrgID, orgLabel.ID))
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: orgLabel.ID})
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), unittest.NonexistentID, orgLabel.ID))
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: orgLabel.ID})
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), orgLabel.OrgID, orgLabel.ID))
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: orgLabel.ID})
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), unittest.NonexistentID, label.ID))
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: label.ID})
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), label.RepoID, label.ID))
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID, RepoID: label.RepoID}) unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID, RepoID: label.RepoID})
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), label.RepoID, label.ID)) assert.NoError(t, issues_model.DeleteLabel(t.Context(), label.RepoID, label.ID))
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID}) unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID})
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), unittest.NonexistentID, unittest.NonexistentID)) assert.NoError(t, issues_model.DeleteLabel(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
unittest.CheckConsistencyFor(t, &issues_model.Label{}, &repo_model.Repository{}) unittest.CheckConsistencyFor(t, &issues_model.Label{}, &repo_model.Repository{})
} }
-15
View File
@@ -407,21 +407,6 @@ func (pr *PullRequest) GetGitHeadRefName() string { // TODO: make it return RefN
return git.RefNameFromPullIndex(pr.Index).String() return git.RefNameFromPullIndex(pr.Index).String()
} }
func (pr *PullRequest) GetInstructionsCliArgs() (ret struct {
BaseBranchArg string
HeadBranchArg string
LocalBranchArg string
},
) {
ret.BaseBranchArg = util.ShellEscape(pr.BaseBranch)
ret.HeadBranchArg = util.ShellEscape(pr.HeadBranch)
ret.LocalBranchArg = ret.HeadBranchArg
if pr.HeadRepo != nil && pr.HeadRepoID != pr.BaseRepoID {
ret.LocalBranchArg = util.ShellEscape(pr.HeadRepo.OwnerName) + "-" + ret.HeadBranchArg
}
return ret
}
// GetReviewCommentsCount returns the number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR) // GetReviewCommentsCount returns the number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR)
func (pr *PullRequest) GetReviewCommentsCount(ctx context.Context) int { func (pr *PullRequest) GetReviewCommentsCount(ctx context.Context) int {
opts := FindCommentsOptions{ opts := FindCommentsOptions{
-18
View File
@@ -9,7 +9,6 @@ import (
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
"gitea.dev/modules/markup/markdown" "gitea.dev/modules/markup/markdown"
"gitea.dev/modules/test"
_ "gitea.dev/modules/markup/orgmode" _ "gitea.dev/modules/markup/orgmode"
@@ -81,23 +80,6 @@ func TestRepoFile(t *testing.T) {
<video src="/user2/repo1/media/commit/1234/my%20dir/LINK"> <video src="/user2/repo1/media/commit/1234/my%20dir/LINK">
</video>`, rendered) </video>`, rendered)
}) })
t.Run("PictureSource", func(t *testing.T) {
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{CurrentRefSubURL: "branch/main"}).
WithMarkupType(markdown.MarkupName)
rendered, err := testRenderString(rctx, `<picture><source media="(prefers-color-scheme: dark)" srcset="dark.svg"><img src="light.svg"></picture>`)
assert.NoError(t, err)
assert.Equal(t, test.NormalizeHTMLSpaces(`
<p>
<a href="/user2/repo1/src/branch/main/light.svg" target="_blank" rel="nofollow noopener">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/user2/repo1/media/branch/main/dark.svg"/>
<img src="/user2/repo1/media/branch/main/light.svg"/>
</picture>
</a>
</p>
`), test.NormalizeHTMLSpaces(rendered))
})
} }
func TestRepoFileOrgMode(t *testing.T) { func TestRepoFileOrgMode(t *testing.T) {
-3
View File
@@ -133,9 +133,6 @@ func ExistAttachmentsByUUID(ctx context.Context, uuid string) (bool, error) {
// GetAttachmentsByIssueID returns all attachments of an issue. // GetAttachmentsByIssueID returns all attachments of an issue.
func GetAttachmentsByIssueID(ctx context.Context, issueID int64) ([]*Attachment, error) { func GetAttachmentsByIssueID(ctx context.Context, issueID int64) ([]*Attachment, error) {
if issueID == 0 { // unsaved issue, would match every issue-less attachment
return []*Attachment{}, nil
}
attachments := make([]*Attachment, 0, 10) attachments := make([]*Attachment, 0, 10)
return attachments, db.GetEngine(ctx).Where("issue_id = ? AND comment_id = 0", issueID).Find(&attachments) return attachments, db.GetEngine(ctx).Where("issue_id = ? AND comment_id = 0", issueID).Find(&attachments)
} }
-4
View File
@@ -36,10 +36,6 @@ func TestGetByCommentOrIssueID(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, attachments, 1) assert.Len(t, attachments, 1)
attachments, err = repo_model.GetAttachmentsByIssueID(t.Context(), 0)
assert.NoError(t, err)
assert.Empty(t, attachments)
attachments, err = repo_model.GetAttachmentsByCommentID(t.Context(), 1) attachments, err = repo_model.GetAttachmentsByCommentID(t.Context(), 1)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, attachments, 2) assert.Len(t, attachments, 2)
+19 -26
View File
@@ -191,10 +191,6 @@ func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs
} }
for i := range attachments { for i := range attachments {
if attachments[i].IssueID != 0 || attachments[i].CommentID != 0 || attachments[i].ReleaseID != 0 {
return util.NewPermissionDeniedErrorf("attachment is already linked")
}
if attachments[i].RepoID == 0 && attachments[i].CreatedUnix < LegacyAttachmentMissingRepoIDCutoff { if attachments[i].RepoID == 0 && attachments[i].CreatedUnix < LegacyAttachmentMissingRepoIDCutoff {
attachments[i].RepoID = rel.RepoID attachments[i].RepoID = rel.RepoID
if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("repo_id").Update(attachments[i]); err != nil { if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("repo_id").Update(attachments[i]); err != nil {
@@ -205,6 +201,10 @@ func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs
if attachments[i].RepoID != rel.RepoID { if attachments[i].RepoID != rel.RepoID {
return util.NewPermissionDeniedErrorf("attachment belongs to different repository") return util.NewPermissionDeniedErrorf("attachment belongs to different repository")
} }
if attachments[i].ReleaseID != 0 {
return util.NewPermissionDeniedErrorf("release permission denied")
}
attachments[i].ReleaseID = releaseID attachments[i].ReleaseID = releaseID
// No assign value could be 0, so ignore AllCols(). // No assign value could be 0, so ignore AllCols().
if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("release_id").Update(attachments[i]); err != nil { if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("release_id").Update(attachments[i]); err != nil {
@@ -357,21 +357,21 @@ func GetLatestReleaseByRepoID(ctx context.Context, repoID int64) (*Release, erro
} }
type releaseMetaSearch struct { type releaseMetaSearch struct {
IDs []int64 ID []int64
Releases []*Release Rel []*Release
} }
func (s releaseMetaSearch) Len() int { func (s releaseMetaSearch) Len() int {
return len(s.IDs) return len(s.ID)
} }
func (s releaseMetaSearch) Swap(i, j int) { func (s releaseMetaSearch) Swap(i, j int) {
s.IDs[i], s.IDs[j] = s.IDs[j], s.IDs[i] s.ID[i], s.ID[j] = s.ID[j], s.ID[i]
s.Releases[i], s.Releases[j] = s.Releases[j], s.Releases[i] s.Rel[i], s.Rel[j] = s.Rel[j], s.Rel[i]
} }
func (s releaseMetaSearch) Less(i, j int) bool { func (s releaseMetaSearch) Less(i, j int) bool {
return s.IDs[i] < s.IDs[j] return s.ID[i] < s.ID[j]
} }
func hasDuplicateName(attaches []*Attachment) bool { func hasDuplicateName(attaches []*Attachment) bool {
@@ -396,26 +396,19 @@ func GetReleaseAttachments(ctx context.Context, rels ...*Release) (err error) {
// then merge join them // then merge join them
// Sort // Sort
sortedRels := releaseMetaSearch{IDs: make([]int64, 0, len(rels)), Releases: make([]*Release, 0, len(rels))} sortedRels := releaseMetaSearch{ID: make([]int64, len(rels)), Rel: make([]*Release, len(rels))}
var attachments []*Attachment var attachments []*Attachment
for _, rel := range rels { for index, element := range rels {
// if a provided "Release" has no ID, don't query it, otherwise, the "IN" query will return all other attachments for the repo element.Attachments = []*Attachment{}
if rel.ID == 0 { sortedRels.ID[index] = element.ID
continue sortedRels.Rel[index] = element
}
rel.Attachments = []*Attachment{}
sortedRels.IDs = append(sortedRels.IDs, rel.ID)
sortedRels.Releases = append(sortedRels.Releases, rel)
}
if len(sortedRels.IDs) == 0 {
return nil
} }
sort.Sort(sortedRels) sort.Sort(sortedRels)
// Select attachments // Select attachments
err = db.GetEngine(ctx). err = db.GetEngine(ctx).
Asc("release_id", "name"). Asc("release_id", "name").
In("release_id", sortedRels.IDs). In("release_id", sortedRels.ID).
Find(&attachments) Find(&attachments)
if err != nil { if err != nil {
return err return err
@@ -424,14 +417,14 @@ func GetReleaseAttachments(ctx context.Context, rels ...*Release) (err error) {
// merge join // merge join
currentIndex := 0 currentIndex := 0
for _, attachment := range attachments { for _, attachment := range attachments {
for sortedRels.IDs[currentIndex] < attachment.ReleaseID { for sortedRels.ID[currentIndex] < attachment.ReleaseID {
currentIndex++ currentIndex++
} }
sortedRels.Releases[currentIndex].Attachments = append(sortedRels.Releases[currentIndex].Attachments, attachment) sortedRels.Rel[currentIndex].Attachments = append(sortedRels.Rel[currentIndex].Attachments, attachment)
} }
// Makes URL's predictable // Makes URL's predictable
for _, release := range sortedRels.Releases { for _, release := range rels {
// If we have no Repo, we don't need to execute this loop // If we have no Repo, we don't need to execute this loop
if release.Repo == nil { if release.Repo == nil {
continue continue
-12
View File
@@ -53,18 +53,6 @@ func TestAddReleaseAttachmentsRejectsDifferentRepo(t *testing.T) {
assert.Zero(t, attach.ReleaseID, "attachment should not be linked to release on failure") assert.Zero(t, attach.ReleaseID, "attachment should not be linked to release on failure")
} }
func TestAddReleaseAttachmentsRejectsIssueAttachment(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
uuid := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"
err := AddReleaseAttachments(t.Context(), 1, []string{uuid})
assert.ErrorIs(t, err, util.ErrPermissionDenied)
attach, err := GetAttachmentByUUID(t.Context(), uuid)
assert.NoError(t, err)
assert.Zero(t, attach.ReleaseID)
}
func TestAddReleaseAttachmentsAllowsLegacyMissingRepoID(t *testing.T) { func TestAddReleaseAttachmentsAllowsLegacyMissingRepoID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
+1 -10
View File
@@ -616,15 +616,6 @@ func ComposeHTTPSCloneURL(ctx context.Context, owner, repo string) string {
// ComposeSSHCloneURL returns SSH clone URL based on the given owner and repository name. // ComposeSSHCloneURL returns SSH clone URL based on the given owner and repository name.
func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) string { func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) string {
return composeSSHCloneURL(doer, ownerName, repoName, setting.Repository.UseCompatSSHURI)
}
// ComposeSSHCloneURI is like ComposeSSHCloneURL but always returns the "ssh://" form, because "go get" rejects scp-style addresses
func ComposeSSHCloneURI(doer *user_model.User, ownerName, repoName string) string {
return composeSSHCloneURL(doer, ownerName, repoName, true)
}
func composeSSHCloneURL(doer *user_model.User, ownerName, repoName string, useURI bool) string {
sshUser := setting.SSH.User sshUser := setting.SSH.User
sshDomain := setting.SSH.Domain sshDomain := setting.SSH.Domain
@@ -651,7 +642,7 @@ func composeSSHCloneURL(doer *user_model.User, ownerName, repoName string, useUR
if ip := net.ParseIP(sshHost); ip != nil && ip.To4() == nil { if ip := net.ParseIP(sshHost); ip != nil && ip.To4() == nil {
sshHost = "[" + sshHost + "]" // for IPv6 address, wrap it with brackets sshHost = "[" + sshHost + "]" // for IPv6 address, wrap it with brackets
} }
if useURI { if setting.Repository.UseCompatSSHURI {
return fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName)) return fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
} }
return fmt.Sprintf("%s@%s:%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName)) return fmt.Sprintf("%s@%s:%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
-2
View File
@@ -185,8 +185,6 @@ func TestComposeSSHCloneURL(t *testing.T) {
assert.Equal(t, "git@domain:user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo")) assert.Equal(t, "git@domain:user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
setting.Repository.UseCompatSSHURI = true setting.Repository.UseCompatSSHURI = true
assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo")) assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
setting.Repository.UseCompatSSHURI = false
assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURI(nil, "user", "repo"))
// test SSH_DOMAIN while use non-standard SSH port // test SSH_DOMAIN while use non-standard SSH port
setting.SSH.Port = 123 setting.SSH.Port = 123
setting.Repository.UseCompatSSHURI = false setting.Repository.UseCompatSSHURI = false
+7 -28
View File
@@ -20,10 +20,9 @@ type WatchMode int8
const ( const (
WatchModeNone WatchMode = iota // 0 watch nothing unless mentioned WatchModeNone WatchMode = iota // 0 watch nothing unless mentioned
WatchModeNormal // 1 proactively watching (all, or custom before WatchModeCustom) WatchModeNormal // 1 proactively watching (all or custom)
WatchModeDont // 2 ignore the repo WatchModeDont // 2 ignore the repo
WatchModeAuto // 3 automatically watching (from AutoWatchOnChanges) WatchModeAuto // 3 automatically watching (from AutoWatchOnChanges)
WatchModeCustom // 4 watching the events picked in the custom watch options
) )
// WatchType is the `watch` column gating one kind of notification // WatchType is the `watch` column gating one kind of notification
@@ -80,7 +79,7 @@ func (w *Watch) IsWatching() bool {
// IsWatchingAll reports whether every event is enabled, which is the "all activity" mode // IsWatchingAll reports whether every event is enabled, which is the "all activity" mode
func (w *Watch) IsWatchingAll() bool { func (w *Watch) IsWatchingAll() bool {
return w.Mode != WatchModeCustom && w.IncludePullRequests && w.IncludeIssues && w.IncludeReleases return w.IncludePullRequests && w.IncludeIssues && w.IncludeReleases
} }
func (w *Watch) IsWatchingAny() bool { func (w *Watch) IsWatchingAny() bool {
@@ -167,9 +166,6 @@ func WatchRepoAuto(ctx context.Context, doer *user_model.User, repo *Repository,
if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) { if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) {
return user_model.ErrBlockedUser return user_model.ErrBlockedUser
} }
if watch.Mode == WatchModeCustom { // keep the events the user picked
return nil
}
return watchRepoByMode(ctx, watch, WatchModeNormal) return watchRepoByMode(ctx, watch, WatchModeNormal)
} }
@@ -193,7 +189,7 @@ func WatchRepoWithOptions(ctx context.Context, doer *user_model.User, repo *Repo
if err != nil { if err != nil {
return err return err
} }
if opts.Mode == WatchModeNormal || opts.Mode == WatchModeCustom { if opts.Mode == WatchModeNormal {
_, err = db.GetEngine(ctx).Where("user_id=? AND repo_id=?", doer.ID, repo.ID). _, err = db.GetEngine(ctx).Where("user_id=? AND repo_id=?", doer.ID, repo.ID).
Cols("include_pull_requests", "include_issues", "include_releases"). Cols("include_pull_requests", "include_issues", "include_releases").
Update(&Watch{IncludePullRequests: opts.WatchPullRequests, IncludeIssues: opts.WatchIssues, IncludeReleases: opts.WatchReleases}) Update(&Watch{IncludePullRequests: opts.WatchPullRequests, IncludeIssues: opts.WatchIssues, IncludeReleases: opts.WatchReleases})
@@ -251,16 +247,6 @@ func GetRepoIgnorersIDs(ctx context.Context, repoID int64) ([]int64, error) {
// but avoids joining with `user` for performance reasons // but avoids joining with `user` for performance reasons
// User permissions must be verified elsewhere if required // User permissions must be verified elsewhere if required
func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) { func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
return getRepoWatchersIDs(ctx, repoID, watchType, false)
}
// GetRepoCustomWatchersIDs is GetRepoWatchersIDs limited to custom watches, which unlike
// watching all activity are never set up automatically (e.g. by AutoWatchNewRepos)
func GetRepoCustomWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
return getRepoWatchersIDs(ctx, repoID, watchType, true)
}
func getRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType, customOnly bool) ([]int64, error) {
ids := make([]int64, 0, 64) ids := make([]int64, 0, 64)
var watchColName string var watchColName string
switch watchType { switch watchType {
@@ -273,19 +259,12 @@ func getRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType,
default: default:
panic("invalid WatchType") panic("invalid WatchType")
} }
sess := db.GetEngine(ctx).Table("watch"). return ids, db.GetEngine(ctx).Table("watch").
Where("watch.repo_id=?", repoID). Where("watch.repo_id=?", repoID).
And("watch.mode<>?", WatchModeDont). And("watch.mode<>?", WatchModeDont).
And(builder.Eq{watchColName: true}) And(builder.Eq{watchColName: true}).
if customOnly { // matches SelectedMode() == "custom" Select("user_id").
sess = sess.And(builder.Or( Find(&ids)
builder.Eq{"watch.mode": WatchModeCustom},
builder.Eq{"watch.include_pull_requests": false},
builder.Eq{"watch.include_issues": false},
builder.Eq{"watch.include_releases": false},
))
}
return ids, sess.Select("user_id").Find(&ids)
} }
// GetRepoWatchers returns range of users watching given repository. // GetRepoWatchers returns range of users watching given repository.
+1 -16
View File
@@ -145,7 +145,7 @@ func TestWatchOptions(t *testing.T) {
// repo 1 is watched by users 1, 4, 9 and 11, all with every event enabled // repo 1 is watched by users 1, 4, 9 and 11, all with every event enabled
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), user, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeNormal, WatchPullRequests: true})) // custom watches were stored like this before WatchModeCustom assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), user, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeNormal, WatchPullRequests: true}))
for watchType, expected := range map[repo_model.WatchType][]int64{ for watchType, expected := range map[repo_model.WatchType][]int64{
repo_model.WatchPullRequests: {1, 4, 9, 11}, repo_model.WatchPullRequests: {1, 4, 9, 11},
@@ -157,20 +157,6 @@ func TestWatchOptions(t *testing.T) {
assert.ElementsMatch(t, expected, ids, watchType) assert.ElementsMatch(t, expected, ids, watchType)
} }
// a custom watch picking every event differs from all activity, which can be set up automatically
customUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), customUser, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeCustom, WatchPullRequests: true, WatchIssues: true, WatchReleases: true}))
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), customUser, repo, true)) // must keep the custom watch
for watchType, expected := range map[repo_model.WatchType][]int64{
repo_model.WatchPullRequests: {1, 4},
repo_model.WatchIssues: {4},
repo_model.WatchReleases: {4},
} {
ids, err := repo_model.GetRepoCustomWatchersIDs(t.Context(), repo.ID, watchType)
assert.NoError(t, err)
assert.ElementsMatch(t, expected, ids, watchType)
}
// the options of one user must not show up for another // the options of one user must not show up for another
watches, err := repo_model.GetUserWatches(t.Context(), 4, []int64{repo.ID}) watches, err := repo_model.GetUserWatches(t.Context(), 4, []int64{repo.ID})
assert.NoError(t, err) assert.NoError(t, err)
@@ -190,6 +176,5 @@ func TestWatchSelectedMode(t *testing.T) {
assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNormal}).SelectedMode()) assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNormal}).SelectedMode())
assert.Equal(t, "ignore", (&repo_model.Watch{Mode: repo_model.WatchModeDont}).SelectedMode()) assert.Equal(t, "ignore", (&repo_model.Watch{Mode: repo_model.WatchModeDont}).SelectedMode())
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeNormal, IncludeIssues: true}).SelectedMode()) assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeNormal, IncludeIssues: true}).SelectedMode())
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeCustom, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode())
assert.Equal(t, "all", (&repo_model.Watch{Mode: repo_model.WatchModeAuto, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode()) assert.Equal(t, "all", (&repo_model.Watch{Mode: repo_model.WatchModeAuto, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode())
} }
+8 -20
View File
@@ -151,28 +151,16 @@ func TestListEmails(t *testing.T) {
func TestEmailAddressValidate(t *testing.T) { func TestEmailAddressValidate(t *testing.T) {
cases := map[string]bool{ cases := map[string]bool{
"": false, "": false,
"@a": false, "root@localhost": true,
"user@[192.168.1.2]": true,
// "_" shouldn't appear in domain but can appear in hostname, since we can't stop site admins from doing so, just accept it "@a": false,
"root@local_host": true, "abc@gmail.com": true,
"root@localhost": true, "abc@gmail.com\n": false,
"root@LOCALHOST": true,
"user@[192.168.1.2]": true,
"user@[IPv6:FFff::1]": true,
"abc@gmail.com": true,
"abc@gmail.com.": false,
"abc@gmail.com-": false,
"abc@gmail.com\n": false,
"abc@gmail..com": false,
"abc@gmail com": false,
"abc@gmail*com": false,
"Foo <foo@bar.com>": false, "Foo <foo@bar.com>": false,
"abc@gmail.com (x)": false, "abc@gmail.com (x)": false,
"jürgen@example.com": false, // utf8 address is not supported yet "jürgen@example.com": false,
"a@foo_bar.com": false,
} }
for tc, isValid := range cases { for tc, isValid := range cases {
t.Run(tc, func(t *testing.T) { t.Run(tc, func(t *testing.T) {
+4 -4
View File
@@ -40,8 +40,8 @@ type SearchUserOptions struct {
Keyword string Keyword string
Types []UserType Types []UserType
UID int64 UID int64
LoginName string // this option should be used only for admin user LoginName string // this option should be used only for admin user
SourceID int64 // this option should be used only for admin user SourceID optional.Option[int64] // this option should be used only for admin user, Some(0) means local users
OrderBy db.SearchOrderBy OrderBy db.SearchOrderBy
Visible []structs.VisibleType Visible []structs.VisibleType
Actor *User // The user doing the search Actor *User // The user doing the search
@@ -106,8 +106,8 @@ func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) db.Session
cond = cond.And(builder.Eq{"id": opts.UID}) cond = cond.And(builder.Eq{"id": opts.UID})
} }
if opts.SourceID > 0 { if opts.SourceID.Has() {
cond = cond.And(builder.Eq{"login_source": opts.SourceID}) cond = cond.And(builder.Eq{"login_source": opts.SourceID.Value()})
} }
if opts.LoginName != "" { if opts.LoginName != "" {
cond = cond.And(builder.Eq{"login_name": opts.LoginName}) cond = cond.And(builder.Eq{"login_name": opts.LoginName})
+2 -31
View File
@@ -1021,8 +1021,7 @@ func GetInactiveUsers(ctx context.Context, olderThan time.Duration) ([]*User, er
Find(&users) Find(&users)
} }
// GetUserByID returns the user object by given ID if exists. TODO: for most cases, this function should be avoided. // GetUserByID returns the user object by given ID if exists.
// Use correct GetXxxUser functions instead to handle user types & states correctly.
func GetUserByID(ctx context.Context, id int64) (*User, error) { func GetUserByID(ctx context.Context, id int64) (*User, error) {
u := new(User) u := new(User)
has, err := db.GetEngine(ctx).ID(id).Get(u) has, err := db.GetEngine(ctx).ID(id).Get(u)
@@ -1034,8 +1033,7 @@ func GetUserByID(ctx context.Context, id int64) (*User, error) {
return u, nil return u, nil
} }
// GetUserByIDs returns the user objects by given IDs if exists. TODO: for most cases, this function should be avoided. // GetUserByIDs returns the user objects by given IDs if exists.
// Use correct GetXxxUser functions instead to handle user types & states correctly.
func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) { func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
if len(ids) == 0 { if len(ids) == 0 {
return nil, nil return nil, nil
@@ -1048,13 +1046,6 @@ func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
return users, err return users, err
} }
// Glossaries for XxxUser:
// * Possible: The user may or may not exist in the database, but we can still return a valid user object (e.g. Ghost user) for the given ID.
// * Individual: The user is a real person, not an organization or a bot.
// * Doer: The user is the one performing an action
// * Valid: The user is an active user and not prohibited from signing in
// * System: Negative ID (e.g. Ghost, Actions, DeployKey, etc.) and not a real person, Ghost is not included if there is no "Possible" keyword
// GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user // GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user
func GetPossibleUserByID(ctx context.Context, id int64) (_ int64, u *User, err error) { func GetPossibleUserByID(ctx context.Context, id int64) (_ int64, u *User, err error) {
if id < 0 { if id < 0 {
@@ -1091,23 +1082,6 @@ func GetPossibleUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
return users, nil return users, nil
} }
func GetValidDoerUserByID(ctx context.Context, id int64) (*User, error) {
u, err := GetUserByID(ctx, id)
if err != nil {
return nil, err
}
if !u.IsIndividual() && !u.IsTypeBot() {
return nil, util.NewNotExistErrorf("user %d is not a doer", id)
}
if !u.IsActive {
return nil, util.NewPermissionDeniedErrorf("user %d is inactive", id)
}
if u.ProhibitLogin {
return nil, util.NewPermissionDeniedErrorf("user %d is prohibited from login", id)
}
return u, nil
}
func getUserByNameWithTypes(ctx context.Context, name string, types ...UserType) (*User, error) { func getUserByNameWithTypes(ctx context.Context, name string, types ...UserType) (*User, error) {
u := &User{} u := &User{}
sess := db.GetEngine(ctx).Where(builder.Eq{"lower_name": strings.ToLower(name)}) sess := db.GetEngine(ctx).Where(builder.Eq{"lower_name": strings.ToLower(name)})
@@ -1227,9 +1201,6 @@ func GetUsersByEmails(ctx context.Context, emails []string) (*EmailUserMap, erro
noReplyAddressSuffix := "@" + strings.ToLower(setting.Service.NoReplyAddress) noReplyAddressSuffix := "@" + strings.ToLower(setting.Service.NoReplyAddress)
for _, email := range emails { for _, email := range emails {
emailLower := strings.ToLower(email) emailLower := strings.ToLower(email)
if emailLower == "" {
continue // a commit without an author email names no user
}
needCheckEmails.Add(emailLower) needCheckEmails.Add(emailLower)
if localPart, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok { if localPart, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok {
name, id := parseLocalPartToNameID(localPart) name, id := parseLocalPartToNameID(localPart)
-6
View File
@@ -95,12 +95,6 @@ func TestUserEmails(t *testing.T) {
setting.Service.NoReplyAddress = "example.com" setting.Service.NoReplyAddress = "example.com"
testGetUserByEmail(t, "user1-2@example.COM", 1) testGetUserByEmail(t, "user1-2@example.COM", 1)
}) })
t.Run("EmptyEmail", func(t *testing.T) {
// A user whose email address is empty must not own commits that have no author email.
require.NoError(t, db.Insert(t.Context(), &user_model.EmailAddress{UID: 2, Email: "", LowerEmail: "", IsActivated: true}))
testGetUserByEmail(t, "", 0)
})
}) })
t.Run("GetUserByEmail", func(t *testing.T) { t.Run("GetUserByEmail", func(t *testing.T) {
testGetUserByEmail := func(t *testing.T, email string, uid int64) { testGetUserByEmail := func(t *testing.T, email string, uid int64) {
+48 -23
View File
@@ -4,6 +4,7 @@
package jobparser package jobparser
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -64,31 +65,44 @@ func (w *SingleWorkflow) SetJob(id string, job *Job) error {
m := map[string]*Job{ m := map[string]*Job{
id: job, id: job,
} }
out, err := marshalYAML(m) var buf bytes.Buffer
if err != nil { encoder := yaml.NewEncoder(&buf)
encoder.SetIndent(2)
if err := encoder.Encode(m); err != nil {
return err return err
} }
encoder.Close()
node := yaml.Node{} node := yaml.Node{}
if err := yaml.Unmarshal(out, &node); err != nil { if err := yaml.Unmarshal(buf.Bytes(), &node); err != nil {
return err return err
} }
if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode { if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode {
return fmt.Errorf("can not set job: %s", out) return fmt.Errorf("can not set job: %s", buf.String())
} }
w.RawJobs = *node.Content[0] w.RawJobs = *node.Content[0]
return nil return nil
} }
func (w *SingleWorkflow) Marshal() ([]byte, error) { func (w *SingleWorkflow) Marshal() ([]byte, error) {
// Encode with the same indentation SetJob uses (2). yaml.Marshal's default
// indentation (4) makes the encoder emit multi-line block scalars (e.g. a
// `run:` step that begins with blank lines) with a wrong explicit indentation
// indicator (`run: |4`) that then fails to re-parse, which silently strands
// the job during concurrency evaluation. Keeping both encoders at indent 2
// makes the serialized single workflow round-trip.
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
payload := *w payload := *w
payload.RunName = "" // already interpolated into the run title, a runner would parse it as a template again payload.RunName = "" // already interpolated into the run title, a runner would parse it as a template again
return marshalYAML(&payload) if err := enc.Encode(&payload); err != nil {
} return nil, err
}
// marshalYAML never wraps lines, go-yaml wraps more-indented lines of folded scalars until https://github.com/yaml/go-yaml/pull/441 is released. if err := enc.Close(); err != nil {
func marshalYAML(v any) ([]byte, error) { return nil, err
return yaml.Dump(v, yaml.WithV3Defaults(), yaml.WithIndent(2), yaml.WithLineWidth(-1)) }
return buf.Bytes(), nil
} }
type Job struct { type Job struct {
@@ -174,18 +188,29 @@ func (j *Job) DisplayName() string {
return util.EllipsisDisplayString(unescapeExpressions(j.Name), 255) return util.EllipsisDisplayString(unescapeExpressions(j.Name), 255)
} }
// BlockSafeString works around https://github.com/yaml/go-yaml/issues/399, quoting a value whose
// leading newline would cost a literal block scalar its indentation indicator.
type BlockSafeString string
func (s BlockSafeString) MarshalYAML() (any, error) {
if !strings.HasPrefix(string(s), "\n") {
return string(s), nil
}
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle, Value: string(s)}, nil
}
type Step struct { type Step struct {
ID string `yaml:"id,omitempty"` ID string `yaml:"id,omitempty"`
If yaml.Node `yaml:"if,omitempty"` If yaml.Node `yaml:"if,omitempty"`
Name string `yaml:"name,omitempty"` Name BlockSafeString `yaml:"name,omitempty"`
Uses string `yaml:"uses,omitempty"` Uses string `yaml:"uses,omitempty"`
Run string `yaml:"run,omitempty"` Run BlockSafeString `yaml:"run,omitempty"`
WorkingDirectory string `yaml:"working-directory,omitempty"` WorkingDirectory string `yaml:"working-directory,omitempty"`
Shell string `yaml:"shell,omitempty"` Shell string `yaml:"shell,omitempty"`
Env yaml.Node `yaml:"env,omitempty"` Env yaml.Node `yaml:"env,omitempty"`
With yaml.Node `yaml:"with,omitempty"` With yaml.Node `yaml:"with,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"` TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
} }
// UnmarshalYAML canonicalizes booleans like continue-on-error // UnmarshalYAML canonicalizes booleans like continue-on-error
@@ -207,9 +232,9 @@ func (s *Step) String() string {
} }
return (&model.Step{ return (&model.Step{
ID: s.ID, ID: s.ID,
Name: s.Name, Name: string(s.Name),
Uses: s.Uses, Uses: s.Uses,
Run: s.Run, Run: string(s.Run),
}).String() }).String()
} }
+2 -36
View File
@@ -48,7 +48,7 @@ jobs:
_, origJob := sws[0].Job() _, origJob := sws[0].Job()
require.Len(t, origJob.Steps, 1) require.Len(t, origJob.Steps, 1)
const wantRun = "\n\necho start\necho done\n" const wantRun = "\n\necho start\necho done\n"
require.Equal(t, wantRun, origJob.Steps[0].Run) require.Equal(t, wantRun, string(origJob.Steps[0].Run))
payload, err := sws[0].Marshal() payload, err := sws[0].Marshal()
require.NoError(t, err) require.NoError(t, err)
@@ -63,7 +63,7 @@ jobs:
// the round-trip must preserve the run block byte-for-byte // the round-trip must preserve the run block byte-for-byte
_, gotJob := roundTripped[0].Job() _, gotJob := roundTripped[0].Job()
require.Len(t, gotJob.Steps, 1) require.Len(t, gotJob.Steps, 1)
require.Equal(t, wantRun, gotJob.Steps[0].Run, "round-trip must preserve run content; got payload:\n%s", payload) require.Equal(t, wantRun, string(gotJob.Steps[0].Run), "round-trip must preserve run content; got payload:\n%s", payload)
} }
// Typing a step's continue-on-error as a bool used to reject the whole `jobs:` node. // Typing a step's continue-on-error as a bool used to reject the whole `jobs:` node.
@@ -104,37 +104,3 @@ jobs:
require.Equal(t, w, steps[i].RawContinueOnError, "step %d, payload:\n%s", i, payload) require.Equal(t, w, steps[i].RawContinueOnError, "step %d, payload:\n%s", i, payload)
} }
} }
// Folded (`>`) scalars in with/env used to gain blank lines and have spaces turned into newlines
// on every Marshal -> Parse cycle.
func TestSingleWorkflowRoundTripFoldedScalar(t *testing.T) {
const wf = `name: demo
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: ./.github/actions/build
with:
cfg: >
begin
first "indented" line with path dir/Installer Scripts/some long path/another long path/some file name.xzy,
second "indented" line
end
`
const want = "begin\n first \"indented\" line with path dir/Installer Scripts/some long path/another long path/some file name.xzy,\n second \"indented\" line\nend\n"
sws, err := Parse([]byte(wf))
require.NoError(t, err)
require.Len(t, sws, 1)
for range 3 {
payload, err := sws[0].Marshal()
require.NoError(t, err)
sws, err = Parse(payload)
require.NoError(t, err)
require.Len(t, sws, 1)
}
_, job := sws[0].Job()
var with map[string]string
require.NoError(t, job.Steps[0].With.Decode(&with))
require.Equal(t, want, with["cfg"])
}
@@ -7,9 +7,5 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- id: extract_tag - id: extract_tag
name: |- name: "\nExtract tag for variant"
run: "\necho Test\n"
Extract tag for variant
run: |
echo Test
+8 -18
View File
@@ -532,7 +532,7 @@ func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool
} }
for _, val := range vals { for _, val := range vals {
if matchActivityType(val, actions...) { if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) {
matchTimes++ matchTimes++
break break
} }
@@ -575,7 +575,7 @@ func matchPullRequestEvent(ctx context.Context, gitRepo *git.Repository, commit
} }
log.Trace("matching pull_request %s with %v", action, vals) log.Trace("matching pull_request %s with %v", action, vals)
for _, val := range vals { for _, val := range vals {
if matchActivityType(val, string(action)) { if glob.MustCompile(val, '/').Match(string(action)) {
activityTypeMatched = true activityTypeMatched = true
matchTimes++ matchTimes++
break break
@@ -678,7 +678,7 @@ func matchIssueCommentEvent(issueCommentPayload *api.IssueCommentPayload, evt *j
// NONE // NONE
for _, val := range vals { for _, val := range vals {
if matchActivityType(val, string(issueCommentPayload.Action)) { if glob.MustCompile(val, '/').Match(string(issueCommentPayload.Action)) {
matchTimes++ matchTimes++
break break
} }
@@ -718,7 +718,7 @@ func matchPullRequestReviewEvent(prPayload *api.PullRequestPayload, evt *jobpars
} }
for _, val := range vals { for _, val := range vals {
if matchActivityType(val, actions...) { if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) {
matchTimes++ matchTimes++
break break
} }
@@ -758,7 +758,7 @@ func matchPullRequestReviewCommentEvent(prPayload *api.PullRequestPayload, evt *
} }
for _, val := range vals { for _, val := range vals {
if matchActivityType(val, actions...) { if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) {
matchTimes++ matchTimes++
break break
} }
@@ -795,7 +795,7 @@ func matchReleaseEvent(payload *api.ReleasePayload, evt *jobparser.Event) bool {
action = "edited" action = "edited"
} }
for _, val := range vals { for _, val := range vals {
if matchActivityType(val, string(action)) { if glob.MustCompile(val, '/').Match(string(action)) {
matchTimes++ matchTimes++
break break
} }
@@ -832,7 +832,7 @@ func matchPackageEvent(payload *api.PackagePayload, evt *jobparser.Event) bool {
action = "published" action = "published"
} }
for _, val := range vals { for _, val := range vals {
if matchActivityType(val, string(action)) { if glob.MustCompile(val, '/').Match(string(action)) {
matchTimes++ matchTimes++
break break
} }
@@ -857,7 +857,7 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
case "types": case "types":
action := payload.Action action := payload.Action
for _, val := range vals { for _, val := range vals {
if matchActivityType(val, action) { if glob.MustCompile(val, '/').Match(action) {
matchTimes++ matchTimes++
break break
} }
@@ -893,13 +893,3 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
} }
return matchTimes == len(evt.Acts()) return matchTimes == len(evt.Acts())
} }
// matchActivityType treats an invalid user-provided pattern as no match instead of panicking
func matchActivityType(pattern string, actions ...string) bool {
g, err := glob.Compile(pattern, '/')
if err != nil {
log.Debug("invalid activity type pattern %q: %v", pattern, err)
return false
}
return slices.ContainsFunc(actions, g.Match)
}
-7
View File
@@ -205,13 +205,6 @@ func TestDetectMatched(t *testing.T) {
yamlOn: "on:\n release:\n types: [published]", yamlOn: "on:\n release:\n types: [published]",
expected: detectMatched, expected: detectMatched,
}, },
{
desc: "HookEventRelease(release) doesn't match an invalid activity type pattern",
triggedEvent: webhook_module.HookEventRelease,
payload: &api.ReleasePayload{Action: api.HookReleasePublished},
yamlOn: "on:\n release:\n types: [\"[\"]",
expected: detectNotApplicable,
},
{ {
desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type", desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type",
triggedEvent: webhook_module.HookEventPackage, triggedEvent: webhook_module.HookEventPackage,
+3 -3
View File
@@ -102,11 +102,11 @@ func NewEmbeddedFS(data []byte) fs.ReadDirFS {
efs := &embeddedFS{data: data, files: make(map[string]*embeddedFileInfo)} efs := &embeddedFS{data: data, files: make(map[string]*embeddedFileInfo)}
efs.meta = sync.OnceValue(func() *EmbeddedMeta { efs.meta = sync.OnceValue(func() *EmbeddedMeta {
var meta EmbeddedMeta var meta EmbeddedMeta
_, metaJSON, ok := bytes.CutLast(data, []byte{'\n'}) p := bytes.LastIndexByte(data, '\n')
if !ok { if p < 0 {
return &meta return &meta
} }
if err := json.Unmarshal(metaJSON, &meta); err != nil { if err := json.Unmarshal(data[p+1:], &meta); err != nil {
panic("embedded file is not valid") panic("embedded file is not valid")
} }
return &meta return &meta
-8
View File
@@ -32,14 +32,6 @@ func Init() {
RPID: setting.AppDomain, RPID: setting.AppDomain,
RPOrigins: []string{appURL}, RPOrigins: []string{appURL},
AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation
// WebKit (Safari on iOS and macOS) returns an unsolicited "appid" client
// extension output even when the relying party did not request it. Since the
// webauthn library upgrade to v0.18, the default policy fails such ceremonies
// ('Client returned the appid extension output which was not requested'), breaking
// every passkey and security-key login from Safari on iOS. Ignore unsolicited
// extension outputs, the same policy other relying parties adopted for the
// same WebKit behavior.
ExtensionsUnsolicitedOutputPolicy: protocol.UnsolicitedOutputPolicyIgnore,
}, },
} }
} }
-2
View File
@@ -8,7 +8,6 @@ import (
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"github.com/go-webauthn/webauthn/protocol"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -23,5 +22,4 @@ func TestInit(t *testing.T) {
assert.Equal(t, setting.AppDomain, WebAuthn.Config.RPID) assert.Equal(t, setting.AppDomain, WebAuthn.Config.RPID)
assert.Equal(t, setting.AppName, WebAuthn.Config.RPDisplayName) assert.Equal(t, setting.AppName, WebAuthn.Config.RPDisplayName)
assert.Equal(t, rpOrigin, WebAuthn.Config.RPOrigins) assert.Equal(t, rpOrigin, WebAuthn.Config.RPOrigins)
assert.Equal(t, protocol.UnsolicitedOutputPolicyIgnore, WebAuthn.Config.ExtensionsUnsolicitedOutputPolicy)
} }
-2
View File
@@ -98,7 +98,6 @@ func NewWebhookPolicy() *policy.Policy {
} }
p = policy.NewPolicy("webhook", policyMode(setting.Security.EgressMode), p = policy.NewPolicy("webhook", policyMode(setting.Security.EgressMode),
policy.WithAllow(setting.Webhook.AllowedHostList, "security.ALLOWED_HOST_LIST"), policy.WithAllow(setting.Webhook.AllowedHostList, "security.ALLOWED_HOST_LIST"),
policy.WithLocalNeedsIPAllow(),
policy.WithProxy(selectProxy)) policy.WithProxy(selectProxy))
return p return p
@@ -107,7 +106,6 @@ func NewWebhookPolicy() *policy.Policy {
func NewSecurityPolicy(usage string) *policy.Policy { func NewSecurityPolicy(usage string) *policy.Policy {
return policy.NewPolicy(usage, policyMode(setting.Security.EgressMode), return policy.NewPolicy(usage, policyMode(setting.Security.EgressMode),
policy.WithAllow(setting.Security.AllowedHostList, "security.ALLOWED_HOST_LIST"), policy.WithAllow(setting.Security.AllowedHostList, "security.ALLOWED_HOST_LIST"),
policy.WithLocalNeedsIPAllow(),
policy.WithProxy(proxy.Proxy())) policy.WithProxy(proxy.Proxy()))
} }
-24
View File
@@ -4,13 +4,10 @@
package egress package egress
import ( import (
"net"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"testing" "testing"
"gitea.dev/modules/egress/policy"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/test" "gitea.dev/modules/test"
@@ -73,27 +70,6 @@ func TestWebhookPolicyProxy(t *testing.T) {
} }
} }
func TestWebhookPolicyNeedsIPAllow(t *testing.T) {
defer test.MockVariableValue(&setting.Webhook.AllowedHostList, "localhost")()
defer test.MockVariableValue(&setting.Security.EgressMode, "lax")()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
dial := func() error {
tcpAddr, ok := ln.Addr().(*net.TCPAddr)
require.True(t, ok)
target := net.JoinHostPort("localhost", strconv.Itoa(tcpAddr.Port))
conn, err := NewWebhookPolicy().NewDialContext()(t.Context(), "tcp", target)
if err == nil {
_ = conn.Close()
}
return err
}
assert.ErrorIs(t, dial(), policy.ErrDenied) // a host name entry doesn't cover the loopback address
setting.Webhook.AllowedHostList = "loopback"
assert.NoError(t, dial()) // an IP entry does
}
func TestSecurityPolicy(t *testing.T) { func TestSecurityPolicy(t *testing.T) {
defer test.MockVariableValue(&setting.Security.AllowedHostList, "avatars.example.com")() defer test.MockVariableValue(&setting.Security.AllowedHostList, "avatars.example.com")()
defer test.MockVariableValue(&setting.Security.EgressMode, "lax")() defer test.MockVariableValue(&setting.Security.EgressMode, "lax")()
+33 -43
View File
@@ -411,43 +411,37 @@ var cgnatRange = netip.MustParsePrefix("100.64.0.0/10") // RFC 6598
// reservedRanges are never dialable, based on https://microsoft.github.io/AntiSSRF/ipaddressranges.html // reservedRanges are never dialable, based on https://microsoft.github.io/AntiSSRF/ipaddressranges.html
var reservedRanges = func() (ranges []netip.Prefix) { var reservedRanges = func() (ranges []netip.Prefix) {
for _, cidr := range []string{ for _, cidr := range []string{
"0.0.0.0/8", // "this network" "0.0.0.0/8", // "this network"
"168.63.129.16/32", // Azure WireServer "100.100.100.200/32", // Alibaba Cloud metadata
"192.88.99.0/24", // 6to4 relay anycast "168.63.129.16/32", // Azure WireServer
"224.0.0.0/4", // multicast "169.254.0.0/16", // link-local, cloud metadata endpoints
"240.0.0.0/4", // reserved, incl. limited broadcast "192.0.0.0/24", // IETF protocol assignments
"::/96", // IPv4-compatible, embeds IPv4 "192.0.2.0/24", // TEST-NET-1
"::ffff:0:0:0/96", // IPv4-translated, embeds IPv4 "192.31.196.0/24", // AS112
"64:ff9b::/96", // wkp NAT64 "192.52.193.0/24", // AMT
"64:ff9b:1::/48", // local-use NAT64 "192.88.99.0/24", // 6to4 relay anycast
"2001::/32", // Teredo, embeds IPv4 "192.175.48.0/24", // AS112
"2002::/16", // 6to4, embeds IPv4 "198.18.0.0/15", // benchmarking
"ff00::/8", // multicast "198.51.100.0/24", // TEST-NET-2
} { "203.0.113.0/24", // TEST-NET-3
ranges = append(ranges, netip.MustParsePrefix(cidr)) "224.0.0.0/4", // multicast
} "240.0.0.0/4", // reserved, incl. limited broadcast
return ranges "::/96", // IPv4-compatible, embeds IPv4
}() "::ffff:0:0:0/96", // IPv4-translated, embeds IPv4
"64:ff9b::/96", // wkp NAT64
// restrictedRanges are dialable if they have been explicitly allowed. "64:ff9b:1::/48", // local-use NAT64
var restrictedRanges = func() (ranges []netip.Prefix) { "100::/64", // discard-only
for _, cidr := range []string{ "100:0:0:1::/64", // dummy
"192.0.0.0/24", // IETF protocol assignments "2001::/23", // IETF protocol assignments, incl. Teredo and ORCHID
"192.0.2.0/24", // TEST-NET-1 "2001:db8::/32", // documentation
"192.31.196.0/24", // AS112 "2002::/16", // 6to4, embeds IPv4
"192.52.193.0/24", // AMT "2620:4f:8000::/48", // AS112
"192.175.48.0/24", // AS112 "3fff::/20", // documentation
"198.18.0.0/15", // benchmarking "5f00::/16", // SRv6 SIDs
"198.51.100.0/24", // TEST-NET-2 "fd00:ec2::254/128", // AWS IMDS
"203.0.113.0/24", // TEST-NET-3 "fe80::/10", // link-local
"100::/64", // discard-only "fec0::/10", // site-local
"100:0:0:1::/64", // dummy "ff00::/8", // multicast
"2001::/23", // IETF protocol assignments
"2001:db8::/32", // documentation
"2620:4f:8000::/48", // AS112
"3fff::/20", // documentation
"5f00::/16", // SRv6 SIDs
"fec0::/10", // site-local
} { } {
ranges = append(ranges, netip.MustParsePrefix(cidr)) ranges = append(ranges, netip.MustParsePrefix(cidr))
} }
@@ -457,14 +451,10 @@ var restrictedRanges = func() (ranges []netip.Prefix) {
// classifyAddr reports the class of a canonical address. // classifyAddr reports the class of a canonical address.
func classifyAddr(ip netip.Addr) addrClass { func classifyAddr(ip netip.Addr) addrClass {
switch { switch {
case ip.Zone() != "" || !ip.IsLoopback() && inRange(reservedRanges, ip): case ip.Zone() != "" || !ip.IsLoopback() && slices.ContainsFunc(reservedRanges, func(p netip.Prefix) bool { return p.Contains(ip) }):
return classReserved return classReserved
case ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || cgnatRange.Contains(ip) || inRange(restrictedRanges, ip): case ip.IsPrivate() || ip.IsLoopback() || cgnatRange.Contains(ip):
return classRestricted return classRestricted
} }
return classPublic return classPublic
} }
func inRange(p []netip.Prefix, ip netip.Addr) bool {
return slices.ContainsFunc(p, func(p netip.Prefix) bool { return p.Contains(ip) })
}
+3 -3
View File
@@ -56,7 +56,7 @@ func WithBlock(hostList, key string) Option {
} }
} }
// WithLocalNeedsIPAllow requires non-public targets (private, loopback, link-local, CGNAT and special-use ranges) to match an IP allow entry (CIDR or named range), a host name match is not enough. // WithLocalNeedsIPAllow requires private, loopback and CGNAT targets to match an IP allow entry (CIDR or named range), a host name match is not enough.
func WithLocalNeedsIPAllow() Option { func WithLocalNeedsIPAllow() Option {
return func(p *Policy) { return func(p *Policy) {
p.localNeedsIPAllow = true p.localNeedsIPAllow = true
@@ -140,9 +140,9 @@ func (p *Policy) allowCheck(host string, ip netip.AddrPort, class addrClass) err
return p.notAllowedError(denyTarget(host, ip)) return p.notAllowedError(denyTarget(host, ip))
} }
if !hostnameOk { if !hostnameOk {
return fmt.Errorf("%s needs an explicit IP allow entry (non-public address)", denyTarget(host, ip)) return fmt.Errorf("%s needs an explicit IP allow entry (private/loopback/CGNAT)", denyTarget(host, ip))
} }
return fmt.Errorf("%s needs an explicit allow entry (non-public address)", denyTarget(host, ip)) return fmt.Errorf("%s needs an explicit allow entry (private/loopback/CGNAT)", denyTarget(host, ip))
} }
func (p *Policy) blockReason(host string, ip netip.AddrPort) error { func (p *Policy) blockReason(host string, ip netip.AddrPort) error {
+5 -15
View File
@@ -33,16 +33,8 @@ func TestCheckAddr(t *testing.T) {
{name: "allow host", allow: "example.com", host: "example.com", ip: "8.8.8.8", want: true}, {name: "allow host", allow: "example.com", host: "example.com", ip: "8.8.8.8", want: true},
{name: "allow cidr", allow: "10.0.0.0/8", ip: "10.0.0.5", want: true}, {name: "allow cidr", allow: "10.0.0.0/8", ip: "10.0.0.5", want: true},
{name: "block overrides allow", allow: "10.0.0.0/8", block: "10.0.0.5/32", ip: "10.0.0.5"}, {name: "block overrides allow", allow: "10.0.0.0/8", block: "10.0.0.5/32", ip: "10.0.0.5"},
{name: "non cloud link-local is default denied", ip: "::ffff:169.254.1.2"}, {name: "reserved denied by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254"},
{name: "link-local allowed by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254", want: true}, {name: "reserved denied ipv4-mapped", allow: "169.254.0.0/16", ip: "::ffff:169.254.169.254"},
{name: "link-local allowed ipv4-mapped", allow: "169.254.0.0/16", ip: "::ffff:169.254.169.254", want: true},
{name: "restricted range allowed by cidr", allow: "192.0.2.0/24", ip: "192.0.2.1", want: true},
{name: "ula metadata allowed by private", allow: "private", ip: "fd00:ec2::254", want: true},
{name: "reserved denied despite allow", allow: "168.63.129.16/32", ip: "168.63.129.16"},
{name: "reserved denied ipv4-mapped", allow: "168.63.129.16/32", ip: "::ffff:168.63.129.16"},
{name: "nat64 reserved denied despite allow", allow: "64:ff9b::/96", ip: "64:ff9b::a9fe:a9fe"},
{name: "teredo reserved denied despite allow", allow: "2001::/23", ip: "2001::1"},
{name: "protocol assignment allowed by cidr", allow: "2001::/23", ip: "2001:3::1", want: true},
{name: "local gate ignores host", allow: "example.com", host: "example.com", ip: "10.0.0.5", localNeedsIPAllow: true}, {name: "local gate ignores host", allow: "example.com", host: "example.com", ip: "10.0.0.5", localNeedsIPAllow: true},
{name: "local gate accepts builtin", allow: "private", ip: "100.64.0.1", localNeedsIPAllow: true, want: true}, {name: "local gate accepts builtin", allow: "private", ip: "100.64.0.1", localNeedsIPAllow: true, want: true},
{name: "local gate accepts cidr", allow: "10.0.0.0/24", ip: "10.0.0.5", localNeedsIPAllow: true, want: true}, {name: "local gate accepts cidr", allow: "10.0.0.0/24", ip: "10.0.0.5", localNeedsIPAllow: true, want: true},
@@ -54,8 +46,7 @@ func TestCheckAddr(t *testing.T) {
{name: "strict rejects unmatched host", allow: "example.com", host: "other.com", ip: "8.8.8.8", strict: true}, {name: "strict rejects unmatched host", allow: "example.com", host: "other.com", ip: "8.8.8.8", strict: true},
{name: "strict allows matched host", allow: "example.com", host: "example.com", ip: "8.8.8.8", strict: true, want: true}, {name: "strict allows matched host", allow: "example.com", host: "example.com", ip: "8.8.8.8", strict: true, want: true},
{name: "strict block overrides allow", allow: "10.0.0.0/8", block: "10.0.0.5/32", ip: "10.0.0.5", strict: true}, {name: "strict block overrides allow", allow: "10.0.0.0/8", block: "10.0.0.5/32", ip: "10.0.0.5", strict: true},
{name: "strict reserved denied despite allow", allow: "168.63.129.16/32", ip: "168.63.129.16", strict: true}, {name: "strict reserved denied by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254", strict: true},
{name: "strict link-local allowed by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254", strict: true, want: true},
{name: "strict local gate ignores host", allow: "example.com", host: "example.com", ip: "10.0.0.5", localNeedsIPAllow: true, strict: true}, {name: "strict local gate ignores host", allow: "example.com", host: "example.com", ip: "10.0.0.5", localNeedsIPAllow: true, strict: true},
{name: "strict local gate accepts builtin", allow: "private", ip: "100.64.0.1", localNeedsIPAllow: true, strict: true, want: true}, {name: "strict local gate accepts builtin", allow: "private", ip: "100.64.0.1", localNeedsIPAllow: true, strict: true, want: true},
} { } {
@@ -72,7 +63,7 @@ func TestCheckAddr(t *testing.T) {
mode = Strict mode = Strict
} }
err := NewPolicy("test", mode, opts...).checkAddr(tc.host, netip.AddrPortFrom(addr, 80)) err := NewPolicy("test", mode, opts...).checkAddr(tc.host, netip.AddrPortFrom(addr, 80))
assert.Equal(t, tc.want, err == nil, "%s (%s): %v", tc.name, tc.ip, err) assert.Equal(t, tc.want, err == nil, "%s: %v", tc.name, err)
} }
} }
@@ -124,9 +115,8 @@ func TestCheckHostIPs(t *testing.T) {
builtins := NewPolicy("test", Lax, WithAllow("private, loopback", "")) builtins := NewPolicy("test", Lax, WithAllow("private, loopback", ""))
assert.NoError(t, builtins.checkHostIPs(hostURL(t, "http://example.com"), ips("8.8.8.8", "100.64.0.1", "::1"))) assert.NoError(t, builtins.checkHostIPs(hostURL(t, "http://example.com"), ips("8.8.8.8", "100.64.0.1", "::1")))
assert.NoError(t, builtins.checkHostIPs(hostURL(t, "http://example.com"), ips("100.100.100.200"))) // cloud metadata is opt-in with its containing range
for _, ip := range []string{ for _, ip := range []string{
"0.1.2.3", "168.63.129.16", "169.254.169.254", "192.0.2.1", "192.88.99.1", "198.18.0.1", "0.1.2.3", "100.100.100.200", "168.63.129.16", "169.254.169.254", "192.0.2.1", "192.88.99.1", "198.18.0.1",
"198.51.100.1", "203.0.113.1", "::7f00:1", "::ffff:0:a00:5", "64:ff9b::a9fe:a9fe", "64:ff9b::808:808", "2001::1", "2001:db8::1", "198.51.100.1", "203.0.113.1", "::7f00:1", "::ffff:0:a00:5", "64:ff9b::a9fe:a9fe", "64:ff9b::808:808", "2001::1", "2001:db8::1",
"2002::1", "fe80::1", "2002::1", "fe80::1",
} { } {
+2 -8
View File
@@ -13,7 +13,6 @@ import (
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd" "gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting"
) )
// BatchChecker provides a reader for check-attribute content that can be long running // BatchChecker provides a reader for check-attribute content that can be long running
@@ -121,17 +120,12 @@ func (c *BatchChecker) CheckPath(path string) (rs *Attributes, err error) {
return fmt.Errorf("CheckPath timeout: %s", debugMsg) return fmt.Errorf("CheckPath timeout: %s", debugMsg)
} }
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
rs = NewAttributes() rs = NewAttributes()
for i := 0; i < c.attributesNum; i++ { for i := 0; i < c.attributesNum; i++ {
select { select {
case <-timeout.C: case <-time.After(5 * time.Second):
// there is no "hang" problem now. This code is just used to catch other potential problems. // there is no "hang" problem now. This code is just used to catch other potential problems.
err = reportTimeout() return nil, reportTimeout()
setting.PanicInDevOrTesting("Unexpected timeout, need to investigate: %v", err)
return nil, err
case attr, ok := <-c.stdOut.ReadAttribute(): case attr, ok := <-c.stdOut.ReadAttribute():
if !ok { if !ok {
return nil, c.ctx.Err() return nil, c.ctx.Err()
+2 -2
View File
@@ -46,11 +46,11 @@ func (sf *CommitSubmoduleFile) getWebLinkInTargetRepo(ctx context.Context, moreL
return &SubmoduleWebLink{RepoWebLink: targetLink, CommitWebLink: targetLink + moreLinkPath} return &SubmoduleWebLink{RepoWebLink: targetLink, CommitWebLink: targetLink + moreLinkPath}
} }
if !sf.parsed { if !sf.parsed {
sf.parsed = true
parsedURL, err := giturl.ParseRepositoryURL(ctx, sf.refURL) parsedURL, err := giturl.ParseRepositoryURL(ctx, sf.refURL)
if err != nil { if err != nil {
return nil // do not mark as parsed, otherwise later calls would return a link with an empty target return nil
} }
sf.parsed = true
sf.parsedTargetLink = giturl.MakeRepositoryWebLink(parsedURL) sf.parsedTargetLink = giturl.MakeRepositoryWebLink(parsedURL)
} }
return &SubmoduleWebLink{RepoWebLink: sf.parsedTargetLink, CommitWebLink: sf.parsedTargetLink + moreLinkPath} return &SubmoduleWebLink{RepoWebLink: sf.parsedTargetLink, CommitWebLink: sf.parsedTargetLink + moreLinkPath}
@@ -37,11 +37,4 @@ func TestCommitSubmoduleLink(t *testing.T) {
assert.Equal(t, "/subpath/user/repo", wl.RepoWebLink) assert.Equal(t, "/subpath/user/repo", wl.RepoWebLink)
assert.Equal(t, "/subpath/user/repo/compare/1111...2222", wl.CommitWebLink) assert.Equal(t, "/subpath/user/repo/compare/1111...2222", wl.CommitWebLink)
}) })
t.Run("UnparsableURL", func(t *testing.T) {
// both calls share one instance on purpose: the second one used to see the cached parse result
sf := NewCommitSubmoduleFile("/any/repo-link", "full-path", "git@github.com:", "aaaa")
assert.Nil(t, sf.SubmoduleWebLinkTree(t.Context()))
assert.Nil(t, sf.SubmoduleWebLinkCompare(t.Context(), "1111", "2222"))
})
} }
+15 -11
View File
@@ -125,11 +125,6 @@ func syncGitConfig(ctx context.Context) (err error) {
return err return err
} }
// Gitea used to write "90", which git reads as a date in 1990
if err := configUnsetAll(ctx, "gc.reflogExpire", "90"); err != nil {
return err
}
// Apply user's git config options last so they take precedence over builtin defaults // Apply user's git config options last so they take precedence over builtin defaults
for k, v := range setting.GitConfig.Options { for k, v := range setting.GitConfig.Options {
if err = configSet(ctx, strings.ToLower(k), v); err != nil { if err = configSet(ctx, strings.ToLower(k), v); err != nil {
@@ -167,7 +162,7 @@ func configSet(ctx context.Context, key, value string) error {
return nil return nil
} }
if _, _, err = gitcmd.NewCommand("config", "--global", "--replace-all"). if _, _, err = gitcmd.NewCommand("config", "--global").
AddDynamicArguments(key, value). AddDynamicArguments(key, value).
RunStdString(ctx); err != nil { RunStdString(ctx); err != nil {
return fmt.Errorf("failed to set git global config %s, err: %w", key, err) return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
@@ -195,7 +190,7 @@ func configSetNonExist(ctx context.Context, key, value string) error {
} }
func configAddNonExist(ctx context.Context, key, value string) error { func configAddNonExist(ctx context.Context, key, value string) error {
_, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key, "^"+regexp.QuoteMeta(value)+"$").RunStdString(ctx) _, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(ctx)
if err == nil { if err == nil {
// already exist // already exist
return nil return nil
@@ -212,9 +207,18 @@ func configAddNonExist(ctx context.Context, key, value string) error {
} }
func configUnsetAll(ctx context.Context, key, value string) error { func configUnsetAll(ctx context.Context, key, value string) error {
_, _, err := gitcmd.NewCommand("config", "--global", "--unset-all").AddDynamicArguments(key, "^"+regexp.QuoteMeta(value)+"$").RunStdString(ctx) _, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx)
if err != nil && !gitcmd.IsErrorExitCode(err, 5) { // 5: no such key or no matching value if err == nil {
return fmt.Errorf("failed to unset git global config %s, err: %w", key, err) // exist, need to remove
_, _, err = gitcmd.NewCommand("config", "--global", "--unset-all").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(ctx)
if err != nil {
return fmt.Errorf("failed to unset git global config %s, err: %w", key, err)
}
return nil
} }
return nil if gitcmd.IsErrorExitCode(err, 1) {
// not exist
return nil
}
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
} }
-6
View File
@@ -32,9 +32,7 @@ func TestGitConfig(t *testing.T) {
assert.NoError(t, configSetNonExist(ctx, "test.key-a", "val-a-changed")) assert.NoError(t, configSetNonExist(ctx, "test.key-a", "val-a-changed"))
assert.False(t, gitConfigContains("key-a = val-a-changed")) assert.False(t, gitConfigContains("key-a = val-a-changed"))
assert.NoError(t, configAddNonExist(ctx, "test.key-a", "val-a2"))
assert.NoError(t, configSet(ctx, "test.key-a", "val-a-changed")) assert.NoError(t, configSet(ctx, "test.key-a", "val-a-changed"))
assert.NoError(t, configUnsetAll(ctx, "test.key-a", "val-a"))
assert.True(t, gitConfigContains("key-a = val-a-changed")) assert.True(t, gitConfigContains("key-a = val-a-changed"))
assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val-b")) assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val-b"))
@@ -43,8 +41,6 @@ func TestGitConfig(t *testing.T) {
assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val-2b")) assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val-2b"))
assert.True(t, gitConfigContains("key-b = val-b")) assert.True(t, gitConfigContains("key-b = val-b"))
assert.True(t, gitConfigContains("key-b = val-2b")) assert.True(t, gitConfigContains("key-b = val-2b"))
assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val"))
assert.True(t, gitConfigContains("key-b = val\n"))
assert.NoError(t, configUnsetAll(ctx, "test.key-b", "val-b")) assert.NoError(t, configUnsetAll(ctx, "test.key-b", "val-b"))
assert.False(t, gitConfigContains("key-b = val-b")) assert.False(t, gitConfigContains("key-b = val-b"))
@@ -64,10 +60,8 @@ func TestSyncGitConfig(t *testing.T) {
defer test.MockVariableValue(&setting.GitConfig)() defer test.MockVariableValue(&setting.GitConfig)()
assert.Empty(t, setting.GitConfig.Options) assert.Empty(t, setting.GitConfig.Options)
assert.NoError(t, configSet(t.Context(), "gc.reflogExpire", "90"))
assert.NoError(t, syncGitConfig(t.Context())) assert.NoError(t, syncGitConfig(t.Context()))
assert.True(t, gitConfigContains("commitGraph = true")) // builtin default config assert.True(t, gitConfigContains("commitGraph = true")) // builtin default config
assert.False(t, gitConfigContains("reflogExpire"))
setting.GitConfig.Options["sync-test.cfg-key-a"] = "CfgValA" setting.GitConfig.Options["sync-test.cfg-key-a"] = "CfgValA"
setting.GitConfig.Options["core.commitgraph"] = "false" setting.GitConfig.Options["core.commitgraph"] = "false"
-4
View File
@@ -27,7 +27,6 @@ type FastImportFile struct {
type FastImportCommit struct { type FastImportCommit struct {
Ref string Ref string
Parent string
Message string Message string
Files []FastImportFile Files []FastImportFile
@@ -73,9 +72,6 @@ func ForceFastImport(ctx context.Context, repo RepositoryFacade, commits []FastI
msg := util.IfZero(c.Message, fmt.Sprintf("test commit %d", i+1)) msg := util.IfZero(c.Message, fmt.Sprintf("test commit %d", i+1))
_, _ = fmt.Fprintf(buf, "data %d\n%s\n", len(msg), msg) _, _ = fmt.Fprintf(buf, "data %d\n%s\n", len(msg), msg)
if c.Parent != "" {
_, _ = fmt.Fprintf(buf, "from %s\n", c.Parent)
}
for _, f := range c.Files { for _, f := range c.Files {
mode := util.IfZero(f.Mode, EntryModeBlob) mode := util.IfZero(f.Mode, EntryModeBlob)
+1 -1
View File
@@ -21,7 +21,7 @@ import (
// merge base between commits. // merge base between commits.
func FetchRemoteCommit(ctx context.Context, repo, remoteRepo RepositoryFacade, commitID string) error { func FetchRemoteCommit(ctx context.Context, repo, remoteRepo RepositoryFacade, commitID string) error {
return LockWriteAndDo(ctx, repo, func(ctx context.Context) error { return LockWriteAndDo(ctx, repo, func(ctx context.Context) error {
return gitcmd.NewCommand("fetch", "--no-tags", "--no-write-commit-graph"). return gitcmd.NewCommand("fetch", "--no-tags").
AddDynamicArguments(gitrepo.RepoLocalPath(remoteRepo)). AddDynamicArguments(gitrepo.RepoLocalPath(remoteRepo)).
AddDynamicArguments(commitID). AddDynamicArguments(commitID).
WithRepo(repo).Run(ctx) WithRepo(repo).Run(ctx)
+5 -10
View File
@@ -49,8 +49,8 @@ type Command struct {
cmd *process.Cmd cmd *process.Cmd
cmdCtx context.Context cmdCtx context.Context
cmdCtxCancel process.CancelCauseFunc cmdCancel process.CancelCauseFunc
cmdFinished func() cmdFinished process.FinishedFunc
cmdStartTime time.Time cmdStartTime time.Time
pipelineFunc func(Context) error pipelineFunc func(Context) error
@@ -428,24 +428,19 @@ func (c *Command) Start(ctx context.Context) (retErr error) {
if c.callerInfo == "" { if c.callerInfo == "" {
c.WithParentCallerInfo() c.WithParentCallerInfo()
} }
// these logs are for debugging purposes only, so no guarantee of correctness or stability // these logs are for debugging purposes only, so no guarantee of correctness or stability
desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.callerInfo, logArgSanitize(c.gitDir), cmdLogString) desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.callerInfo, logArgSanitize(c.gitDir), cmdLogString)
log.Debug("git.Command: %s", desc) log.Debug("git.Command: %s", desc)
_, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun) _, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun)
defer span.End()
span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.callerInfo) span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.callerInfo)
span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString) span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString)
var cmdCtxFinished func()
if c.cmdTimeout <= 0 { if c.cmdTimeout <= 0 {
c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = process.GetManager().AddContext(ctx, desc) c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContext(ctx, desc)
} else { } else {
c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = process.GetManager().AddContextTimeout(ctx, c.cmdTimeout, desc) c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContextTimeout(ctx, c.cmdTimeout, desc)
}
c.cmdFinished = func() {
cmdCtxFinished()
span.End()
} }
c.cmdStartTime = time.Now() c.cmdStartTime = time.Now()
+1 -1
View File
@@ -27,6 +27,6 @@ func (c *cmdContext) CancelPipeline(err error) error {
// * context canceled by pipeline caller with/without error (normal cancellation) // * context canceled by pipeline caller with/without error (normal cancellation)
// * context canceled by parent context (still context.Canceled error) // * context canceled by parent context (still context.Canceled error)
// * other causes // * other causes
c.cmd.cmdCtxCancel(pipelineError{err}) c.cmd.cmdCancel(pipelineError{err})
return err return err
} }
+7 -11
View File
@@ -56,17 +56,13 @@ func parseLsTreeLine(line []byte) (*LsTreeEntry, error) {
return nil, fmt.Errorf("invalid ls-tree output (invalid object id): %q, err: %w", line, err) return nil, fmt.Errorf("invalid ls-tree output (invalid object id): %q, err: %w", line, err)
} }
entry.Name, err = UnquoteDiffFilePath(string(entryName)) if len(entryName) > 0 && entryName[0] == '"' {
if err != nil { entry.Name, err = strconv.Unquote(string(entryName))
return nil, fmt.Errorf("invalid ls-tree output (invalid name): %q, err: %w", line, err) if err != nil {
return nil, fmt.Errorf("invalid ls-tree output (invalid name): %q, err: %w", line, err)
}
} else {
entry.Name = string(entryName)
} }
return entry, nil return entry, nil
} }
func UnquoteDiffFilePath(path string) (string, error) {
// ref: see the document of git config's "core.quotepath"
if !strings.HasPrefix(path, `"`) {
return path, nil
}
return strconv.Unquote(path)
}
+13 -91
View File
@@ -8,13 +8,9 @@ package git
import ( import (
"errors" "errors"
"io"
"os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings"
"gitea.dev/modules/container"
"gitea.dev/modules/git/gitrepo" "gitea.dev/modules/git/gitrepo"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
@@ -24,7 +20,6 @@ import (
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/cache" "github.com/go-git/go-git/v5/plumbing/cache"
"github.com/go-git/go-git/v5/storage/filesystem" "github.com/go-git/go-git/v5/storage/filesystem"
"github.com/go-git/go-git/v5/storage/filesystem/dotgit"
) )
const isGogit = true const isGogit = true
@@ -36,98 +31,25 @@ type Repository struct {
gogitStorage *reindexingStorage gogitStorage *reindexingStorage
} }
// reindexingStorage reloads the pack index when git added or removed packs after go-git loaded it // reindexingStorage picks up packs that git wrote after go-git loaded its index
// https://github.com/go-git/go-git/issues/2439 https://github.com/go-git/go-git/issues/1623 // https://github.com/go-git/go-git/issues/2439
// FIXME: gogit workaround, remove with the gogit build
type reindexingStorage struct { type reindexingStorage struct {
*filesystem.Storage *filesystem.Storage
packs []plumbing.Hash packs []plumbing.Hash
} }
func isRepackError(err error) bool { func (s *reindexingStorage) EncodedObject(t plumbing.ObjectType, h plumbing.Hash) (plumbing.EncodedObject, error) {
return errors.Is(err, plumbing.ErrObjectNotFound) || errors.Is(err, dotgit.ErrPackfileNotFound) || errors.Is(err, os.ErrNotExist) obj, err := s.Storage.EncodedObject(t, h)
} if !errors.Is(err, plumbing.ErrObjectNotFound) {
return obj, err
// retry reruns fn while a concurrent repack keeps changing the packs
func (s *reindexingStorage) retry(fn func() error) error {
for {
err := fn()
if !isRepackError(err) {
return err
}
packs, _ := s.ObjectPacks()
if slices.Equal(packs, s.packs) {
return err
}
s.packs = packs
s.Reindex()
} }
} packs, _ := s.ObjectPacks()
if slices.Equal(packs, s.packs) {
func (s *reindexingStorage) EncodedObject(t plumbing.ObjectType, h plumbing.Hash) (obj plumbing.EncodedObject, err error) { return obj, err
err = s.retry(func() (err error) {
obj, err = s.Storage.EncodedObject(t, h)
return err
})
if err != nil {
return nil, err
} }
if _, ok := obj.(*plumbing.MemoryObject); ok { s.packs = packs
return obj, nil s.Reindex()
} return s.Storage.EncodedObject(t, h)
return &lazyObject{EncodedObject: obj, storage: s}, nil
}
// lazyObject looks up a large object again when its file got removed before Reader reopened it
// FIXME: gogit workaround, remove with the gogit build
type lazyObject struct {
plumbing.EncodedObject
storage *reindexingStorage
}
func (o *lazyObject) Reader() (rc io.ReadCloser, err error) {
rc, err = o.EncodedObject.Reader()
if !isRepackError(err) {
return rc, err
}
err = o.storage.retry(func() error {
obj, err := o.storage.Storage.EncodedObject(o.Type(), o.Hash())
if err == nil {
o.EncodedObject = obj
rc, err = obj.Reader()
}
return err
})
return rc, err
}
// packIdxFS lists packs like git, only while their .idx exists
// FIXME: gogit workaround, remove with the gogit build
type packIdxFS struct {
billy.Filesystem
}
func (f packIdxFS) ReadDir(dir string) ([]os.FileInfo, error) {
if dir != f.Join("objects", "pack") {
return f.Filesystem.ReadDir(dir)
}
dirFile, err := os.Open(filepath.Join(f.Root(), dir))
if err != nil {
return nil, err
}
defer dirFile.Close()
infos, err := dirFile.Readdir(-1) // skips files removed before their lstat, unlike billy's ReadDir
if err != nil {
return nil, err
}
names := make(container.Set[string], len(infos))
for _, info := range infos {
names.Add(info.Name())
}
return slices.DeleteFunc(infos, func(info os.FileInfo) bool {
base, isPack := strings.CutSuffix(info.Name(), ".pack")
return isPack && !names.Contains(base+".idx")
}), nil
} }
func openRepositoryInternal(gitRepo *Repository) error { func openRepositoryInternal(gitRepo *Repository) error {
@@ -149,7 +71,7 @@ func openRepositoryInternal(gitRepo *Repository) error {
altFs = osfs.New("/") altFs = osfs.New("/")
} }
gitRepo.objectFormatCache = ParseGogitHash(plumbing.ZeroHash).Type() gitRepo.objectFormatCache = ParseGogitHash(plumbing.ZeroHash).Type()
storage := filesystem.NewStorageWithOptions(packIdxFS{fs}, cache.NewObjectLRUDefault(), filesystem.Options{KeepDescriptors: true, LargeObjectThreshold: setting.Git.LargeObjectThreshold, AlternatesFS: altFs}) storage := filesystem.NewStorageWithOptions(fs, cache.NewObjectLRUDefault(), filesystem.Options{KeepDescriptors: true, LargeObjectThreshold: setting.Git.LargeObjectThreshold, AlternatesFS: altFs})
packs, _ := storage.ObjectPacks() packs, _ := storage.ObjectPacks()
gitRepo.gogitStorage = &reindexingStorage{Storage: storage, packs: packs} gitRepo.gogitStorage = &reindexingStorage{Storage: storage, packs: packs}
gitRepo.gogitRepo, err = gogit.Open(gitRepo.gogitStorage, fs) gitRepo.gogitRepo, err = gogit.Open(gitRepo.gogitStorage, fs)
-41
View File
@@ -4,15 +4,9 @@
package git package git
import ( import (
"fmt"
"os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -45,41 +39,6 @@ func TestRepository_GetBranches(t *testing.T) {
assert.ElementsMatch(t, []string{}, branches) assert.ElementsMatch(t, []string{}, branches)
} }
// FIXME: covers the gogit workarounds in repo_base_gogit.go, remove with the gogit build
func TestReadsAfterConcurrentRepack(t *testing.T) {
repoDir := t.TempDir()
require.NoError(t, gitcmd.NewCommand("init", "--bare").AddDynamicArguments(repoDir).Run(t.Context()))
content := strings.Repeat("a", int(setting.Git.LargeObjectThreshold)+1)
for _, from := range []string{"", "from refs/heads/main^0\n"} {
stdin := fmt.Sprintf("commit refs/heads/main\ncommitter a <a@a> 0 +0000\ndata 0\n%sM 100644 inline f\ndata %d\n%s\n", from, len(content), content)
require.NoError(t, gitcmd.NewCommand("fast-import").WithDir(repoDir).WithStdinBytes([]byte(stdin)).Run(t.Context()))
require.NoError(t, gitcmd.NewCommand("repack", "-d").WithDir(repoDir).Run(t.Context()))
}
repo, err := OpenRepositoryLocal(t.Context(), repoDir)
require.NoError(t, err)
defer repo.Close()
require.False(t, repo.IsObjectExist(t.Context(), "0000000000000000000000000000000000000001"))
blobRepo, err := OpenRepositoryLocal(t.Context(), repoDir)
require.NoError(t, err)
defer blobRepo.Close()
commit, err := blobRepo.GetBranchCommit(t.Context(), "main")
require.NoError(t, err)
readBlob := func() string {
data, err := commit.GetFileContent(t.Context(), blobRepo, "f", len(content))
require.NoError(t, err)
return data
}
require.Equal(t, content, readBlob())
require.NoError(t, gitcmd.NewCommand("repack", "-a", "-d").WithDir(repoDir).Run(t.Context()))
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "objects", "pack", "pack-"+strings.Repeat("1", 40)+".pack"), nil, 0o644))
branches, _, err := repo.GetBranchNames(t.Context(), 0, 0)
require.NoError(t, err)
assert.Equal(t, []string{"main"}, branches)
assert.Equal(t, content, readBlob())
}
func BenchmarkRepository_GetBranches(b *testing.B) { func BenchmarkRepository_GetBranches(b *testing.B) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
bareRepo1, err := OpenRepositoryLocal(b.Context(), bareRepo1Path) bareRepo1, err := OpenRepositoryLocal(b.Context(), bareRepo1Path)
+2 -20
View File
@@ -415,26 +415,8 @@ func (repo *Repository) GetCommitsFromIDs(ctx context.Context, commitIDs []strin
return commits return commits
} }
// IsCommitInRepo checks if the commit exists in the repository. // IsCommitInBranch check if the commit is on the branch
func IsCommitInRepo(ctx context.Context, repo gitrepo.RepositoryFacade, commitID string) (bool, error) {
// "--quiet" makes it exit with 1 and no output if the commit doesn't exist
_, _, err := gitcmd.NewCommand("rev-parse", "--verify", "--quiet").
AddDynamicArguments(commitID + "^{commit}").
WithRepo(repo).
RunStdString(ctx)
if gitcmd.IsErrorExitCode(err, 1) {
return false, nil
}
return err == nil, err
}
// IsCommitInBranch checks if the commit is on the branch.
// It returns false (no error) if the commit doesn't exist or isn't on the branch.
func IsCommitInBranch(ctx context.Context, repo gitrepo.RepositoryFacade, commitID, branch string) (r bool, err error) { func IsCommitInBranch(ctx context.Context, repo gitrepo.RepositoryFacade, commitID, branch string) (r bool, err error) {
if exist, err := IsCommitInRepo(ctx, repo, commitID); err != nil || !exist {
return false, err
}
stdout, _, err := gitcmd.NewCommand("branch", "--contains"). stdout, _, err := gitcmd.NewCommand("branch", "--contains").
AddDynamicArguments(commitID, branch). AddDynamicArguments(commitID, branch).
WithRepo(repo). WithRepo(repo).
@@ -442,7 +424,7 @@ func IsCommitInBranch(ctx context.Context, repo gitrepo.RepositoryFacade, commit
if err != nil { if err != nil {
return false, err return false, err
} }
return len(stdout) > 0, nil return len(stdout) > 0, err
} }
// GetCommitBranchStart returns the commit where the branch diverged // GetCommitBranchStart returns the commit where the branch diverged
-18
View File
@@ -70,20 +70,6 @@ func TestGetCommitWithBadCommitID(t *testing.T) {
assert.True(t, IsErrNotExist(err)) assert.True(t, IsErrNotExist(err))
} }
func TestIsCommitInRepo(t *testing.T) {
bareRepo1, err := OpenRepositoryLocal(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
assert.NoError(t, err)
defer bareRepo1.Close()
exist, err := IsCommitInRepo(t.Context(), bareRepo1, "2839944139e0de9737a044f78b0e4b40d989a9e3")
assert.NoError(t, err)
assert.True(t, exist)
exist, err = IsCommitInRepo(t.Context(), bareRepo1, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
assert.NoError(t, err)
assert.False(t, exist)
}
func TestIsCommitInBranch(t *testing.T) { func TestIsCommitInBranch(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
bareRepo1, err := OpenRepositoryLocal(t.Context(), bareRepo1Path) bareRepo1, err := OpenRepositoryLocal(t.Context(), bareRepo1Path)
@@ -97,10 +83,6 @@ func TestIsCommitInBranch(t *testing.T) {
result, err = IsCommitInBranch(t.Context(), bareRepo1, "2839944139e0de9737a044f78b0e4b40d989a9e3", "branch2") result, err = IsCommitInBranch(t.Context(), bareRepo1, "2839944139e0de9737a044f78b0e4b40d989a9e3", "branch2")
assert.NoError(t, err) assert.NoError(t, err)
assert.False(t, result) assert.False(t, result)
result, err = IsCommitInBranch(t.Context(), bareRepo1, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "branch1")
assert.NoError(t, err)
assert.False(t, result)
} }
func TestRepository_CommitsBetween(t *testing.T) { func TestRepository_CommitsBetween(t *testing.T) {
+1 -1
View File
@@ -177,7 +177,7 @@ func initGlobCompiler(g *globCompiler, pattern string, separators []rune) (Glob,
regex, err := regexp.Compile(g.regexpPattern) regex, err := regexp.Compile(g.regexpPattern)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to compile glob regexp: %w", err) return nil, fmt.Errorf("failed to compile regexp: %w", err)
} }
g.regexp = regex g.regexp = regex
-2
View File
@@ -122,8 +122,6 @@ func (t *Tracer) Start(ctx context.Context, spanName string) (context.Context, *
ts.parent = parentSpan ts.parent = parentSpan
} }
// FIXME: this ctx handling is not right. The returned ctx should inherit the ctx passed in, but not from span's internal contexts
// The returned ctx only needs to inherit the values of the internal contexts of spans
parentCtx := ctx parentCtx := ctx
for internalSpanIdx, tsp := range starters { for internalSpanIdx, tsp := range starters {
var internalSpan traceSpanInternal var internalSpan traceSpanInternal
+4 -7
View File
@@ -6,17 +6,14 @@ package gtprof
// Some interesting names could be found in https://github.com/open-telemetry/opentelemetry-go/tree/main/semconv // Some interesting names could be found in https://github.com/open-telemetry/opentelemetry-go/tree/main/semconv
const ( const (
TraceSpanContext = "context"
TraceSpanHTTP = "http" TraceSpanHTTP = "http"
TraceSpanGitRun = "git-run" TraceSpanGitRun = "git-run"
TraceSpanDatabase = "database" TraceSpanDatabase = "database"
) )
const ( const (
TraceAttrGeneralName = "general.name" TraceAttrFuncCaller = "func.caller"
TraceAttrGeneralDesc = "general.desc" TraceAttrDbSQL = "db.sql"
TraceAttrFuncCaller = "func.caller" TraceAttrGitCommand = "git.command"
TraceAttrDbSQL = "db.sql" TraceAttrHTTPRoute = "http.route"
TraceAttrGitCommand = "git.command"
TraceAttrHTTPRoute = "http.route"
) )
-105
View File
@@ -1,105 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package htmlutil
import (
"strings"
)
type SrcSetCandidate struct {
SrcLink string
SrcDesc []string
}
const (
asciiSpaces = " \t\n\r\f"
asciiSpacesAndComma = " \t\n\r\f,"
)
func ParseSrcset(srcset string) (candidates []SrcSetCandidate) {
// ref: https://html.spec.whatwg.org/multipage/images.html#parse-a-srcset-attribute
// It can't simply use "split" because comma can appear in URLs and descriptors can be parenthesized and contain commas.
extractLink := func() (string, bool) {
srcset = strings.TrimLeft(srcset, asciiSpacesAndComma)
if srcset == "" {
return "", false
}
end := strings.IndexAny(srcset, asciiSpaces)
if end == -1 {
end = len(srcset)
}
link := srcset[:end]
srcset = srcset[end:]
if strings.HasSuffix(link, ",") {
return strings.TrimRight(link, ","), false
}
return link, true
}
extractDesc := func() (string, bool) {
srcset = strings.TrimLeft(srcset, asciiSpaces)
if srcset == "" {
return "", false
}
inParentheses := false
for i := range len(srcset) {
c := srcset[i]
if inParentheses {
inParentheses = c != ')'
continue
}
if c == '(' {
inParentheses = true
continue
}
if strings.IndexByte(asciiSpacesAndComma, c) != -1 {
desc := srcset[:i]
srcset = srcset[i+1:]
return desc, c != ','
}
}
desc := srcset
srcset = ""
return desc, false
}
for {
link, hasDesc := extractLink()
if link == "" {
break
}
var descriptors []string
if hasDesc {
for {
desc, hasMoreDesc := extractDesc()
if desc != "" {
descriptors = append(descriptors, desc)
}
if !hasMoreDesc {
break
}
}
}
candidates = append(candidates, SrcSetCandidate{SrcLink: link, SrcDesc: descriptors})
}
return candidates
}
func EncodeSrcset(candidates []SrcSetCandidate) string {
var sb strings.Builder
for i, c := range candidates {
if i != 0 {
sb.WriteString(", ")
}
link := c.SrcLink
if prefix, ok := strings.CutSuffix(link, ","); ok {
link = prefix + "%2C"
}
sb.WriteString(link)
for _, d := range c.SrcDesc {
sb.WriteByte(' ')
sb.WriteString(d)
}
}
return sb.String()
}
-81
View File
@@ -1,81 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package htmlutil
import (
"testing"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
)
func TestSrcset(t *testing.T) {
for _, test := range []struct {
name string
input string
candidates []SrcSetCandidate
encoded string
}{
{
name: "bare URL",
input: "image.png",
candidates: []SrcSetCandidate{{SrcLink: "image.png"}},
},
{
name: "bare URLs",
input: "a.png, b.png , c.png,",
candidates: []SrcSetCandidate{{SrcLink: "a.png"}, {SrcLink: "b.png"}, {SrcLink: "c.png"}},
encoded: "a.png, b.png, c.png",
},
{
name: "empty candidates",
input: ", ,\ta.png 1x,, ,b.png 2x, ,",
candidates: []SrcSetCandidate{{SrcLink: "a.png", SrcDesc: []string{"1x"}}, {SrcLink: "b.png", SrcDesc: []string{"2x"}}},
encoded: "a.png 1x, b.png 2x",
},
{
name: "trailing commas in URL",
input: "a.png,, b.png 2x",
candidates: []SrcSetCandidate{{SrcLink: "a.png"}, {SrcLink: "b.png", SrcDesc: []string{"2x"}}},
encoded: "a.png, b.png 2x",
},
{
name: "comma in URL",
input: "comma,in,url.png",
candidates: []SrcSetCandidate{{SrcLink: "comma,in,url.png"}},
},
{
name: "embedded data URL",
input: "data:image/png;base64,abcd 1x, image.png 2x",
candidates: []SrcSetCandidate{{SrcLink: "data:image/png;base64,abcd", SrcDesc: []string{"1x"}}, {SrcLink: "image.png", SrcDesc: []string{"2x"}}},
},
{
name: "parenthesized descriptor",
input: "a.png (x,y) 1x, b.png 2x",
candidates: []SrcSetCandidate{{SrcLink: "a.png", SrcDesc: []string{"(x,y)", "1x"}}, {SrcLink: "b.png", SrcDesc: []string{"2x"}}},
},
} {
t.Run(test.name, func(t *testing.T) {
candidates := ParseSrcset(test.input)
assert.Equal(t, test.candidates, candidates)
encoded := EncodeSrcset(candidates)
assert.Equal(t, util.IfZero(test.encoded, test.input), encoded)
})
}
t.Run("encode trailing commas", func(t *testing.T) {
encoded := EncodeSrcset([]SrcSetCandidate{
{SrcLink: "image,"},
{SrcLink: "image,,", SrcDesc: []string{"2x"}},
{SrcLink: "image.svg?variant=light,", SrcDesc: []string{"3x"}},
})
assert.Equal(t, "image%2C, image,%2C 2x, image.svg?variant=light%2C 3x", encoded)
assert.Equal(t, []SrcSetCandidate{
{SrcLink: "image%2C"},
{SrcLink: "image,%2C", SrcDesc: []string{"2x"}},
{SrcLink: "image.svg?variant=light%2C", SrcDesc: []string{"3x"}},
}, ParseSrcset(encoded))
})
}
+1 -3
View File
@@ -99,9 +99,7 @@ func handleGenericETagTimeCache(req *http.Request, w http.ResponseWriter, etag s
return true return true
} }
} }
// https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3 if lastModified != nil && !lastModified.IsZero() {
// A recipient MUST ignore If-Modified-Since if the request contains an If-None-Match header field
if lastModified != nil && !lastModified.IsZero() && req.Header.Get("If-None-Match") == "" {
ifModifiedSince := req.Header.Get("If-Modified-Since") ifModifiedSince := req.Header.Get("If-Modified-Since")
if ifModifiedSince != "" { if ifModifiedSince != "" {
t, err := time.Parse(http.TimeFormat, ifModifiedSince) t, err := time.Parse(http.TimeFormat, ifModifiedSince)
-13
View File
@@ -76,19 +76,6 @@ func TestHandleGenericETagCache(t *testing.T) {
wantHeaders: map[string]string{"Last-Modified": lastModified, "Cache-Control": "", "Etag": weakEtag}, wantHeaders: map[string]string{"Last-Modified": lastModified, "Cache-Control": "", "Etag": weakEtag},
wantStatus: http.StatusNotModified, wantStatus: http.StatusNotModified,
}, },
{
name: "If-Modified-Since without If-None-Match",
reqHeaders: map[string]string{"If-Modified-Since": lastModified},
wantHandled: true,
wantHeaders: map[string]string{"Last-Modified": lastModified, "Cache-Control": "", "Etag": matchedEtag},
wantStatus: http.StatusNotModified,
},
{
name: "Mismatched If-None-Match takes precedence over If-Modified-Since",
reqHeaders: map[string]string{"If-None-Match": `"mismatched-etag"`, "If-Modified-Since": lastModified},
wantHandled: false,
wantHeaders: map[string]string{"Last-Modified": lastModified, "Cache-Control": cacheControl, "Etag": matchedEtag},
},
{ {
name: "Multiple Matched If-None-Match", name: "Multiple Matched If-None-Match",
reqHeaders: map[string]string{"If-None-Match": `"mismatched-etag", ` + matchedEtag}, reqHeaders: map[string]string{"If-None-Match": `"mismatched-etag", ` + matchedEtag},
-4
View File
@@ -45,10 +45,6 @@ func MarshalKeepOptionalEmpty(v any) ([]byte, error) {
return jsonv2.Marshal(v, jsonV2.marshalKeepOptionalEmptyOptions) return jsonv2.Marshal(v, jsonV2.marshalKeepOptionalEmptyOptions)
} }
func MarshalDeterministic(v any) ([]byte, error) {
return jsonv2.Marshal(v, jsonV2.marshalOptions, jsonv2.Deterministic(true))
}
func (j *JSONv2) Marshal(v any) ([]byte, error) { func (j *JSONv2) Marshal(v any) ([]byte, error) {
return jsonv2.Marshal(v, j.marshalOptions) return jsonv2.Marshal(v, j.marshalOptions)
} }
+1 -5
View File
@@ -8,16 +8,12 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestNewClient(t *testing.T) { func TestNewClient(t *testing.T) {
u, _ := url.Parse("file:///test") u, _ := url.Parse("file:///test")
c := newClient(u, nil) c := newClient(u, nil)
require.IsType(t, &FilesystemClient{}, c) assert.IsType(t, &FilesystemClient{}, c)
localPath, err := c.(*FilesystemClient).objectPath("invalid") //nolint:forcetypeassert // type is expected
assert.Empty(t, localPath)
assert.ErrorIs(t, err, ErrInvalidOIDFormat)
u, _ = url.Parse("https://test.com/lfs") u, _ = url.Parse("https://test.com/lfs")
c = newClient(u, nil) c = newClient(u, nil)
+7 -15
View File
@@ -18,7 +18,7 @@ type FilesystemClient struct {
lfsDir string lfsDir string
} }
// BatchSize returns the preferred size of batches to process // BatchSize returns the preferred size of batchs to process
func (c *FilesystemClient) BatchSize() int { func (c *FilesystemClient) BatchSize() int {
return 1 return 1
} }
@@ -29,12 +29,8 @@ func newFilesystemClient(endpoint *url.URL) *FilesystemClient {
return &FilesystemClient{lfsDir} return &FilesystemClient{lfsDir}
} }
func (c *FilesystemClient) objectPath(oid string) (string, error) { func (c *FilesystemClient) objectPath(oid string) string {
if !isValidOid(oid) { return filepath.Join(c.lfsDir, oid[0:2], oid[2:4], oid)
return "", ErrInvalidOIDFormat
}
// HINT: LFS-INTERNAL-PATH-PATTERN
return filepath.Join(c.lfsDir, oid[0:2], oid[2:4], oid), nil
} }
// Download reads the specific LFS object from the target path // Download reads the specific LFS object from the target path
@@ -42,10 +38,8 @@ func (c *FilesystemClient) Download(ctx context.Context, objects []Pointer, call
for _, object := range objects { for _, object := range objects {
p := Pointer{object.Oid, object.Size} p := Pointer{object.Oid, object.Size}
objectPath, err := c.objectPath(p.Oid) objectPath := c.objectPath(p.Oid)
if err != nil {
return err
}
f, err := os.Open(objectPath) f, err := os.Open(objectPath)
if err != nil { if err != nil {
return err return err
@@ -63,10 +57,8 @@ func (c *FilesystemClient) Upload(ctx context.Context, objects []Pointer, callba
for _, object := range objects { for _, object := range objects {
p := Pointer{object.Oid, object.Size} p := Pointer{object.Oid, object.Size}
objectPath, err := c.objectPath(p.Oid) objectPath := c.objectPath(p.Oid)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(objectPath), os.ModePerm); err != nil { if err := os.MkdirAll(filepath.Dir(objectPath), os.ModePerm); err != nil {
return err return err
} }
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff. Show more