mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-09 04:30:16 +09:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ebd5e319b | |||
| d6945fb0b5 | |||
| 69a2bfcfbd | |||
| 15a0ea83ca | |||
| 30cc41ff97 | |||
| 7f672160de | |||
| e27f69bc88 | |||
| a67171af37 | |||
| c7bd37e901 | |||
| 0a4078558e | |||
| e66e773a6f | |||
| 08c123299a | |||
| 219b0e7c17 | |||
| 02fd18970b | |||
| efee6d7ce4 | |||
| 0ca36eeb3c | |||
| 3ee1924069 | |||
| 99ac787400 | |||
| 1856fef7a8 | |||
| 7440f1e452 | |||
| 638c75a4d3 | |||
| 575754cbeb | |||
| f24f3e1eda | |||
| 1f615a406f | |||
| 9c77a87908 | |||
| 4c3df3ab3c | |||
| 565957503c | |||
| e48591ba64 | |||
| f2a08e0261 | |||
| 532fb8f4f4 | |||
| 25416e9be7 | |||
| 1c4fff096f | |||
| d16daed041 | |||
| 2d58c8c3df | |||
| 1e28bb1bd7 | |||
| f0e8c3c3d0 | |||
| 7c58b73243 | |||
| 6546382f4e | |||
| 0930bd71fe | |||
| 15b8a5805a |
@@ -1,59 +0,0 @@
|
||||
# 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\/.+
|
||||
@@ -0,0 +1,59 @@
|
||||
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.
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: go-gitea/giteabot@4c9d4d3fd913b5c35f59dcc0b004a3d11d5b22bf # v1.0.7
|
||||
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
||||
gitea_fork: giteabot/gitea
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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"
|
||||
@@ -20,13 +20,12 @@ on:
|
||||
- closed
|
||||
- review_requested
|
||||
- review_request_removed
|
||||
# Review events keep review-derived state such as lgtm labels and status checks
|
||||
# in sync after approvals, edits, or dismissals.
|
||||
pull_request_review:
|
||||
# Reviews arrive through giteabot-review because fork PR review runs get no secrets
|
||||
workflow_run:
|
||||
workflows:
|
||||
- giteabot-review
|
||||
types:
|
||||
- submitted
|
||||
- edited
|
||||
- dismissed
|
||||
- requested
|
||||
# Periodic maintenance is still useful as a backstop for queue cleanup and
|
||||
# other housekeeping, even though main pushes now trigger it promptly.
|
||||
schedule:
|
||||
@@ -43,12 +42,12 @@ on:
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
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') }}
|
||||
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') }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
giteabot:
|
||||
if: github.repository == 'go-gitea/gitea'
|
||||
if: github.repository == 'go-gitea/gitea' && (github.event_name != 'workflow_run' || github.event.workflow_run.event == 'pull_request_review')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
@@ -57,9 +56,7 @@ jobs:
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
steps:
|
||||
# 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
|
||||
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITEABOT_TOKEN || github.token }}
|
||||
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
||||
checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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"
|
||||
@@ -1,149 +0,0 @@
|
||||
name: release-tag-rc
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v1*-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,9 +3,8 @@ name: release-tag-version
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v1.*"
|
||||
- "!v1*-rc*"
|
||||
- "!v1*-dev"
|
||||
- "v[0-9]*"
|
||||
- "!v[0-9]*-*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -73,12 +72,19 @@ jobs:
|
||||
uses: dev-hanz-ops/install-gh-cli-action@6089bdde54118ad7ca3d22053eb2d69387fd2779 # v0.3.0
|
||||
with:
|
||||
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"
|
||||
- uses: orhun/git-cliff-action@a9a95522b26fe6403f7bb24031f21fb573d0f5ff # v4.9.1
|
||||
with:
|
||||
args: --tag ${{ github.ref_name }} ${{ steps.range.outputs.range }}
|
||||
- name: create github release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release create "$TAG" --title "$TAG" --notes-from-tag dist/release/*
|
||||
gh release create "$TAG" --title "$TAG" --notes-file git-cliff/CHANGELOG.md dist/release/*
|
||||
|
||||
container:
|
||||
runs-on: namespace-profile-gitea-release-docker
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-6614
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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. \
|
||||
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:
|
||||
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:
|
||||
|
||||
```text
|
||||
type(scope)!: subject
|
||||
|
||||
@@ -136,7 +136,7 @@ WEB_DIRS := web_src/js web_src/css
|
||||
|
||||
ESLINT_FILES := web_src/js tools *.ts tests/e2e
|
||||
STYLELINT_FILES := web_src/css web_src/js/components/*.vue
|
||||
SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) templates options/locale/locale_en-US.json .github $(filter-out CHANGELOG.md, $(wildcard *.go *.md *.yml *.yaml *.toml))
|
||||
SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) templates options/locale/locale_en-US.json .github $(wildcard *.go *.md *.yml *.yaml *.toml)
|
||||
EDITORCONFIG_FILES := templates .github/workflows options/locale/locale_en-US.json
|
||||
|
||||
GO_SOURCES := $(wildcard *.go)
|
||||
|
||||
@@ -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?**
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
(more FAQs are listed in [FAQ documentation](https://docs.gitea.com/help/faq))
|
||||
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ Gitea 的发音是 [/ɡɪ’ti:/](https://youtu.be/EM71-2uDAoY),就像 "gi-tea
|
||||
|
||||
**在哪里可以找到安全补丁?**
|
||||
|
||||
在 [发布日志](https://github.com/go-gitea/gitea/releases) 或 [变更日志](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md) 中,搜索关键词 `SECURITY` 以找到安全补丁。
|
||||
在 [发布日志](https://github.com/go-gitea/gitea/releases) 中,搜索关键词 `SECURITY` 以找到安全补丁。
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ Gitea 的發音是 [/ɡɪ’ti:/](https://youtu.be/EM71-2uDAoY),就像 "gi-tea
|
||||
|
||||
**在哪裡可以找到安全補丁?**
|
||||
|
||||
在 [發佈日誌](https://github.com/go-gitea/gitea/releases) 或 [變更日誌](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md) 中,搜索關鍵詞 `SECURITY` 以找到安全補丁。
|
||||
在 [發佈日誌](https://github.com/go-gitea/gitea/releases) 中,搜索關鍵詞 `SECURITY` 以找到安全補丁。
|
||||
|
||||
## 許可證
|
||||
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
[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" },
|
||||
]
|
||||
+1
-1
@@ -87,7 +87,7 @@ echo "Checking currently installed version..."
|
||||
current=$(giteacmd --version | cut -d ' ' -f 3)
|
||||
[[ "$current" == "$giteaversion" ]] && echo "$current is already installed, stopping." && exit 0
|
||||
if [[ -z "${no_confirm:-}" ]]; then
|
||||
echo "Make sure to read the changelog first: https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md"
|
||||
echo "Make sure to read the changelog first: https://github.com/go-gitea/gitea/releases"
|
||||
echo "Are you ready to update Gitea from ${current} to ${giteaversion}? (y/N)"
|
||||
read -r confirm
|
||||
[[ "$confirm" == "y" ]] || [[ "$confirm" == "Y" ]] || exit 1
|
||||
|
||||
@@ -536,7 +536,7 @@ INTERNAL_TOKEN =
|
||||
;CONTENT_SECURITY_POLICY_GENERAL =
|
||||
;;
|
||||
;; Egress mode toggles between strictness of outgoing requests:
|
||||
;; Lax requires addresses to be allowed only if they are in private ranges, it allows all public ones
|
||||
;; Lax requires non-public targets (private, loopback, link-local, CGNAT and special-use ranges) to be allowed, it allows all public ones
|
||||
;; Strict requires an explicit allow of all addresses
|
||||
; EGRESS_MODE = lax
|
||||
;;
|
||||
@@ -551,10 +551,12 @@ INTERNAL_TOKEN =
|
||||
;; a bracketed set of ports and ranges, | separated: *.mydomain.com:[80|443|3000-3010]
|
||||
;; all ports: *.mydomain.com:*
|
||||
;; 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 private, loopback and CGNAT
|
||||
;; targets alone, public targets are allowed on every port whatever the list says. In Strict mode every
|
||||
;; Port specs apply only where the list is consulted: in Lax mode that is non-public 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.
|
||||
;; Reserved addresses like link-local and cloud metadata are denied
|
||||
;; Non-public targets need an IP or built-in entry, a host name entry alone never covers them.
|
||||
;; 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.
|
||||
;ALLOWED_HOST_LIST =
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@ 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
|
||||
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
|
||||
|
||||
- Never call `x.Update(exemplar)` without an explicit `WHERE` clause — it updates
|
||||
|
||||
+10
-26
@@ -8,10 +8,9 @@ This document describes the release cycle, backports, versioning, and the releas
|
||||
|
||||
We backport PRs given the following circumstances:
|
||||
|
||||
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. `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 new features.
|
||||
4. We never backport breaking changes except when
|
||||
1. We backport bug- and security-fixes and small enhancements. Large changes such as refactors are not backported.
|
||||
2. We never backport new features.
|
||||
3. We never backport breaking changes except when
|
||||
1. The breaking change has no effect on the vast majority of users
|
||||
2. The component triggering the breaking change is marked as experimental
|
||||
|
||||
@@ -53,7 +52,6 @@ We use a release schedule so work, stabilization, and releases stay predictable.
|
||||
### Cadence
|
||||
|
||||
- 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.*
|
||||
|
||||
### Release schedule
|
||||
@@ -65,16 +63,6 @@ We will try to publish a new major version every three months:
|
||||
- v1.28.0 in September 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
|
||||
|
||||
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.
|
||||
@@ -99,17 +87,13 @@ be reviewed by two maintainers and must pass the automatic tests.
|
||||
|
||||
## Releasing Gitea
|
||||
|
||||
- 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.
|
||||
- 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:
|
||||
- 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`.
|
||||
- When CI has finished building tag then you have to create a new branch named `release/vMAJOR.MINOR`
|
||||
- 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.
|
||||
Track each release using the [release issue template](https://github.com/go-gitea/gitea/issues/new?template=release.yaml).
|
||||
|
||||
- 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.
|
||||
- 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`.
|
||||
- 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.
|
||||
- Optionally send a PR to the [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:
|
||||
- bump the version of https://dl.gitea.com/gitea/version.json
|
||||
- verify the automated update of https://dl.gitea.com/gitea/version.json, where applicable to the release line
|
||||
- merge the blog post PR
|
||||
- announce the release in discord `#announcements`
|
||||
|
||||
@@ -109,7 +109,7 @@ require (
|
||||
modernc.org/sqlite v1.59.0
|
||||
mvdan.cc/xurls/v2 v2.6.0
|
||||
xorm.io/builder v0.3.13
|
||||
xorm.io/xorm v1.4.1
|
||||
xorm.io/xorm v1.4.3
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -862,5 +862,5 @@ pgregory.net/rapid v0.4.2 h1:lsi9jhvZTYvzVpeG93WWgimPRmiJQfGFRNTEZh1dtY0=
|
||||
pgregory.net/rapid v0.4.2/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU=
|
||||
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
|
||||
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
|
||||
xorm.io/xorm v1.4.1 h1:m7QlNd0eBGb31IV4Q/ow0Du83rtdC1CiwlvJZGvYde8=
|
||||
xorm.io/xorm v1.4.1/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
|
||||
xorm.io/xorm v1.4.3 h1:MwWFWzVr+/6D07qGCDhBAfABcuT0gvqY3XmTy1215BM=
|
||||
xorm.io/xorm v1.4.3/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"gitea.dev/modelmigration/v1_8"
|
||||
"gitea.dev/modelmigration/v1_9"
|
||||
"gitea.dev/modelmigration/v28"
|
||||
"gitea.dev/modelmigration/v29"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
@@ -429,6 +430,10 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(353, "Add audit event table", v28.AddAuditEventTable),
|
||||
newMigration(354, "Add Actions job queue indexes", v28.AddActionQueueIndexes),
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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))
|
||||
}
|
||||
@@ -39,9 +39,9 @@ type ActionRun struct {
|
||||
TriggerUserID int64 `xorm:"index"`
|
||||
TriggerUser *user_model.User `xorm:"-"`
|
||||
ScheduleID int64
|
||||
Ref string `xorm:"index"` // the commit/tag/… that caused the run
|
||||
IsRefDeleted bool `xorm:"-"`
|
||||
CommitSHA string
|
||||
Ref string `xorm:"index"` // the commit/tag/… that caused the run
|
||||
IsRefDeleted bool `xorm:"-"`
|
||||
CommitSHA string `xorm:"index"`
|
||||
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
|
||||
ApprovedBy int64 `xorm:"index"` // who approved
|
||||
@@ -295,9 +295,8 @@ func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, er
|
||||
return &run, nil
|
||||
}
|
||||
|
||||
func GetRunsByRepoAndID(ctx context.Context, repoID int64, runIDs []int64) ([]*ActionRun, error) {
|
||||
var runs []*ActionRun
|
||||
err := db.GetEngine(ctx).In("id", runIDs).Where("repo_id=?", repoID).Find(&runs)
|
||||
func GetRunsByRepoAndID(ctx context.Context, repoID int64, runIDs []int64) (runs []*ActionRun, err error) {
|
||||
err = db.GetEngine(ctx).In("id", runIDs).Where("repo_id=?", repoID).OrderBy("id").Find(&runs)
|
||||
return runs, err
|
||||
}
|
||||
|
||||
|
||||
@@ -298,6 +298,12 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
||||
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
|
||||
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 {
|
||||
if !runner.CanMatchLabels(v.RunsOn) {
|
||||
@@ -313,12 +319,9 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
||||
// Another runner claimed this job concurrently; try the next one.
|
||||
}
|
||||
|
||||
// A short page means no waiting jobs remain beyond it.
|
||||
if len(jobs) < pickTaskBatchSize {
|
||||
if isLastPage {
|
||||
return nil, false, nil
|
||||
}
|
||||
last := jobs[len(jobs)-1]
|
||||
cursorUpdated, cursorID = last.Updated, last.ID
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -564,6 +564,12 @@ func (grant *OAuth2Grant) SetNonce(ctx context.Context, nonce string) error {
|
||||
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
|
||||
func GetOAuth2GrantByID(ctx context.Context, id int64) (grant *OAuth2Grant, err error) {
|
||||
grant = new(OAuth2Grant)
|
||||
|
||||
@@ -5,7 +5,9 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
@@ -59,6 +61,11 @@ func InitEngine(ctx context.Context) error {
|
||||
xe.SetMaxIdleConns(setting.Database.MaxIdleConns)
|
||||
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 {
|
||||
xe.AddHook(&EngineHook{
|
||||
Threshold: setting.Database.SlowQueryThreshold,
|
||||
@@ -103,6 +110,10 @@ func InitEngineWithMigration(ctx context.Context, migrateFunc func(context.Conte
|
||||
|
||||
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.
|
||||
// If we do not then table schemas will be changed and there will be conflicts when the migrations run properly.
|
||||
//
|
||||
@@ -125,3 +136,12 @@ func InitEngineWithMigration(ctx context.Context, migrateFunc func(context.Conte
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+37
-34
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -35,11 +36,11 @@ import (
|
||||
// CommitStatus holds a single Status of a single Commit
|
||||
type CommitStatus struct {
|
||||
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)"`
|
||||
RepoID int64 `xorm:"INDEX UNIQUE(repo_sha_index)"`
|
||||
Repo *repo_model.Repository `xorm:"-"`
|
||||
State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"`
|
||||
SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"`
|
||||
|
||||
// TargetURL points to the commit status page reported by a CI system
|
||||
// If Gitea Actions is used, it is a relative link like "{RepoLink}/actions/runs/{RunID}/jobs{JobID}"
|
||||
@@ -311,17 +312,17 @@ func (opts *CommitStatusOptions) ToConds() builder.Cond {
|
||||
func (opts *CommitStatusOptions) ToOrders() string {
|
||||
switch opts.SortType {
|
||||
case "oldest":
|
||||
return "created_unix ASC"
|
||||
return "created_unix ASC, `index` ASC"
|
||||
case "recentupdate":
|
||||
return "updated_unix DESC"
|
||||
return "updated_unix DESC, `index` DESC"
|
||||
case "leastupdate":
|
||||
return "updated_unix ASC"
|
||||
return "updated_unix ASC, `index` ASC"
|
||||
case "leastindex":
|
||||
return "`index` DESC"
|
||||
case "highestindex":
|
||||
return "`index` ASC"
|
||||
default:
|
||||
return "created_unix DESC"
|
||||
return "created_unix DESC, `index` DESC" // timestamps have 1s resolution, `index` keeps paging stable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,46 +421,48 @@ func GetLatestCommitStatusForPairs(ctx context.Context, repoSHAs []RepoSHA) (map
|
||||
return repoStatuses, nil
|
||||
}
|
||||
|
||||
// GetLatestCommitStatusForRepoCommitIDs returns all statuses with a unique context for a given list of repo-sha pairs
|
||||
func GetLatestCommitStatusForRepoCommitIDs(ctx context.Context, repoID int64, commitIDs []string) (map[string][]*CommitStatus, error) {
|
||||
type result struct {
|
||||
// GetLatestCommitStatusForRepoCommitIDs returns the commit 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, allCommitIDs []string) (map[string][]*CommitStatus, error) {
|
||||
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
|
||||
SHA string
|
||||
}
|
||||
|
||||
getBase := func() db.Session {
|
||||
return db.GetEngine(ctx).Table(&CommitStatus{}).Where("repo_id = ?", repoID)
|
||||
}
|
||||
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)
|
||||
shaMaxIndexResults := make([]*shaMaxIndexResult, 0, len(allCommitIDs))
|
||||
err := baseSql().And(builder.In("sha", queryCommitIDs)).
|
||||
Select("max(`index`) as `index`, sha").
|
||||
GroupBy("context_hash, sha").
|
||||
Find(&shaMaxIndexResults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repoStatuses := make(map[string][]*CommitStatus)
|
||||
|
||||
if len(results) > 0 {
|
||||
statuses := make([]*CommitStatus, 0, len(results))
|
||||
|
||||
conds = make([]builder.Cond, 0, len(results))
|
||||
for _, result := range results {
|
||||
conds = append(conds, builder.Eq{"`index`": result.Index, "sha": result.SHA})
|
||||
for chunk := range slices.Chunk(shaMaxIndexResults, maxBatchSize) {
|
||||
statuses := make([]*CommitStatus, 0, len(chunk))
|
||||
condIndexSha := make([]builder.Cond, 0, len(chunk))
|
||||
for _, res := range chunk {
|
||||
condIndexSha = append(condIndexSha, builder.Eq{"`index`": res.Index, "sha": res.SHA})
|
||||
}
|
||||
err = getBase().And(builder.Or(conds...)).Find(&statuses)
|
||||
err = baseSql().And(builder.Or(condIndexSha...)).Find(&statuses)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Group the statuses by commit
|
||||
for _, status := range statuses {
|
||||
repoStatuses[status.SHA] = append(repoStatuses[status.SHA], status)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package git_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -22,46 +23,71 @@ func TestGetCommitStatuses(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
|
||||
|
||||
statuses, maxResults, err := db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{
|
||||
ListOptions: db.ListOptions{Page: 1, PageSize: 50},
|
||||
RepoID: repo1.ID,
|
||||
SHA: sha1,
|
||||
t.Run("CommitStatusOptions", func(t *testing.T) {
|
||||
statuses, maxResults, err := db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{
|
||||
ListOptions: db.ListOptions{Page: 1, PageSize: 50},
|
||||
RepoID: repo1.ID,
|
||||
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)
|
||||
|
||||
assert.Equal(t, "ci/awesomeness", statuses[0].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[0].APIURL(t.Context()))
|
||||
t.Run("GetCountLatestCommitStatus", func(t *testing.T) {
|
||||
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)
|
||||
|
||||
assert.Equal(t, "cov/awesomeness", statuses[1].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusWarning, statuses[1].State)
|
||||
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)
|
||||
assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[2].APIURL(t.Context()))
|
||||
|
||||
assert.Equal(t, "ci/awesomeness", statuses[3].Context)
|
||||
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()))
|
||||
|
||||
assert.Equal(t, "deploy/awesomeness", statuses[4].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusError, statuses[4].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[4].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,
|
||||
count, err := git_model.CountLatestCommitStatus(t.Context(), repo1.ID, sha1)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 3, count)
|
||||
})
|
||||
|
||||
t.Run("GetLatestCommitStatusForRepoCommitIDs", func(t *testing.T) {
|
||||
commitIDs := []string{sha1}
|
||||
// SQLite has a limit of 1000 for WHERE expression variables per query, use a larger slice to test
|
||||
for i := range 2000 {
|
||||
commitIDs = append(commitIDs, fmt.Sprintf("%040x", i+1))
|
||||
}
|
||||
statuses, err := git_model.GetLatestCommitStatusForRepoCommitIDs(t.Context(), repo1.ID, commitIDs)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, statuses[sha1], 3)
|
||||
latestIndexes := make(map[string]int64)
|
||||
for _, status := range statuses[sha1] {
|
||||
latestIndexes[status.Context] = status.Index
|
||||
}
|
||||
assert.Equal(t, map[string]int64{
|
||||
"ci/awesomeness": 4,
|
||||
"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) {
|
||||
@@ -260,26 +286,3 @@ func TestCommitStatusesApplyDoerPermission(t *testing.T) {
|
||||
assert.Empty(t, statuses[1].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)
|
||||
}
|
||||
|
||||
@@ -123,23 +123,13 @@ func (protectBranch *ProtectedBranch) LoadRepo(ctx context.Context) (err error)
|
||||
}
|
||||
|
||||
// CanUserPush returns if some user could push to this protected branch
|
||||
func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *user_model.User) bool {
|
||||
func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *user_model.User, permissionInRepo access_model.Permission) bool {
|
||||
if !protectBranch.CanPush {
|
||||
return false
|
||||
}
|
||||
|
||||
if !protectBranch.EnableWhitelist {
|
||||
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
|
||||
return permissionInRepo.CanWrite(unit.TypeCode)
|
||||
}
|
||||
|
||||
if slices.Contains(protectBranch.WhitelistUserIDs, user.ID) {
|
||||
@@ -160,17 +150,17 @@ func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *use
|
||||
|
||||
// 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
|
||||
func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user *user_model.User) bool {
|
||||
func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user *user_model.User, permissionInRepo access_model.Permission) bool {
|
||||
if !protectBranch.CanForcePush {
|
||||
return false
|
||||
}
|
||||
|
||||
if !protectBranch.EnableForcePushAllowlist {
|
||||
return protectBranch.CanUserPush(ctx, user)
|
||||
return protectBranch.CanUserPush(ctx, user, permissionInRepo)
|
||||
}
|
||||
|
||||
if slices.Contains(protectBranch.ForcePushAllowlistUserIDs, user.ID) {
|
||||
return protectBranch.CanUserPush(ctx, user)
|
||||
return protectBranch.CanUserPush(ctx, user, permissionInRepo)
|
||||
}
|
||||
|
||||
if len(protectBranch.ForcePushAllowlistTeamIDs) == 0 {
|
||||
@@ -182,7 +172,7 @@ func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user
|
||||
log.Error("IsUserInTeams: %v", err)
|
||||
return false
|
||||
}
|
||||
return in && protectBranch.CanUserPush(ctx, user)
|
||||
return in && protectBranch.CanUserPush(ctx, user, permissionInRepo)
|
||||
}
|
||||
|
||||
// IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch
|
||||
|
||||
@@ -407,6 +407,21 @@ func (pr *PullRequest) GetGitHeadRefName() string { // TODO: make it return RefN
|
||||
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)
|
||||
func (pr *PullRequest) GetReviewCommentsCount(ctx context.Context) int {
|
||||
opts := FindCommentsOptions{
|
||||
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type htmlChunkReader struct {
|
||||
@@ -30,6 +32,10 @@ type escapeStreamer struct {
|
||||
ambiguousTables []*AmbiguousTable
|
||||
allowed map[rune]bool
|
||||
|
||||
tagPartial []byte // partial tag content, used to detect if we are in some tags
|
||||
|
||||
inTagMath bool // MathML operators like U+2212 are intended and wrapping them breaks the math layout
|
||||
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
@@ -62,6 +68,7 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
|
||||
for i, part := range parts {
|
||||
if partInTag[i] {
|
||||
lastIsTag = true
|
||||
es.trackHtmlTag(part)
|
||||
if _, err := out.Write(part); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -75,7 +82,11 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err = es.detectAndWriteRunes(part); err != nil {
|
||||
if es.inTagMath {
|
||||
if _, err := out.Write(part); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err = es.detectAndWriteRunes(part); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -83,6 +94,34 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
|
||||
}
|
||||
}
|
||||
|
||||
// trackHtmlTag receives tag parts, a tag might be split into multiple parts
|
||||
func (e *escapeStreamer) trackHtmlTag(part []byte) {
|
||||
const maxHeadLen = 100 // only read the first N bytes of the tag for detection purpose
|
||||
if part[0] == '<' {
|
||||
// start a new tag
|
||||
e.tagPartial = e.tagPartial[:0]
|
||||
}
|
||||
if len(e.tagPartial) >= maxHeadLen {
|
||||
return
|
||||
}
|
||||
e.tagPartial = append(e.tagPartial, part[:min(len(part), maxHeadLen-len(e.tagPartial))]...)
|
||||
|
||||
isTag := func(prefix string) bool {
|
||||
if len(e.tagPartial) < len(prefix)+1 {
|
||||
return false
|
||||
}
|
||||
if !util.AsciiEqualFold(e.tagPartial[:len(prefix)], []byte(prefix)) {
|
||||
return false
|
||||
}
|
||||
return strings.IndexByte(" \t\n\r\f>", e.tagPartial[len(prefix)]) != -1
|
||||
}
|
||||
if isTag("<math") {
|
||||
e.inTagMath = true
|
||||
} else if isTag("</math") {
|
||||
e.inTagMath = false
|
||||
}
|
||||
}
|
||||
|
||||
func (e *escapeStreamer) trimAndWriteBom(part []byte) ([]byte, error) {
|
||||
remaining, ok := bytes.CutPrefix(part, globalVars().utf8Bom)
|
||||
if ok {
|
||||
|
||||
@@ -141,6 +141,12 @@ then resh (ר), and finally heh (ה) (which should appear leftmost).`,
|
||||
result: `O<span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character:𝐾 [U+1D43E],K [U+004B]"><span class="char">𝐾</span></span>`,
|
||||
status: EscapeStatus{Escaped: true, HasAmbiguous: true},
|
||||
},
|
||||
{
|
||||
name: "ambiguous in math",
|
||||
text: "<math><mo>−</mo><mi>b</mi></math> −",
|
||||
result: `<math><mo>−</mo><mi>b</mi></math> <span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character:− [U+2212],- [U+002D]"><span class="char">−</span></span>`,
|
||||
status: EscapeStatus{Escaped: true, HasAmbiguous: true},
|
||||
},
|
||||
}
|
||||
|
||||
func TestEscapeControlReader(t *testing.T) {
|
||||
@@ -156,6 +162,24 @@ func TestEscapeControlReader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackHtmlTag(t *testing.T) {
|
||||
e := &escapeStreamer{}
|
||||
for _, tt := range []struct {
|
||||
parts []string
|
||||
inMath bool
|
||||
}{
|
||||
{[]string{"<ma", `TH display="block">`}, true},
|
||||
{[]string{"<mo>"}, true},
|
||||
{[]string{"</MA", "th>"}, false},
|
||||
{[]string{"<mathx>"}, false},
|
||||
} {
|
||||
for _, part := range tt.parts {
|
||||
e.trackHtmlTag([]byte(part))
|
||||
}
|
||||
assert.Equal(t, tt.inMath, e.inTagMath, "%v", tt.parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingAmbiguousUnicodeDetection(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.UI.AmbiguousUnicodeDetection, true)()
|
||||
_, out := EscapeControlHTML("a test", &translation.MockLocale{})
|
||||
|
||||
@@ -98,6 +98,7 @@ func NewWebhookPolicy() *policy.Policy {
|
||||
}
|
||||
p = policy.NewPolicy("webhook", policyMode(setting.Security.EgressMode),
|
||||
policy.WithAllow(setting.Webhook.AllowedHostList, "security.ALLOWED_HOST_LIST"),
|
||||
policy.WithLocalNeedsIPAllow(),
|
||||
policy.WithProxy(selectProxy))
|
||||
|
||||
return p
|
||||
@@ -106,6 +107,7 @@ func NewWebhookPolicy() *policy.Policy {
|
||||
func NewSecurityPolicy(usage string) *policy.Policy {
|
||||
return policy.NewPolicy(usage, policyMode(setting.Security.EgressMode),
|
||||
policy.WithAllow(setting.Security.AllowedHostList, "security.ALLOWED_HOST_LIST"),
|
||||
policy.WithLocalNeedsIPAllow(),
|
||||
policy.WithProxy(proxy.Proxy()))
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
package egress
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/egress/policy"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
@@ -70,6 +73,27 @@ 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) {
|
||||
defer test.MockVariableValue(&setting.Security.AllowedHostList, "avatars.example.com")()
|
||||
defer test.MockVariableValue(&setting.Security.EgressMode, "lax")()
|
||||
|
||||
@@ -411,37 +411,43 @@ var cgnatRange = netip.MustParsePrefix("100.64.0.0/10") // RFC 6598
|
||||
// reservedRanges are never dialable, based on https://microsoft.github.io/AntiSSRF/ipaddressranges.html
|
||||
var reservedRanges = func() (ranges []netip.Prefix) {
|
||||
for _, cidr := range []string{
|
||||
"0.0.0.0/8", // "this network"
|
||||
"100.100.100.200/32", // Alibaba Cloud metadata
|
||||
"168.63.129.16/32", // Azure WireServer
|
||||
"169.254.0.0/16", // link-local, cloud metadata endpoints
|
||||
"192.0.0.0/24", // IETF protocol assignments
|
||||
"192.0.2.0/24", // TEST-NET-1
|
||||
"192.31.196.0/24", // AS112
|
||||
"192.52.193.0/24", // AMT
|
||||
"192.88.99.0/24", // 6to4 relay anycast
|
||||
"192.175.48.0/24", // AS112
|
||||
"198.18.0.0/15", // benchmarking
|
||||
"198.51.100.0/24", // TEST-NET-2
|
||||
"203.0.113.0/24", // TEST-NET-3
|
||||
"224.0.0.0/4", // multicast
|
||||
"240.0.0.0/4", // reserved, incl. limited broadcast
|
||||
"::/96", // IPv4-compatible, embeds IPv4
|
||||
"::ffff:0:0:0/96", // IPv4-translated, embeds IPv4
|
||||
"64:ff9b::/96", // wkp NAT64
|
||||
"64:ff9b:1::/48", // local-use NAT64
|
||||
"100::/64", // discard-only
|
||||
"100:0:0:1::/64", // dummy
|
||||
"2001::/23", // IETF protocol assignments, incl. Teredo and ORCHID
|
||||
"2001:db8::/32", // documentation
|
||||
"2002::/16", // 6to4, embeds IPv4
|
||||
"2620:4f:8000::/48", // AS112
|
||||
"3fff::/20", // documentation
|
||||
"5f00::/16", // SRv6 SIDs
|
||||
"fd00:ec2::254/128", // AWS IMDS
|
||||
"fe80::/10", // link-local
|
||||
"fec0::/10", // site-local
|
||||
"ff00::/8", // multicast
|
||||
"0.0.0.0/8", // "this network"
|
||||
"168.63.129.16/32", // Azure WireServer
|
||||
"192.88.99.0/24", // 6to4 relay anycast
|
||||
"224.0.0.0/4", // multicast
|
||||
"240.0.0.0/4", // reserved, incl. limited broadcast
|
||||
"::/96", // IPv4-compatible, embeds IPv4
|
||||
"::ffff:0:0:0/96", // IPv4-translated, embeds IPv4
|
||||
"64:ff9b::/96", // wkp NAT64
|
||||
"64:ff9b:1::/48", // local-use NAT64
|
||||
"2001::/32", // Teredo, embeds IPv4
|
||||
"2002::/16", // 6to4, embeds IPv4
|
||||
"ff00::/8", // multicast
|
||||
} {
|
||||
ranges = append(ranges, netip.MustParsePrefix(cidr))
|
||||
}
|
||||
return ranges
|
||||
}()
|
||||
|
||||
// restrictedRanges are dialable if they have been explicitly allowed.
|
||||
var restrictedRanges = func() (ranges []netip.Prefix) {
|
||||
for _, cidr := range []string{
|
||||
"192.0.0.0/24", // IETF protocol assignments
|
||||
"192.0.2.0/24", // TEST-NET-1
|
||||
"192.31.196.0/24", // AS112
|
||||
"192.52.193.0/24", // AMT
|
||||
"192.175.48.0/24", // AS112
|
||||
"198.18.0.0/15", // benchmarking
|
||||
"198.51.100.0/24", // TEST-NET-2
|
||||
"203.0.113.0/24", // TEST-NET-3
|
||||
"100::/64", // discard-only
|
||||
"100:0:0:1::/64", // dummy
|
||||
"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))
|
||||
}
|
||||
@@ -451,10 +457,14 @@ var reservedRanges = func() (ranges []netip.Prefix) {
|
||||
// classifyAddr reports the class of a canonical address.
|
||||
func classifyAddr(ip netip.Addr) addrClass {
|
||||
switch {
|
||||
case ip.Zone() != "" || !ip.IsLoopback() && slices.ContainsFunc(reservedRanges, func(p netip.Prefix) bool { return p.Contains(ip) }):
|
||||
case ip.Zone() != "" || !ip.IsLoopback() && inRange(reservedRanges, ip):
|
||||
return classReserved
|
||||
case ip.IsPrivate() || ip.IsLoopback() || cgnatRange.Contains(ip):
|
||||
case ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || cgnatRange.Contains(ip) || inRange(restrictedRanges, ip):
|
||||
return classRestricted
|
||||
}
|
||||
return classPublic
|
||||
}
|
||||
|
||||
func inRange(p []netip.Prefix, ip netip.Addr) bool {
|
||||
return slices.ContainsFunc(p, func(p netip.Prefix) bool { return p.Contains(ip) })
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func WithBlock(hostList, key string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithLocalNeedsIPAllow requires private, loopback and CGNAT targets to match an IP allow entry (CIDR or named range), a host name match is not enough.
|
||||
// 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.
|
||||
func WithLocalNeedsIPAllow() Option {
|
||||
return func(p *Policy) {
|
||||
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))
|
||||
}
|
||||
if !hostnameOk {
|
||||
return fmt.Errorf("%s needs an explicit IP allow entry (private/loopback/CGNAT)", denyTarget(host, ip))
|
||||
return fmt.Errorf("%s needs an explicit IP allow entry (non-public address)", denyTarget(host, ip))
|
||||
}
|
||||
return fmt.Errorf("%s needs an explicit allow entry (private/loopback/CGNAT)", denyTarget(host, ip))
|
||||
return fmt.Errorf("%s needs an explicit allow entry (non-public address)", denyTarget(host, ip))
|
||||
}
|
||||
|
||||
func (p *Policy) blockReason(host string, ip netip.AddrPort) error {
|
||||
|
||||
@@ -33,8 +33,16 @@ func TestCheckAddr(t *testing.T) {
|
||||
{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: "block overrides allow", allow: "10.0.0.0/8", block: "10.0.0.5/32", ip: "10.0.0.5"},
|
||||
{name: "reserved denied by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254"},
|
||||
{name: "reserved denied ipv4-mapped", allow: "169.254.0.0/16", ip: "::ffff:169.254.169.254"},
|
||||
{name: "non cloud link-local is default denied", ip: "::ffff:169.254.1.2"},
|
||||
{name: "link-local allowed by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254", want: true},
|
||||
{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 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},
|
||||
@@ -46,7 +54,8 @@ 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 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 reserved denied by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254", strict: true},
|
||||
{name: "strict reserved denied despite allow", allow: "168.63.129.16/32", ip: "168.63.129.16", 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 accepts builtin", allow: "private", ip: "100.64.0.1", localNeedsIPAllow: true, strict: true, want: true},
|
||||
} {
|
||||
@@ -63,7 +72,7 @@ func TestCheckAddr(t *testing.T) {
|
||||
mode = Strict
|
||||
}
|
||||
err := NewPolicy("test", mode, opts...).checkAddr(tc.host, netip.AddrPortFrom(addr, 80))
|
||||
assert.Equal(t, tc.want, err == nil, "%s: %v", tc.name, err)
|
||||
assert.Equal(t, tc.want, err == nil, "%s (%s): %v", tc.name, tc.ip, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,8 +124,9 @@ func TestCheckHostIPs(t *testing.T) {
|
||||
|
||||
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("100.100.100.200"))) // cloud metadata is opt-in with its containing range
|
||||
for _, ip := range []string{
|
||||
"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",
|
||||
"0.1.2.3", "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",
|
||||
"2002::1", "fe80::1",
|
||||
} {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// BatchChecker provides a reader for check-attribute content that can be long running
|
||||
@@ -120,12 +121,17 @@ func (c *BatchChecker) CheckPath(path string) (rs *Attributes, err error) {
|
||||
return fmt.Errorf("CheckPath timeout: %s", debugMsg)
|
||||
}
|
||||
|
||||
timeout := time.NewTimer(5 * time.Second)
|
||||
defer timeout.Stop()
|
||||
|
||||
rs = NewAttributes()
|
||||
for i := 0; i < c.attributesNum; i++ {
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-timeout.C:
|
||||
// there is no "hang" problem now. This code is just used to catch other potential problems.
|
||||
return nil, reportTimeout()
|
||||
err = reportTimeout()
|
||||
setting.PanicInDevOrTesting("Unexpected timeout, need to investigate: %v", err)
|
||||
return nil, err
|
||||
case attr, ok := <-c.stdOut.ReadAttribute():
|
||||
if !ok {
|
||||
return nil, c.ctx.Err()
|
||||
|
||||
@@ -46,11 +46,11 @@ func (sf *CommitSubmoduleFile) getWebLinkInTargetRepo(ctx context.Context, moreL
|
||||
return &SubmoduleWebLink{RepoWebLink: targetLink, CommitWebLink: targetLink + moreLinkPath}
|
||||
}
|
||||
if !sf.parsed {
|
||||
sf.parsed = true
|
||||
parsedURL, err := giturl.ParseRepositoryURL(ctx, sf.refURL)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil // do not mark as parsed, otherwise later calls would return a link with an empty target
|
||||
}
|
||||
sf.parsed = true
|
||||
sf.parsedTargetLink = giturl.MakeRepositoryWebLink(parsedURL)
|
||||
}
|
||||
return &SubmoduleWebLink{RepoWebLink: sf.parsedTargetLink, CommitWebLink: sf.parsedTargetLink + moreLinkPath}
|
||||
|
||||
@@ -37,4 +37,11 @@ func TestCommitSubmoduleLink(t *testing.T) {
|
||||
assert.Equal(t, "/subpath/user/repo", wl.RepoWebLink)
|
||||
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"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ type Command struct {
|
||||
cmd *process.Cmd
|
||||
|
||||
cmdCtx context.Context
|
||||
cmdCancel process.CancelCauseFunc
|
||||
cmdFinished process.FinishedFunc
|
||||
cmdCtxCancel process.CancelCauseFunc
|
||||
cmdFinished func()
|
||||
cmdStartTime time.Time
|
||||
|
||||
pipelineFunc func(Context) error
|
||||
@@ -428,19 +428,24 @@ func (c *Command) Start(ctx context.Context) (retErr error) {
|
||||
if c.callerInfo == "" {
|
||||
c.WithParentCallerInfo()
|
||||
}
|
||||
|
||||
// these logs are for debugging purposes only, so no guarantee of correctness or stability
|
||||
desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.callerInfo, logArgSanitize(c.gitDir), cmdLogString)
|
||||
log.Debug("git.Command: %s", desc)
|
||||
|
||||
_, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun)
|
||||
defer span.End()
|
||||
span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.callerInfo)
|
||||
span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString)
|
||||
|
||||
var cmdCtxFinished func()
|
||||
if c.cmdTimeout <= 0 {
|
||||
c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContext(ctx, desc)
|
||||
c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = process.GetManager().AddContext(ctx, desc)
|
||||
} else {
|
||||
c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContextTimeout(ctx, c.cmdTimeout, desc)
|
||||
c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = process.GetManager().AddContextTimeout(ctx, c.cmdTimeout, desc)
|
||||
}
|
||||
c.cmdFinished = func() {
|
||||
cmdCtxFinished()
|
||||
span.End()
|
||||
}
|
||||
|
||||
c.cmdStartTime = time.Now()
|
||||
|
||||
@@ -27,6 +27,6 @@ func (c *cmdContext) CancelPipeline(err error) error {
|
||||
// * context canceled by pipeline caller with/without error (normal cancellation)
|
||||
// * context canceled by parent context (still context.Canceled error)
|
||||
// * other causes
|
||||
c.cmd.cmdCancel(pipelineError{err})
|
||||
c.cmd.cmdCtxCancel(pipelineError{err})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,9 +8,13 @@ package git
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git/gitrepo"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
@@ -20,6 +24,7 @@ import (
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"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/dotgit"
|
||||
)
|
||||
|
||||
const isGogit = true
|
||||
@@ -31,25 +36,98 @@ type Repository struct {
|
||||
gogitStorage *reindexingStorage
|
||||
}
|
||||
|
||||
// reindexingStorage picks up packs that git wrote after go-git loaded its index
|
||||
// https://github.com/go-git/go-git/issues/2439
|
||||
// reindexingStorage reloads the pack index when git added or removed packs after go-git loaded it
|
||||
// https://github.com/go-git/go-git/issues/2439 https://github.com/go-git/go-git/issues/1623
|
||||
// FIXME: gogit workaround, remove with the gogit build
|
||||
type reindexingStorage struct {
|
||||
*filesystem.Storage
|
||||
packs []plumbing.Hash
|
||||
}
|
||||
|
||||
func (s *reindexingStorage) EncodedObject(t plumbing.ObjectType, h plumbing.Hash) (plumbing.EncodedObject, error) {
|
||||
obj, err := s.Storage.EncodedObject(t, h)
|
||||
if !errors.Is(err, plumbing.ErrObjectNotFound) {
|
||||
return obj, err
|
||||
func isRepackError(err error) bool {
|
||||
return errors.Is(err, plumbing.ErrObjectNotFound) || errors.Is(err, dotgit.ErrPackfileNotFound) || errors.Is(err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return obj, err
|
||||
}
|
||||
|
||||
func (s *reindexingStorage) EncodedObject(t plumbing.ObjectType, h plumbing.Hash) (obj plumbing.EncodedObject, err error) {
|
||||
err = s.retry(func() (err error) {
|
||||
obj, err = s.Storage.EncodedObject(t, h)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.packs = packs
|
||||
s.Reindex()
|
||||
return s.Storage.EncodedObject(t, h)
|
||||
if _, ok := obj.(*plumbing.MemoryObject); ok {
|
||||
return obj, nil
|
||||
}
|
||||
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 {
|
||||
@@ -71,7 +149,7 @@ func openRepositoryInternal(gitRepo *Repository) error {
|
||||
altFs = osfs.New("/")
|
||||
}
|
||||
gitRepo.objectFormatCache = ParseGogitHash(plumbing.ZeroHash).Type()
|
||||
storage := filesystem.NewStorageWithOptions(fs, cache.NewObjectLRUDefault(), filesystem.Options{KeepDescriptors: true, LargeObjectThreshold: setting.Git.LargeObjectThreshold, AlternatesFS: altFs})
|
||||
storage := filesystem.NewStorageWithOptions(packIdxFS{fs}, cache.NewObjectLRUDefault(), filesystem.Options{KeepDescriptors: true, LargeObjectThreshold: setting.Git.LargeObjectThreshold, AlternatesFS: altFs})
|
||||
packs, _ := storage.ObjectPacks()
|
||||
gitRepo.gogitStorage = &reindexingStorage{Storage: storage, packs: packs}
|
||||
gitRepo.gogitRepo, err = gogit.Open(gitRepo.gogitStorage, fs)
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -39,6 +45,41 @@ func TestRepository_GetBranches(t *testing.T) {
|
||||
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) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
bareRepo1, err := OpenRepositoryLocal(b.Context(), bareRepo1Path)
|
||||
|
||||
@@ -122,6 +122,8 @@ func (t *Tracer) Start(ctx context.Context, spanName string) (context.Context, *
|
||||
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
|
||||
for internalSpanIdx, tsp := range starters {
|
||||
var internalSpan traceSpanInternal
|
||||
|
||||
@@ -6,14 +6,17 @@ package gtprof
|
||||
// Some interesting names could be found in https://github.com/open-telemetry/opentelemetry-go/tree/main/semconv
|
||||
|
||||
const (
|
||||
TraceSpanContext = "context"
|
||||
TraceSpanHTTP = "http"
|
||||
TraceSpanGitRun = "git-run"
|
||||
TraceSpanDatabase = "database"
|
||||
)
|
||||
|
||||
const (
|
||||
TraceAttrFuncCaller = "func.caller"
|
||||
TraceAttrDbSQL = "db.sql"
|
||||
TraceAttrGitCommand = "git.command"
|
||||
TraceAttrHTTPRoute = "http.route"
|
||||
TraceAttrGeneralName = "general.name"
|
||||
TraceAttrGeneralDesc = "general.desc"
|
||||
TraceAttrFuncCaller = "func.caller"
|
||||
TraceAttrDbSQL = "db.sql"
|
||||
TraceAttrGitCommand = "git.command"
|
||||
TraceAttrHTTPRoute = "http.route"
|
||||
)
|
||||
|
||||
@@ -99,7 +99,9 @@ func handleGenericETagTimeCache(req *http.Request, w http.ResponseWriter, etag s
|
||||
return true
|
||||
}
|
||||
}
|
||||
if lastModified != nil && !lastModified.IsZero() {
|
||||
// https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3
|
||||
// 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")
|
||||
if ifModifiedSince != "" {
|
||||
t, err := time.Parse(http.TimeFormat, ifModifiedSince)
|
||||
|
||||
@@ -76,6 +76,19 @@ func TestHandleGenericETagCache(t *testing.T) {
|
||||
wantHeaders: map[string]string{"Last-Modified": lastModified, "Cache-Control": "", "Etag": weakEtag},
|
||||
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",
|
||||
reqHeaders: map[string]string{"If-None-Match": `"mismatched-etag", ` + matchedEtag},
|
||||
|
||||
@@ -45,6 +45,10 @@ func MarshalKeepOptionalEmpty(v any) ([]byte, error) {
|
||||
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) {
|
||||
return jsonv2.Marshal(v, j.marshalOptions)
|
||||
}
|
||||
|
||||
@@ -349,8 +349,8 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
|
||||
// TextNode emoji will be converted to `<span class="emoji">`, then the next iteration will visit the "span"
|
||||
// if we don't stop it, it will go into the TextNode again and create an infinite recursion
|
||||
return node.NextSibling
|
||||
} else if node.Data == "code" || node.Data == "pre" {
|
||||
return node.NextSibling // ignore code and pre nodes
|
||||
} else if node.Data == "code" || node.Data == "pre" || node.Data == "math" {
|
||||
return node.NextSibling // ignore code, pre and math nodes
|
||||
} else if node.Data == "img" {
|
||||
return visitNodeImg(ctx, node)
|
||||
} else if node.Data == "video" {
|
||||
|
||||
@@ -5,6 +5,7 @@ package markup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/references"
|
||||
@@ -26,12 +27,13 @@ func mentionProcessor(ctx *RenderContext, node *html.Node) {
|
||||
loc.Start += start
|
||||
loc.End += start
|
||||
mention := node.Data[loc.Start:loc.End]
|
||||
teams, ok := ctx.RenderOptions.Metas["teams"]
|
||||
orgLowerTeams, checkOrgTeams := ctx.RenderOptions.Metas["teams"] // in format ",team1,team2,...,team-n,", always lowercase
|
||||
|
||||
if ok && strings.Contains(mention, "/") {
|
||||
mentionOrgAndTeam := strings.Split(mention, "/")
|
||||
if mentionOrgAndTeam[0][1:] == ctx.RenderOptions.Metas["org"] && strings.Contains(teams, ","+strings.ToLower(mentionOrgAndTeam[1])+",") {
|
||||
link := fmt.Sprintf("/:root/org/%s/teams/%s", ctx.RenderOptions.Metas["org"], mentionOrgAndTeam[1])
|
||||
if checkOrgTeams && strings.Contains(mention, "/") {
|
||||
mentionOrg, teamName, _ := strings.Cut(mention, "/")
|
||||
orgName := mentionOrg[1:] // remove the '@' prefix
|
||||
if strings.EqualFold(orgName, ctx.RenderOptions.Metas["org"]) && strings.Contains(orgLowerTeams, ","+strings.ToLower(teamName)+",") {
|
||||
link := fmt.Sprintf("/:root/org/%s/teams/%s", url.PathEscape(orgName), url.PathEscape(teamName))
|
||||
replaceContent(node, loc.Start, loc.End, createLink(ctx, link, mention, "" /*mention*/))
|
||||
node = node.NextSibling.NextSibling
|
||||
start = 0
|
||||
@@ -43,7 +45,7 @@ func mentionProcessor(ctx *RenderContext, node *html.Node) {
|
||||
mentionedUsername := mention[1:]
|
||||
|
||||
if DefaultRenderHelperFuncs != nil && DefaultRenderHelperFuncs.IsUsernameMentionable(ctx, mentionedUsername) {
|
||||
link := "/:root/" + mentionedUsername
|
||||
link := "/:root/" + url.PathEscape(mentionedUsername)
|
||||
replaceContent(node, loc.Start, loc.End, createLink(ctx, link, mention, "" /*mention*/))
|
||||
node = node.NextSibling.NextSibling
|
||||
start = 0
|
||||
|
||||
@@ -543,6 +543,9 @@ func TestPostProcess(t *testing.T) {
|
||||
`Some text with <span class="emoji" data-alias="smile">😄</span> in the middle`)
|
||||
test("http://localhost:3000/person/repo/issues/4#issuecomment-1234",
|
||||
`<a href="http://localhost:3000/person/repo/issues/4#issuecomment-1234" class="ref-issue">person/repo#4 (comment)</a>`)
|
||||
test(
|
||||
"<math><mtext>:gitea: go-gitea/gitea#12345</mtext></math>",
|
||||
"<math><mtext>:gitea: go-gitea/gitea#12345</mtext></math>")
|
||||
|
||||
// special tags, GitHub's behavior, and for unclosed tags, output as text content as much as possible
|
||||
test("<script>a", `<script>a`)
|
||||
@@ -602,3 +605,18 @@ func TestIssue18471(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `<a href="`+markup.TestAppURL+`org/repo/compare/783b039...da951ce" class="compare"><code>783b039...da951ce</code></a>`, res.String())
|
||||
}
|
||||
|
||||
func TestRender_TeamMention(t *testing.T) {
|
||||
// the "org" meta is lower-cased, see Repository.composeCommonMetas
|
||||
metas := map[string]string{"user": "Org1", "repo": "repo1", "org": "org1", "teams": ",developers,"}
|
||||
test := func(input, expected string) {
|
||||
rctx := markup.NewTestRenderContext(markup.TestAppURL, metas).WithRelativePath("a.md")
|
||||
buffer, err := testRenderString(rctx, input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
test("@org1/developers", `<p><a href="/org/org1/teams/developers" rel="nofollow">@org1/developers</a></p>`)
|
||||
test("@Org1/Developers", `<p><a href="/org/Org1/teams/Developers" rel="nofollow">@Org1/Developers</a></p>`)
|
||||
test("@org2/developers", `<p>@org2/developers</p>`)
|
||||
test("@org1/testers", `<p>@org1/testers</p>`)
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ type PackageMetadataVersion struct {
|
||||
Engines map[string]string `json:"engines,omitempty"`
|
||||
CPU []string `json:"cpu,omitempty"`
|
||||
OS []string `json:"os,omitempty"`
|
||||
Libc []string `json:"libc,omitempty"`
|
||||
Directories map[string]string `json:"directories,omitempty"`
|
||||
Funding any `json:"funding,omitempty"`
|
||||
AcceptDependencies map[string]string `json:"acceptDependencies,omitempty"`
|
||||
@@ -129,12 +130,9 @@ type PackageMetadataVersion struct {
|
||||
|
||||
// PackageDistribution https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version
|
||||
type PackageDistribution struct {
|
||||
Integrity string `json:"integrity"`
|
||||
Shasum string `json:"shasum"`
|
||||
Tarball string `json:"tarball"`
|
||||
FileCount int `json:"fileCount,omitempty"`
|
||||
UnpackedSize int `json:"unpackedSize,omitempty"`
|
||||
NpmSignature string `json:"npm-signature,omitempty"`
|
||||
Integrity string `json:"integrity"`
|
||||
Shasum string `json:"shasum"`
|
||||
Tarball string `json:"tarball"`
|
||||
}
|
||||
|
||||
type PackageSearch struct {
|
||||
@@ -226,7 +224,7 @@ func (r *Repository) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
// Bin maps command names to executable files. npm also allows a single string,
|
||||
// in which case the command is named after the package (resolved in ParsePackage).
|
||||
// in which case the command is named after the package (resolved in parseUploadPackage).
|
||||
type Bin map[string]string
|
||||
|
||||
// UnmarshalJSON is needed because the bin field can be a string or an object.
|
||||
@@ -264,7 +262,7 @@ type packageUpload struct {
|
||||
// is non-nil on success; a body without `_attachments` is a deprecate request,
|
||||
// otherwise it is a "publish".
|
||||
func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r, 10*1024*1024))
|
||||
body, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -280,16 +278,6 @@ func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) {
|
||||
return p, nil, err
|
||||
}
|
||||
|
||||
// ParsePackage parses a npm publish PUT body. Bodies without `_attachments`
|
||||
// surface as ErrInvalidAttachment once name/version validation has passed.
|
||||
func ParsePackage(r io.Reader) (*Package, error) {
|
||||
var upload packageUpload
|
||||
if err := json.NewDecoder(r).Decode(&upload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseUploadPackage(&upload)
|
||||
}
|
||||
|
||||
// parseUploadPackage builds a Package from a decoded publish body.
|
||||
func parseUploadPackage(upload *packageUpload) (*Package, error) {
|
||||
for _, meta := range upload.Versions {
|
||||
@@ -343,6 +331,7 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
|
||||
Engines: meta.Engines,
|
||||
CPU: meta.CPU,
|
||||
OS: meta.OS,
|
||||
Libc: meta.Libc,
|
||||
Directories: meta.Directories,
|
||||
Funding: meta.Funding,
|
||||
AcceptDependencies: meta.AcceptDependencies,
|
||||
@@ -356,12 +345,12 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
|
||||
|
||||
p.Filename = strings.ToLower(fmt.Sprintf("%s-%s.tgz", name, p.Version))
|
||||
|
||||
attachment := func() *PackageAttachment {
|
||||
attachment := upload.Attachments[meta.Name+"-"+meta.Version+".tgz"] // not the sigstore bundle of `npm publish --provenance`
|
||||
if attachment == nil && len(upload.Attachments) == 1 {
|
||||
for _, a := range upload.Attachments {
|
||||
return a
|
||||
attachment = a
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
}
|
||||
if attachment == nil || len(attachment.Data) == 0 {
|
||||
return nil, ErrInvalidAttachment
|
||||
}
|
||||
@@ -393,8 +382,6 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
|
||||
return nil, ErrInvalidIntegrity
|
||||
}
|
||||
|
||||
// Derive _hasShrinkwrap and hasInstallScript from the tarball; the
|
||||
// packument can lie about either.
|
||||
p.Metadata.HasShrinkwrap, p.Metadata.HasInstallScript = inspectTarball(data)
|
||||
|
||||
return p, nil
|
||||
@@ -410,11 +397,7 @@ const maxNpmTarballScanBytes = int64(32 * 1024 * 1024) // 32 MiB
|
||||
// maxNpmPackageJSONBytes caps the package.json bytes decoded from the tarball.
|
||||
const maxNpmPackageJSONBytes = int64(1 * 1024 * 1024) // 1 MiB
|
||||
|
||||
// inspectTarball reports hasShrinkwrap (presence of package/npm-shrinkwrap.json)
|
||||
// and hasInstallScript (package/package.json declares any of preinstall,
|
||||
// install, postinstall). Both must be derived server-side because the client
|
||||
// can lie in the packument. Any read/decode error yields (false, false) so a
|
||||
// malformed archive does not block publishing.
|
||||
// inspectTarball trusts the tarball over the client's packument, read errors yield zero values to not block publishing
|
||||
func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
|
||||
gr, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
@@ -422,11 +405,16 @@ func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
|
||||
}
|
||||
defer gr.Close()
|
||||
|
||||
var hasGypFile bool
|
||||
var pkg struct {
|
||||
Scripts map[string]string `json:"scripts"`
|
||||
Gypfile any `json:"gypfile"`
|
||||
}
|
||||
tr := tar.NewReader(io.LimitReader(gr, maxNpmTarballScanBytes))
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err != nil {
|
||||
return hasShrinkwrap, hasInstallScript
|
||||
break
|
||||
}
|
||||
// npm pack puts files under a single root directory (usually "package/").
|
||||
name := strings.TrimPrefix(hdr.Name, "./")
|
||||
@@ -436,30 +424,14 @@ func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
|
||||
switch {
|
||||
case strings.HasSuffix(name, "/npm-shrinkwrap.json"):
|
||||
hasShrinkwrap = true
|
||||
case strings.HasSuffix(name, ".gyp"):
|
||||
hasGypFile = true
|
||||
case strings.HasSuffix(name, "/package.json"):
|
||||
hasInstallScript = tarballDeclaresInstallScript(tr)
|
||||
}
|
||||
if hasShrinkwrap && hasInstallScript {
|
||||
return hasShrinkwrap, hasInstallScript
|
||||
_ = json.NewDecoder(io.LimitReader(tr, maxNpmPackageJSONBytes)).Decode(&pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tarballDeclaresInstallScript reports whether a package.json declares any
|
||||
// of preinstall, install, postinstall.
|
||||
func tarballDeclaresInstallScript(r io.Reader) bool {
|
||||
var pkg struct {
|
||||
Scripts map[string]string `json:"scripts"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r, maxNpmPackageJSONBytes)).Decode(&pkg); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, name := range []string{"preinstall", "install", "postinstall"} {
|
||||
if strings.TrimSpace(pkg.Scripts[name]) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
// npm publish adds "install": "node-gyp rebuild" for a root *.gyp file to the manifest, but not to the tarball
|
||||
return hasShrinkwrap, strings.TrimSpace(pkg.Scripts["preinstall"]+pkg.Scripts["install"]+pkg.Scripts["postinstall"]) != "" || hasGypFile && pkg.Gypfile != false
|
||||
}
|
||||
|
||||
func validateName(name string) bool {
|
||||
|
||||
@@ -41,21 +41,20 @@ func TestParsePackage(t *testing.T) {
|
||||
integrity := "sha512-" + base64Sha512(dataBytes)
|
||||
|
||||
t.Run("InvalidUpload", func(t *testing.T) {
|
||||
p, err := ParsePackage(bytes.NewReader([]byte{0}))
|
||||
p, _, err := ParseUpload(bytes.NewReader([]byte{0}))
|
||||
assert.Nil(t, p)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("InvalidUploadNoData", func(t *testing.T) {
|
||||
b, _ := json.Marshal(packageUpload{})
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, err := parseUploadPackage(&packageUpload{})
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidPackage)
|
||||
})
|
||||
|
||||
t.Run("InvalidPackageName", func(t *testing.T) {
|
||||
test := func(t *testing.T, name string) {
|
||||
b, _ := json.Marshal(packageUpload{
|
||||
p, err := parseUploadPackage(&packageUpload{
|
||||
PackageMetadata: PackageMetadata{
|
||||
ID: name,
|
||||
Name: name,
|
||||
@@ -66,8 +65,6 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidPackageName)
|
||||
}
|
||||
@@ -94,7 +91,7 @@ func TestParsePackage(t *testing.T) {
|
||||
|
||||
t.Run("ValidPackageName", func(t *testing.T) {
|
||||
test := func(t *testing.T, name string) {
|
||||
b, _ := json.Marshal(packageUpload{
|
||||
p, err := parseUploadPackage(&packageUpload{
|
||||
PackageMetadata: PackageMetadata{
|
||||
ID: name,
|
||||
Name: name,
|
||||
@@ -105,8 +102,6 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidPackageVersion)
|
||||
}
|
||||
@@ -125,7 +120,7 @@ func TestParsePackage(t *testing.T) {
|
||||
|
||||
t.Run("InvalidPackageVersion", func(t *testing.T) {
|
||||
version := "first-version"
|
||||
b, _ := json.Marshal(packageUpload{
|
||||
p, err := parseUploadPackage(&packageUpload{
|
||||
PackageMetadata: PackageMetadata{
|
||||
ID: packageFullName,
|
||||
Name: packageFullName,
|
||||
@@ -137,8 +132,6 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidPackageVersion)
|
||||
})
|
||||
@@ -160,7 +153,7 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, _, err := ParseUpload(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidAttachment)
|
||||
})
|
||||
@@ -185,7 +178,7 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, _, err := ParseUpload(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidAttachment)
|
||||
})
|
||||
@@ -213,7 +206,7 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, _, err := ParseUpload(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidIntegrity)
|
||||
})
|
||||
@@ -241,7 +234,7 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, _, err := ParseUpload(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidIntegrity)
|
||||
})
|
||||
@@ -281,10 +274,13 @@ func TestParsePackage(t *testing.T) {
|
||||
filename: {
|
||||
Data: data,
|
||||
},
|
||||
packageFullName + "-" + packageVersion + ".sigstore": {
|
||||
Data: "{}",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, _, err := ParseUpload(bytes.NewReader(b))
|
||||
assert.NotNil(t, p)
|
||||
assert.NoError(t, err)
|
||||
|
||||
@@ -329,7 +325,7 @@ func TestParsePackage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}`
|
||||
p, err := ParsePackage(strings.NewReader(packageJSON))
|
||||
p, _, err := ParseUpload(strings.NewReader(packageJSON))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "MIT", string(p.Metadata.License))
|
||||
})
|
||||
@@ -354,7 +350,7 @@ func TestParsePackage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}`
|
||||
p, err := ParsePackage(strings.NewReader(packageJSON))
|
||||
p, _, err := ParseUpload(strings.NewReader(packageJSON))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://gitea.io/gitea/test.git", p.Metadata.Repository.URL)
|
||||
// a string bin is named after the package
|
||||
@@ -426,6 +422,15 @@ func TestInspectTarball(t *testing.T) {
|
||||
// npm pack sometimes emits "./package/..." entries.
|
||||
wantShrinkwrap: true,
|
||||
},
|
||||
{
|
||||
name: "gyp file implies node-gyp install",
|
||||
files: map[string]string{"package/binding.gyp": "{}"},
|
||||
wantInstaller: true,
|
||||
},
|
||||
{
|
||||
name: "gypfile false disables gyp install",
|
||||
files: map[string]string{"package/binding.gyp": "{}", "package/package.json": `{"gypfile":false}`},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -460,41 +465,6 @@ func TestParseUpload(t *testing.T) {
|
||||
require.NotNil(t, dep)
|
||||
assert.Equal(t, map[string]string{"1.0.0": "gone", "1.0.1": ""}, dep.Versions)
|
||||
})
|
||||
|
||||
t.Run("dispatches publish when _attachments present", func(t *testing.T) {
|
||||
// Reuse a minimal tarball with a package.json.
|
||||
data := buildTarball(map[string]string{"package/package.json": `{}`})
|
||||
integrity := "sha512-" + base64Sha512(data)
|
||||
body := fmt.Sprintf(
|
||||
`{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`,
|
||||
pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data),
|
||||
)
|
||||
p, dep, err := ParseUpload(strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, dep)
|
||||
require.NotNil(t, p)
|
||||
assert.Equal(t, pkg, p.Name)
|
||||
})
|
||||
|
||||
t.Run("publish whose readme mentions deprecated is not misrouted", func(t *testing.T) {
|
||||
// The old fast-path used a substring check for "deprecated"; make sure
|
||||
// the new dispatch keys off _attachments only.
|
||||
data := buildTarball(map[string]string{"package/package.json": `{}`})
|
||||
integrity := "sha512-" + base64Sha512(data)
|
||||
body := fmt.Sprintf(
|
||||
`{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","readme":"this package is deprecated!","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`,
|
||||
pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data),
|
||||
)
|
||||
p, dep, err := ParseUpload(strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, dep)
|
||||
require.NotNil(t, p)
|
||||
})
|
||||
|
||||
t.Run("invalid json errors out", func(t *testing.T) {
|
||||
_, _, err := ParseUpload(strings.NewReader("not json"))
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func base64Sha512(data []byte) string {
|
||||
|
||||
@@ -29,6 +29,7 @@ type Metadata struct {
|
||||
Engines map[string]string `json:"engines,omitempty"`
|
||||
CPU []string `json:"cpu,omitempty"`
|
||||
OS []string `json:"os,omitempty"`
|
||||
Libc []string `json:"libc,omitempty"`
|
||||
Directories map[string]string `json:"directories,omitempty"`
|
||||
Funding any `json:"funding,omitempty"`
|
||||
AcceptDependencies map[string]string `json:"accept_dependencies,omitempty"`
|
||||
|
||||
@@ -240,7 +240,7 @@ func FindAllMentionsBytes(content []byte) []RefSpan {
|
||||
}
|
||||
|
||||
// FindFirstMentionBytes matches the first mention in then given content
|
||||
// and returns the location of the unvalidated user name, including the @ prefix.
|
||||
// and returns the location of the unvalidated username, including the @ prefix.
|
||||
func FindFirstMentionBytes(content []byte) (bool, RefSpan) {
|
||||
mention := mentionPattern.FindSubmatchIndex(content)
|
||||
if mention == nil {
|
||||
|
||||
@@ -119,7 +119,7 @@ func (l *LocalStorage) Stat(path string) (os.FileInfo, error) {
|
||||
|
||||
func (l *LocalStorage) deleteEmptyParentDirs(localFullPath string) {
|
||||
for parent := filepath.Dir(localFullPath); len(parent) > len(l.dir); parent = filepath.Dir(parent) {
|
||||
if err := util.RemoveWithRetry(parent); err != nil && !os.IsNotExist(err) {
|
||||
if err := os.Remove(parent); err != nil && !os.IsNotExist(err) {
|
||||
// since the target file has been deleted, parent dir error is not related to the file deletion itself.
|
||||
break
|
||||
}
|
||||
|
||||
@@ -15,19 +15,23 @@ import (
|
||||
// * the "cat-batch" git process might be running in a goroutine
|
||||
// * there can be a data-race between the "cat-batch" git process cancel+exit and the repo rename
|
||||
// So we need to retry the rename/remove operation for a few times when the "cat-batch" git process is exiting.
|
||||
// ref: https://github.com/go-gitea/gitea/issues/16427, https://github.com/go-gitea/gitea/issues/16475, https://github.com/go-gitea/gitea/pull/16479
|
||||
// ref: https://github.com/go-gitea/gitea/issues/16427, https://github.com/go-gitea/gitea/issues/16475
|
||||
// ref: https://github.com/go-gitea/gitea/pull/16435, https://github.com/go-gitea/gitea/pull/16479
|
||||
// Also some similar problems when removing a file, e.g.: https://github.com/go-gitea/gitea/issues/12339
|
||||
//
|
||||
// Usually, if no concurrent access to a file, use "os.Xxx", otherwise, use "util.XxxWithRetry"
|
||||
|
||||
func retryWhenFileBusyInternal(count int, delay time.Duration, f func() error) (err error) {
|
||||
// Windows: an opened file without share flags can't be removed or renamed:
|
||||
// Error code 32: The process cannot access the file because it is being used by another process.
|
||||
// Also, Error code 16 (EBUSY) happens to be "The directory cannot be removed" (the directory is used as a current directory by a process)
|
||||
const errWindowsSharingViolationError = syscall.Errno(32)
|
||||
for range count {
|
||||
err = f()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
isErrBusy := errors.Is(err, syscall.EBUSY) || errors.Is(err, syscall.ENOTEMPTY) || errors.Is(err, syscall.EPERM) || errors.Is(err, syscall.EMFILE) || errors.Is(err, syscall.ENFILE)
|
||||
isErrBusy := errors.Is(err, syscall.EBUSY)
|
||||
isErrBusy = isErrBusy || (isOSWindows && errors.Is(err, errWindowsSharingViolationError))
|
||||
if !isErrBusy {
|
||||
break
|
||||
|
||||
@@ -36,6 +36,7 @@ import "strings"
|
||||
|
||||
const (
|
||||
tildePrefix = '~'
|
||||
commentPrefix = '#'
|
||||
needsEscape = " \t\n|&;()<>${}[]*?!\"'`\\"
|
||||
needsSingleQuote = "!\n"
|
||||
)
|
||||
@@ -74,7 +75,7 @@ func ShellEscape(toEscape string) string {
|
||||
}
|
||||
|
||||
// Now for simplicity we'll look at the rest of the string
|
||||
if !strings.ContainsAny(toEscape[start:], needsEscape) {
|
||||
if !strings.ContainsAny(toEscape[start:], needsEscape) && toEscape[0] != commentPrefix {
|
||||
return toEscape
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ func TestShellEscape(t *testing.T) {
|
||||
"Double quote and escape `...",
|
||||
"~/gitea`",
|
||||
"~/\"gitea\\`\"",
|
||||
}, {
|
||||
"Double quote leading #",
|
||||
"#123",
|
||||
`"#123"`,
|
||||
}, {
|
||||
"Double quotes can handle a number of things without having to escape them but not everything ...",
|
||||
"~/<gitea> ${gitea} `gitea` [gitea] (gitea) \"gitea\" \\gitea\\ 'gitea'",
|
||||
|
||||
@@ -121,7 +121,7 @@ func asciiLower(b byte) byte {
|
||||
|
||||
// AsciiEqualFold is from Golang https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/net/http/internal/ascii/print.go
|
||||
// ASCII only. In most cases for protocols, we should only use this but not [strings.EqualFold]
|
||||
func AsciiEqualFold(s, t string) bool {
|
||||
func AsciiEqualFold[T string | []byte](s, t T) bool {
|
||||
if len(s) != len(t) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -19,13 +20,17 @@ type RouterPathGroup struct {
|
||||
r *Router
|
||||
pathParam string
|
||||
matchers []*routerPathMatcher
|
||||
unescape bool
|
||||
}
|
||||
|
||||
func (g *RouterPathGroup) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
|
||||
chiCtx := chi.RouteContext(req.Context())
|
||||
path := chiCtx.URLParam(g.pathParam)
|
||||
if g.unescape {
|
||||
path, _ = url.PathUnescape(path)
|
||||
}
|
||||
for _, m := range g.matchers {
|
||||
if m.matchPath(chiCtx, path) {
|
||||
if m.matchPath(chiCtx, path, g.unescape) {
|
||||
chiCtx.RoutePatterns = append(chiCtx.RoutePatterns, m.pattern)
|
||||
executeMiddlewaresHandler(resp, req, m.middlewares, m.handlerFunc)
|
||||
return
|
||||
@@ -53,6 +58,10 @@ func (g *RouterPathGroup) MatchPattern(methods string, pattern *RouterPathGroupP
|
||||
g.matchers = append(g.matchers, newRouterPathMatcher(methods, pattern, h...))
|
||||
}
|
||||
|
||||
func (g *RouterPathGroup) UseUnescapedPath() {
|
||||
g.unescape = true
|
||||
}
|
||||
|
||||
type routerPathParam struct {
|
||||
name string
|
||||
pathSepEnd bool
|
||||
@@ -68,7 +77,7 @@ type routerPathMatcher struct {
|
||||
handlerFunc http.HandlerFunc
|
||||
}
|
||||
|
||||
func (p *routerPathMatcher) matchPath(chiCtx *chi.Context, path string) bool {
|
||||
func (p *routerPathMatcher) matchPath(chiCtx *chi.Context, path string, unescaped bool) bool {
|
||||
if !p.methods.Contains(chiCtx.RouteMethod) {
|
||||
return false
|
||||
}
|
||||
@@ -102,6 +111,9 @@ func (p *routerPathMatcher) matchPath(chiCtx *chi.Context, path string) bool {
|
||||
if p.params[i].pathSepEnd {
|
||||
val = strings.TrimSuffix(val, "/")
|
||||
}
|
||||
if unescaped {
|
||||
val = url.PathEscape(val)
|
||||
}
|
||||
chiCtx.URLParams.Add(p.params[i].name, val)
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -97,12 +98,16 @@ func (r *testRecorder) test(t *testing.T, rt *Router, methodPath string, expecte
|
||||
}
|
||||
|
||||
func TestPathProcessor(t *testing.T) {
|
||||
unescape := false
|
||||
testProcess := func(pattern, uri string, expectedPathParams map[string]string) {
|
||||
chiCtx := chi.NewRouteContext()
|
||||
chiCtx.RouteMethod = "GET"
|
||||
p := newRouterPathMatcher("GET", patternRegexp(pattern), http.NotFound)
|
||||
shouldProcess := expectedPathParams != nil
|
||||
assert.Equal(t, shouldProcess, p.matchPath(chiCtx, uri), "use pattern %s to process uri %s", pattern, uri)
|
||||
if unescape {
|
||||
uri, _ = url.PathUnescape(uri)
|
||||
}
|
||||
assert.Equal(t, shouldProcess, p.matchPath(chiCtx, uri, unescape), "use pattern %s to process uri %s", pattern, uri)
|
||||
assert.Equal(t, expectedPathParams, chiURLParamsToMap(chiCtx), "use pattern %s to process uri %s", pattern, uri)
|
||||
}
|
||||
|
||||
@@ -119,6 +124,9 @@ func TestPathProcessor(t *testing.T) {
|
||||
testProcess("/<p1:*>/part/<p2>", "/part/c", map[string]string{"p1": "", "p2": "c"})
|
||||
testProcess("/<p1:*>/part/<p2>", "/a/other-part/c", nil)
|
||||
testProcess("/<p1:*>-part/<p2>", "/a-other-part/c", map[string]string{"p1": "a-other", "p2": "c"})
|
||||
|
||||
unescape = true
|
||||
testProcess("/<p1:@/x>", "/%40%2fx", map[string]string{"p1": "@%2Fx"})
|
||||
}
|
||||
|
||||
func TestRouter(t *testing.T) {
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"auth.authorize_application_created_by": "This application was created by %s.",
|
||||
"auth.authorize_application_description": "If you grant access, it will be able to access and write to all your account information, including private repos and organizations.",
|
||||
"auth.authorize_application_with_scopes": "With scopes: %s",
|
||||
"auth.authorize_application_new_scopes": "New scopes: %s",
|
||||
"auth.authorize_title": "Authorize \"%s\" to access your account?",
|
||||
"auth.authorization_failed": "Authorization failed",
|
||||
"auth.authorization_failed_desc": "The authorization failed because we detected an invalid request. Please contact the maintainer of the app you tried to authorize.",
|
||||
|
||||
+20
-30
@@ -405,37 +405,27 @@ func CommonRoutes() *web.Router {
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
})
|
||||
r.Group("/npm", func() {
|
||||
// HINT: NPM-ROUTE-PATH-PATTERN: search this keyword to see more details
|
||||
scopeRegexp := `^@` + npm_module.RegexpNamePart + `$`
|
||||
idRegexp := `^(@` + npm_module.RegexpNamePart + `%2[fF])?` + npm_module.RegexpNamePart + `$`
|
||||
addPackageHandlers := func() {
|
||||
r.Get("", npm.PackageMetadata)
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), npm.UploadPackage)
|
||||
r.Get("/{version}", npm.PackageVersionMetadata)
|
||||
r.Group("/-/{version}/{filename}", func() {
|
||||
r.Get("", npm.DownloadPackageFile)
|
||||
r.Delete("/-rev/{revision}", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
|
||||
})
|
||||
r.Get("/-/{filename}", npm.DownloadPackageFileByName)
|
||||
r.Group("/-rev/{revision}", func() {
|
||||
r.Delete("", npm.DeletePackage)
|
||||
r.Put("", npm.DeletePreview)
|
||||
}, reqPackageAccess(perm.AccessModeWrite))
|
||||
}
|
||||
r.Group("/{scope:"+scopeRegexp+"}/{id:"+idRegexp+"}", addPackageHandlers)
|
||||
r.Group("/{id:"+idRegexp+"}", addPackageHandlers)
|
||||
r.Get("/-/v1/search", npm.PackageSearch)
|
||||
r.Get("/-/ping", npm.Ping)
|
||||
r.Get("/-/whoami", npm.Whoami)
|
||||
r.PathGroup("/*", func(g *web.RouterPathGroup) {
|
||||
// HINT: NPM-ROUTE-PATH-PATTERN: search this keyword to see more details
|
||||
packageId := `/<id:(@` + npm_module.RegexpNamePart + `/)?` + npm_module.RegexpNamePart + ">"
|
||||
g.UseUnescapedPath()
|
||||
g.MatchPath("DELETE", packageId+"/-/<version>/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
|
||||
g.MatchPath("DELETE", packageId+"/-/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
|
||||
g.MatchPath("GET", packageId+"/-/<version>/<filename>", npm.DownloadPackageFileByName) // former tarball URL, still in lockfiles
|
||||
g.MatchPath("GET", packageId+"/-/<filename>", npm.DownloadPackageFileByName)
|
||||
g.MatchPath("DELETE", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackage)
|
||||
g.MatchPath("PUT", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePreview)
|
||||
g.MatchPath("GET", packageId+"/<version>", npm.PackageVersionMetadata)
|
||||
g.MatchPath("GET", packageId, npm.PackageMetadata)
|
||||
g.MatchPath("PUT", packageId, reqPackageAccess(perm.AccessModeWrite), npm.UploadPackage)
|
||||
|
||||
addPackageDistTagsHandlers := func() {
|
||||
r.Get("", npm.ListPackageTags)
|
||||
r.Group("/{tag}", func() {
|
||||
r.Put("", npm.AddPackageTag)
|
||||
r.Delete("", npm.DeletePackageTag)
|
||||
}, reqPackageAccess(perm.AccessModeWrite))
|
||||
}
|
||||
r.Group("/-/package/{scope:"+scopeRegexp+"}/{id:"+idRegexp+"}/dist-tags", addPackageDistTagsHandlers)
|
||||
r.Group("/-/package/{id:"+idRegexp+"}/dist-tags", addPackageDistTagsHandlers)
|
||||
r.Group("/-/v1/search", func() {
|
||||
r.Get("", npm.PackageSearch)
|
||||
packageDistTags := "/-/package" + packageId + "/dist-tags"
|
||||
g.MatchPath("GET", packageDistTags, npm.ListPackageTags)
|
||||
g.MatchPath("PUT", packageDistTags+"/<tag>", reqPackageAccess(perm.AccessModeWrite), npm.AddPackageTag)
|
||||
g.MatchPath("DELETE", packageDistTags+"/<tag>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageTag)
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/pub", func() {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
@@ -25,6 +25,7 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
|
||||
distTags := make(map[string]string)
|
||||
times := make(map[string]time.Time)
|
||||
firstPublished, lastPublished := pds[0].Version.CreatedUnix, pds[0].Version.CreatedUnix
|
||||
var latest *packages_model.PackageDescriptor
|
||||
for _, pd := range pds {
|
||||
semVer := pd.SemVer.String()
|
||||
versions[semVer] = createPackageMetadataVersion(registryURL, pd)
|
||||
@@ -35,6 +36,9 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
|
||||
for _, pvp := range pd.VersionProperties {
|
||||
if pvp.Name == npm_module.TagProperty {
|
||||
distTags[pvp.Value] = pd.Version.Version
|
||||
if pvp.Value == "latest" {
|
||||
latest = pd
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +47,16 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
|
||||
times["created"] = firstPublished.AsTimeInLocation(time.UTC)
|
||||
times["modified"] = lastPublished.AsTimeInLocation(time.UTC)
|
||||
|
||||
latest := pds[len(pds)-1]
|
||||
if latest == nil { // yarn and pnpm fail without it, e.g. after its version got deleted
|
||||
latest = pds[len(pds)-1]
|
||||
for _, pd := range slices.Backward(pds) {
|
||||
if pd.SemVer.Prerelease() == "" {
|
||||
latest = pd
|
||||
break
|
||||
}
|
||||
}
|
||||
distTags["latest"] = latest.Version.Version
|
||||
}
|
||||
|
||||
metadata := packages_model.DescriptorMetadata[*npm_module.Metadata](latest)
|
||||
|
||||
@@ -86,13 +99,13 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
|
||||
PeerDependencies: metadata.PeerDependencies,
|
||||
PeerDependenciesMeta: metadata.PeerDependenciesMeta,
|
||||
OptionalDependencies: metadata.OptionalDependencies,
|
||||
Readme: metadata.Readme,
|
||||
Bin: metadata.Bin,
|
||||
HasInstallScript: metadata.HasInstallScript,
|
||||
HasShrinkwrap: metadata.HasShrinkwrap,
|
||||
Engines: metadata.Engines,
|
||||
CPU: metadata.CPU,
|
||||
OS: metadata.OS,
|
||||
Libc: metadata.Libc,
|
||||
Directories: metadata.Directories,
|
||||
Funding: metadata.Funding,
|
||||
AcceptDependencies: metadata.AcceptDependencies,
|
||||
@@ -100,7 +113,7 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
|
||||
Dist: npm_module.PackageDistribution{
|
||||
Shasum: pd.Files[0].Blob.HashSHA1,
|
||||
Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes),
|
||||
Tarball: fmt.Sprintf("%s/%s/-/%s/%s", registryURL, url.PathEscape(pd.Package.Name), url.PathEscape(pd.Version.Version), url.PathEscape(pd.Files[0].File.LowerName)),
|
||||
Tarball: fmt.Sprintf("%s/%s/-/%s", registryURL, pd.Package.Name, pd.Files[0].File.LowerName), // npmjs shape, which npm parses for allowScripts and yarn keeps registry-relative
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestCreatePackageMetadataResponse(t *testing.T) {
|
||||
Owner: &user_model.User{Name: "alice"},
|
||||
Version: &packages_model.PackageVersion{Version: v, CreatedUnix: timeutil.TimeStamp(publishedUnix)},
|
||||
SemVer: version.Must(version.NewVersion(v)),
|
||||
Metadata: &npm_module.Metadata{Keywords: []string{"gitea"}, Repository: repo},
|
||||
Metadata: &npm_module.Metadata{Readme: v, Keywords: []string{"gitea"}, Repository: repo},
|
||||
Files: []*packages_model.PackageFileDescriptor{{
|
||||
File: &packages_model.PackageFile{LowerName: "test-" + v + ".tgz"},
|
||||
Blob: &packages_model.PackageBlob{},
|
||||
@@ -35,21 +35,26 @@ func TestCreatePackageMetadataResponse(t *testing.T) {
|
||||
|
||||
result := createPackageMetadataResponse("https://gitea.dev/api/packages/alice/npm", []*packages_model.PackageDescriptor{
|
||||
descriptor("1.1.0", 1000, npm_module.Repository{}),
|
||||
descriptor("2.0.0-rc.1", 1500, repository),
|
||||
descriptor("1.0.0", 2000, repository),
|
||||
})
|
||||
|
||||
assert.Equal(t, map[string]time.Time{
|
||||
"1.0.0": time.Unix(2000, 0).UTC(),
|
||||
"1.1.0": time.Unix(1000, 0).UTC(),
|
||||
"created": time.Unix(1000, 0).UTC(),
|
||||
"modified": time.Unix(2000, 0).UTC(),
|
||||
"1.0.0": time.Unix(2000, 0).UTC(),
|
||||
"1.1.0": time.Unix(1000, 0).UTC(),
|
||||
"2.0.0-rc.1": time.Unix(1500, 0).UTC(),
|
||||
"created": time.Unix(1000, 0).UTC(),
|
||||
"modified": time.Unix(2000, 0).UTC(),
|
||||
}, result.Time)
|
||||
assert.Equal(t, map[string]string{"latest": "1.1.0"}, result.DistTags)
|
||||
assert.Equal(t, "1.1.0", result.Readme)
|
||||
assert.Empty(t, result.Versions["1.1.0"].Readme)
|
||||
assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Maintainers)
|
||||
assert.Equal(t, []string{"gitea"}, result.Keywords)
|
||||
assert.Equal(t, []string{"gitea"}, result.Versions["1.0.0"].Keywords)
|
||||
assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Versions["1.0.0"].Maintainers)
|
||||
assert.Equal(t,
|
||||
"https://gitea.dev/api/packages/alice/npm/@scope%2Ftest/-/1.0.0/test-1.0.0.tgz",
|
||||
"https://gitea.dev/api/packages/alice/npm/@scope/test/-/test-1.0.0.tgz",
|
||||
result.Versions["1.0.0"].Dist.Tarball,
|
||||
)
|
||||
assert.Equal(t, repository, result.Versions["1.0.0"].Repository)
|
||||
|
||||
+90
-104
@@ -6,6 +6,7 @@ package npm
|
||||
import (
|
||||
"bytes"
|
||||
std_ctx "context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -44,49 +45,53 @@ func apiError(ctx *context.Context, status int, obj any) {
|
||||
|
||||
// packageNameFromParams gets the package name from the url parameters
|
||||
func packageNameFromParams(ctx *context.Context) string {
|
||||
// Real examples: these 2 both should work:
|
||||
// HINT: NPM-ROUTE-PATH-PATTERN: real examples: these cases all should work:
|
||||
// * "https://registry.npmjs.org/@angular/core"
|
||||
// * "https://registry.npmjs.org/@angular%2Fcore"
|
||||
// * "https://registry.npmjs.org/%40angular%2Fcore"
|
||||
//
|
||||
// HINT: NPM-ROUTE-PATH-PATTERN: The cases for the path parameters:
|
||||
// * ".../TheName/...": id="TheName"
|
||||
// * ".../@TheScope/TheName/...": scope="@TheScope", id="TheName"
|
||||
// * ".../@TheScope%2FTheName/...": id="@TheScope/TheName"
|
||||
scope := ctx.PathParam("scope")
|
||||
fullOrSub := ctx.PathParam("id") // may be a full name or a subpath of the full package name
|
||||
if scope != "" {
|
||||
// now id is the subpath of the full package name, e.g. "core" in "@angular/core"
|
||||
return fmt.Sprintf("%s/%s", scope, fullOrSub)
|
||||
}
|
||||
return fullOrSub // id is the full package name, e.g.: "@angular/core" or "lodash"
|
||||
return ctx.PathParam("id") // id is the full package name, e.g.: "@angular/core" or "lodash"
|
||||
}
|
||||
|
||||
func buildNpmRegistryURL(ctx std_ctx.Context, owner *user_model.User) string {
|
||||
return httplib.GuessCurrentAppURL(ctx) + "api/packages/" + url.PathEscape(owner.Name) + "/npm"
|
||||
}
|
||||
|
||||
// PackageMetadata returns the metadata for a single package
|
||||
func PackageMetadata(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
|
||||
func packageMetadata(ctx *context.Context) *npm_module.PackageMetadata {
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageNameFromParams(ctx))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
resp := createPackageMetadataResponse(buildNpmRegistryURL(ctx, ctx.Package.Owner), pds)
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
return createPackageMetadataResponse(buildNpmRegistryURL(ctx, ctx.Package.Owner), pds)
|
||||
}
|
||||
|
||||
// PackageMetadata returns the metadata for a single package
|
||||
func PackageMetadata(ctx *context.Context) {
|
||||
if metadata := packageMetadata(ctx); metadata != nil {
|
||||
serveMetadata(ctx, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func serveMetadata(ctx *context.Context, obj any) {
|
||||
body, err := json.MarshalDeterministic(obj)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
ctx.Resp.Header().Set("ETag", fmt.Sprintf(`W/"%x"`, sha256.Sum256(body)))
|
||||
ctx.ServeContent(bytes.NewReader(body), context.ServeHeaderOptions{ContentType: "application/json;charset=utf-8"})
|
||||
}
|
||||
|
||||
// PackageVersionMetadata returns the metadata for a single version or dist-tag
|
||||
@@ -110,7 +115,11 @@ func PackageVersionMetadata(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, "version not found: "+versionOrTag)
|
||||
if versionOrTag != "latest" {
|
||||
apiError(ctx, http.StatusNotFound, "version not found: "+versionOrTag)
|
||||
} else if metadata := packageMetadata(ctx); metadata != nil { // unset, so serve the packument's fallback
|
||||
serveMetadata(ctx, metadata.Versions[metadata.DistTags["latest"]])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,25 +129,40 @@ func PackageVersionMetadata(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, createPackageMetadataVersion(buildNpmRegistryURL(ctx, ctx.Package.Owner), pd))
|
||||
serveMetadata(ctx, createPackageMetadataVersion(buildNpmRegistryURL(ctx, ctx.Package.Owner), pd))
|
||||
}
|
||||
|
||||
// DownloadPackageFile serves the content of a package
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
packageVersion := ctx.PathParam("version")
|
||||
filename := ctx.PathParam("filename")
|
||||
func packageVersionByFilename(ctx *context.Context) *packages_model.PackageVersion {
|
||||
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeNpm,
|
||||
Name: packages_model.SearchValue{ExactMatch: true, Value: packageNameFromParams(ctx)},
|
||||
HasFileWithName: ctx.PathParam("filename"),
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return nil
|
||||
}
|
||||
if len(pvs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return nil
|
||||
}
|
||||
return pvs[0]
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
// DownloadPackageFileByName finds the version and serves the contents of a package
|
||||
func DownloadPackageFileByName(ctx *context.Context) {
|
||||
pv := packageVersionByFilename(ctx)
|
||||
if pv == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeNpm,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
Filename: ctx.PathParam("filename"),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
@@ -150,54 +174,14 @@ func DownloadPackageFile(ctx *context.Context) {
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// DownloadPackageFileByName finds the version and serves the contents of a package
|
||||
func DownloadPackageFileByName(ctx *context.Context) {
|
||||
filename := ctx.PathParam("filename")
|
||||
|
||||
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeNpm,
|
||||
Name: packages_model.SearchValue{
|
||||
ExactMatch: true,
|
||||
Value: packageNameFromParams(ctx),
|
||||
},
|
||||
HasFileWithName: filename,
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pvs[0],
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// UploadPackage creates a new package
|
||||
func UploadPackage(ctx *context.Context) {
|
||||
npmPackage, deprecation, err := npm_module.ParseUpload(ctx.Req.Body)
|
||||
// about the npmjs and GitHub Packages limit, fits base64 tarballs up to ~200 MB
|
||||
npmPackage, deprecation, err := npm_module.ParseUpload(http.MaxBytesReader(ctx.Resp, ctx.Req.Body, 256*1024*1024))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
|
||||
apiError(ctx, http.StatusRequestEntityTooLarge, err)
|
||||
} else if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
@@ -350,26 +334,14 @@ func deprecatePackage(ctx *context.Context, dep *npm_module.PackageDeprecation)
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// DeletePackageVersion deletes the package version
|
||||
// DeletePackageVersion deletes the package version, which `npm unpublish` addresses by its tarball
|
||||
func DeletePackageVersion(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
packageVersion := ctx.PathParam("version")
|
||||
pv := packageVersionByFilename(ctx)
|
||||
if pv == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := packages_service.RemovePackageVersionByNameAndVersion(
|
||||
ctx,
|
||||
ctx.Doer,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeNpm,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
@@ -404,9 +376,7 @@ func DeletePackage(ctx *context.Context) {
|
||||
|
||||
// ListPackageTags returns all tags for a package
|
||||
func ListPackageTags(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageNameFromParams(ctx))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -424,7 +394,11 @@ func ListPackageTags(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, tags)
|
||||
if _, ok := tags["latest"]; ok {
|
||||
ctx.JSON(http.StatusOK, tags)
|
||||
} else if metadata := packageMetadata(ctx); metadata != nil { // unset, so list the packument's fallback
|
||||
ctx.JSON(http.StatusOK, metadata.DistTags)
|
||||
}
|
||||
}
|
||||
|
||||
// AddPackageTag adds a tag to the package
|
||||
@@ -534,6 +508,18 @@ func setPackageTag(ctx std_ctx.Context, tag string, pv *packages_model.PackageVe
|
||||
})
|
||||
}
|
||||
|
||||
func Ping(ctx *context.Context) {
|
||||
ctx.JSON(http.StatusOK, map[string]any{})
|
||||
}
|
||||
|
||||
func Whoami(ctx *context.Context) {
|
||||
if ctx.Doer == nil {
|
||||
apiError(ctx, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, map[string]string{"username": ctx.Doer.Name})
|
||||
}
|
||||
|
||||
func PackageSearch(ctx *context.Context) {
|
||||
pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
|
||||
+18
-34
@@ -76,6 +76,7 @@ import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
@@ -935,31 +936,22 @@ func apiAuth(authMethod auth.Method) func(*context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// verifyAuthWithOptions checks authentication according to options
|
||||
func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIContext) {
|
||||
// verifyAuthWithOptionsAPI checks authentication according to options
|
||||
func verifyAuthWithOptionsAPI(options *common.VerifyOptions) func(ctx *context.APIContext) {
|
||||
return func(ctx *context.APIContext) {
|
||||
// Check prohibit login users.
|
||||
if ctx.IsSigned {
|
||||
if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
|
||||
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{
|
||||
"message": "This account is not activated.",
|
||||
})
|
||||
check := common.CheckSignedInUser(ctx.Doer, nil)
|
||||
if check.NeedActivateAccount {
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{"message": "This account is not activated."})
|
||||
return
|
||||
}
|
||||
if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin {
|
||||
} else if check.LoginIsProhibited {
|
||||
log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr())
|
||||
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{
|
||||
"message": "This account is prohibited from signing in, please contact your site administrator.",
|
||||
})
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{"message": "This account is prohibited from signing in, please contact your site administrator."})
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Doer.MustChangePassword {
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{
|
||||
"message": "You must change your password. Change it at: " + setting.AppURL + "/user/change_password",
|
||||
})
|
||||
} else if check.NeedChangePassword {
|
||||
msg := "You must change your password. Change it at: " + httplib.MakeAbsoluteURL(ctx, setting.AppSubURL+"/user/settings/change_password")
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{"message": msg})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -970,20 +962,12 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIC
|
||||
return
|
||||
}
|
||||
|
||||
if options.SignInRequired {
|
||||
if !ctx.IsSigned {
|
||||
// Restrict API calls with error message.
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{
|
||||
"message": "Only signed in user is allowed to call APIs.",
|
||||
})
|
||||
return
|
||||
} else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
|
||||
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{
|
||||
"message": "This account is not activated.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if options.SignInRequired && !ctx.IsSigned {
|
||||
// Restrict API calls with error message.
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{
|
||||
"message": "Only signed in user is allowed to call APIs.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if options.AdminRequired {
|
||||
@@ -1035,7 +1019,7 @@ func Routes() *web.Router {
|
||||
// Get user from session if logged in.
|
||||
m.AfterRouting(apiAuth(buildAuthGroup()))
|
||||
|
||||
m.AfterRouting(verifyAuthWithOptions(&common.VerifyOptions{
|
||||
m.AfterRouting(verifyAuthWithOptionsAPI(&common.VerifyOptions{
|
||||
SignInRequired: setting.Service.RequireSignInViewStrict,
|
||||
}))
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@ func assignTeamPermissionUnits(team *organization.Team, permission string, units
|
||||
oldAccessMode := team.AccessMode
|
||||
oldUnitPerms := team.GetUnitsMap()
|
||||
if len(unitsMap) > 0 {
|
||||
team.AccessMode = perm.AccessModeNone
|
||||
team.Units = make([]*organization.TeamUnit, 0, len(unitsMap))
|
||||
for unitKey, p := range unitsMap {
|
||||
unitType, unitPerm := unit_model.TypeFromKey(unitKey), perm.ParseAccessMode(p)
|
||||
|
||||
@@ -162,7 +162,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
|
||||
|
||||
// if it's not a pointer, just serve the data directly
|
||||
if !pointer.IsValid() {
|
||||
_, _ = ctx.Resp.Write(lfsPointerBuf)
|
||||
httplib.ServeUserContentByReader(ctx.Req, ctx.Resp, int64(len(lfsPointerBuf)), bytes.NewReader(lfsPointerBuf), httplib.ServeHeaderOptions{Filename: blob.Name()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
|
||||
|
||||
// If there isn't one, just serve the data directly
|
||||
if errors.Is(err, git_model.ErrLFSObjectNotExist) {
|
||||
_, _ = ctx.Resp.Write(lfsPointerBuf)
|
||||
httplib.ServeUserContentByReader(ctx.Req, ctx.Resp, int64(len(lfsPointerBuf)), bytes.NewReader(lfsPointerBuf), httplib.ServeHeaderOptions{Filename: blob.Name()})
|
||||
return
|
||||
} else if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
@@ -198,7 +198,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
defer lfsDataFile.Close()
|
||||
httplib.ServeUserContentByFile(ctx.Base.Req, ctx.Base.Resp, lfsDataFile, httplib.ServeHeaderOptions{Filename: ctx.Repo.TreePath})
|
||||
httplib.ServeUserContentByFile(ctx.Base.Req, ctx.Base.Resp, lfsDataFile, httplib.ServeHeaderOptions{Filename: blob.Name()})
|
||||
}
|
||||
|
||||
func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEntry, lastModified *time.Time) {
|
||||
|
||||
@@ -386,8 +386,8 @@ func attachmentBelongsToRepoOrIssue(ctx *context.APIContext, attachment *repo_mo
|
||||
ctx.APIErrorNotFound("no such attachment in repo")
|
||||
return false
|
||||
}
|
||||
if attachment.IssueID == 0 {
|
||||
log.Debug("Requested attachment[%d] is not in an issue.", attachment.ID)
|
||||
if attachment.IssueID == 0 || attachment.CommentID != 0 {
|
||||
log.Debug("Requested attachment[%d] is not an issue attachment.", attachment.ID)
|
||||
ctx.APIErrorNotFound("no such attachment in issue")
|
||||
return false
|
||||
} else if issue != nil && attachment.IssueID != issue.ID {
|
||||
|
||||
@@ -291,6 +291,11 @@ func AddPushMirror(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
if setting.Mirror.DisableNewPush {
|
||||
ctx.APIError(http.StatusForbidden, "the site administrator has disabled the creation of new push mirrors")
|
||||
return
|
||||
}
|
||||
|
||||
pushMirror := web.GetForm[*api.CreatePushMirrorOption](ctx)
|
||||
CreatePushMirror(ctx, pushMirror)
|
||||
}
|
||||
@@ -356,7 +361,7 @@ func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirro
|
||||
|
||||
address, err := git.ParseRemoteAddr(mirrorOption.RemoteAddress, mirrorOption.RemoteUsername, mirrorOption.RemotePassword)
|
||||
if err == nil {
|
||||
err = migrations.IsMigrateURLAllowed(address, ctx.ContextUser)
|
||||
err = migrations.IsMigrateURLAllowed(address, ctx.Doer)
|
||||
}
|
||||
if err != nil {
|
||||
HandleRemoteAddressError(ctx, err)
|
||||
|
||||
@@ -10,13 +10,36 @@ import (
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreatePushMirrorUsesCallerPermission(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.ImportLocalPaths, true)()
|
||||
ctx, resp := contexttest.MockAPIContext(t, "user2/repo1")
|
||||
ctx.Doer = &user_model.User{}
|
||||
ctx.ContextUser = &user_model.User{AllowImportLocal: true}
|
||||
|
||||
CreatePushMirror(ctx, &api.CreatePushMirrorOption{RemoteAddress: "local-mirror", Interval: "0"})
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.Code)
|
||||
}
|
||||
|
||||
func TestAddPushMirrorDisabled(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Mirror.DisableNewPush, true)()
|
||||
ctx, resp := contexttest.MockAPIContext(t, "user2/repo1")
|
||||
|
||||
AddPushMirror(ctx)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, resp.Code)
|
||||
assert.Contains(t, resp.Body.String(), "the site administrator has disabled the creation of new push mirrors")
|
||||
}
|
||||
|
||||
// TestPushMirrorSync verifies the endpoint attempts every push mirror instead
|
||||
// of aborting on the first failure, reporting all failed remotes with a 422.
|
||||
// Each remote name is not a configured git remote, so SyncPushMirror fails fast
|
||||
|
||||
@@ -1032,7 +1032,7 @@ func MergePullRequest(ctx *context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := pull_service.Merge(pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if err := pull_service.Merge(ctx, pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if pull_service.IsErrInvalidMergeStyle(err) {
|
||||
ctx.APIError(http.StatusMethodNotAllowed, fmt.Sprintf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do)))
|
||||
} else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
|
||||
|
||||
@@ -6,6 +6,8 @@ package common
|
||||
import (
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/web/middleware"
|
||||
auth_service "gitea.dev/services/auth"
|
||||
"gitea.dev/services/context"
|
||||
@@ -56,3 +58,16 @@ type VerifyOptions struct {
|
||||
AdminRequired bool
|
||||
DisableCrossOriginProtection bool
|
||||
}
|
||||
|
||||
func CheckSignedInUser(doer *user_model.User, sess session.Store) (ret struct {
|
||||
NeedActivateAccount bool
|
||||
LoginIsProhibited bool
|
||||
NeedChangePassword bool
|
||||
},
|
||||
) {
|
||||
ret.NeedActivateAccount = !doer.IsActive && setting.Service.RegisterEmailConfirm
|
||||
ret.LoginIsProhibited = !doer.IsActive || doer.ProhibitLogin
|
||||
isImpersonated := sess != nil && context.IsDoerSessionImpersonated(sess)
|
||||
ret.NeedChangePassword = doer.MustChangePassword && !isImpersonated && !doer.IsTypeBot()
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCheckSignedInUser(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm)()
|
||||
sessNormal := session.NewMockMemStore("session-a")
|
||||
sessImpersonated := session.NewMockMemStore("session-b")
|
||||
_ = sessImpersonated.Set(session.KeyImpersonatorData, "any-value")
|
||||
|
||||
setting.Service.RegisterEmailConfirm = false
|
||||
ret := CheckSignedInUser(&user_model.User{IsActive: false}, nil)
|
||||
assert.False(t, ret.NeedActivateAccount)
|
||||
assert.True(t, ret.LoginIsProhibited)
|
||||
|
||||
setting.Service.RegisterEmailConfirm = true
|
||||
ret = CheckSignedInUser(&user_model.User{IsActive: false}, nil)
|
||||
assert.True(t, ret.NeedActivateAccount)
|
||||
assert.True(t, ret.LoginIsProhibited)
|
||||
|
||||
ret = CheckSignedInUser(&user_model.User{IsActive: true}, nil)
|
||||
assert.False(t, ret.NeedActivateAccount)
|
||||
assert.False(t, ret.LoginIsProhibited)
|
||||
assert.False(t, ret.NeedChangePassword)
|
||||
|
||||
ret = CheckSignedInUser(&user_model.User{IsActive: true, ProhibitLogin: true}, nil)
|
||||
assert.False(t, ret.NeedActivateAccount)
|
||||
assert.True(t, ret.LoginIsProhibited)
|
||||
|
||||
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true}, nil)
|
||||
assert.True(t, ret.NeedChangePassword)
|
||||
|
||||
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true}, sessNormal)
|
||||
assert.True(t, ret.NeedChangePassword)
|
||||
|
||||
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true, Type: user_model.UserTypeBot}, sessNormal)
|
||||
assert.False(t, ret.NeedChangePassword)
|
||||
|
||||
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true}, sessImpersonated)
|
||||
assert.False(t, ret.NeedChangePassword)
|
||||
}
|
||||
@@ -139,7 +139,8 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
|
||||
// The repo is empty and being initialized by this push, so there is no
|
||||
// dependent state (webhooks, notifications, visibility fan-out) to reconcile
|
||||
// yet; setting the flags directly is sufficient in this push-to-create case.
|
||||
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() {
|
||||
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() &&
|
||||
(isPrivate.Value() || !setting.Repository.ForcePrivate || ctx.Doer.IsAdmin) {
|
||||
repo.IsPrivate = isPrivate.Value()
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
|
||||
log.Error("failed to update repo is_private: %v", err)
|
||||
|
||||
@@ -229,9 +229,9 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
}
|
||||
} else {
|
||||
if isForcePush {
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer)
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer, ctx.Repo.Permission)
|
||||
} else {
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.Doer)
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.Doer, ctx.Repo.Permission)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,67 @@ package private
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/private"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPreReceiveActionsProtectedBranch(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
protection git_model.ProtectedBranch
|
||||
forcePush bool
|
||||
allowed bool
|
||||
}{
|
||||
{name: "push", protection: git_model.ProtectedBranch{CanPush: true}, allowed: true},
|
||||
{name: "push allowlist", protection: git_model.ProtectedBranch{CanPush: true, EnableWhitelist: true}},
|
||||
{name: "force push", protection: git_model.ProtectedBranch{CanPush: true, CanForcePush: true}, forcePush: true, allowed: true},
|
||||
{name: "force push allowlist", protection: git_model.ProtectedBranch{CanPush: true, CanForcePush: true, EnableForcePushAllowlist: true}, forcePush: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mockCtx, resp := contexttest.MockPrivateContext(t, "/")
|
||||
ctx := &preReceiveContext{PrivateContext: mockCtx, opts: &private.HookOptions{UserID: user_model.ActionsUserID}}
|
||||
ctx.SetPathParam("owner", "user2")
|
||||
ctx.SetPathParam("repo", "repo2")
|
||||
RepoAssignment(ctx.PrivateContext)
|
||||
require.False(t, ctx.Written())
|
||||
defer ctx.Repo.GitRepo.Close()
|
||||
|
||||
doer := user_model.NewActionsUserWithTaskID(53)
|
||||
loadContextDoerPermission(ctx.PrivateContext, doer.ID, doer.ExtDoerData.EncodeToString())
|
||||
|
||||
protection := tc.protection
|
||||
protection.RepoID = ctx.Repo.Repository.ID
|
||||
protection.RuleName = "probe"
|
||||
require.NoError(t, db.Insert(t.Context(), &protection))
|
||||
defer func() {
|
||||
require.NoError(t, git_model.DeleteProtectedBranch(t.Context(), ctx.Repo.Repository, protection.ID))
|
||||
}()
|
||||
|
||||
oldCommitID, newCommitID := "205ac761f3326a7ebe416e8673760016450b5cec", "1032bbf17fbc0d9c95bb5418dabe8f8c99278700"
|
||||
if tc.forcePush {
|
||||
oldCommitID, newCommitID = newCommitID, oldCommitID
|
||||
}
|
||||
preReceiveBranch(ctx, oldCommitID, newCommitID, git.RefNameFromBranch("probe"))
|
||||
if tc.allowed {
|
||||
assert.False(t, ctx.Written(), resp.Body.String())
|
||||
} else {
|
||||
assert.Contains(t, resp.Body.String(), "Not allowed to")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
|
||||
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
|
||||
// Otherwise, a per-branch grant (an open PR with "allow edits from maintainers") could be batched
|
||||
|
||||
@@ -374,7 +374,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
||||
// Reactivate user only if they were disabled by the OAuth2 auto sync cron (invalid_grant),
|
||||
// which clears AccessToken/RefreshToken/ExpiresAt on the ExternalLoginUser row
|
||||
// An admin-disabled user has no such signature, so we leave IsActive alone
|
||||
// and let verifyAuthWithOptions route them through the prohibit-login / activate page.
|
||||
// and let verifyAuthWithOptionsWeb route them through the prohibit-login / activate page.
|
||||
if !u.IsActive {
|
||||
extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
audit_model "gitea.dev/models/audit"
|
||||
"gitea.dev/models/auth"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/audit"
|
||||
auth_service "gitea.dev/services/auth"
|
||||
@@ -321,9 +323,18 @@ func AuthorizeOAuth(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var addedScopes, removedScopes []string
|
||||
if grant != nil {
|
||||
if form.Scope == "" {
|
||||
form.Scope = grant.Scope
|
||||
}
|
||||
addedScopes, removedScopes = util.DiffSlice(strings.Fields(grant.Scope), strings.Fields(form.Scope))
|
||||
}
|
||||
scopeChanged := len(addedScopes) > 0 || len(removedScopes) > 0
|
||||
|
||||
// Redirect if user already granted access and the application is confidential or trusted otherwise
|
||||
// I.e. always require authorization for untrusted public clients as recommended by RFC 6749 Section 10.2
|
||||
if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil {
|
||||
if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil && !scopeChanged {
|
||||
code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
|
||||
if err != nil {
|
||||
handleServerError(ctx, form.State, form.RedirectURI)
|
||||
@@ -347,6 +358,7 @@ func AuthorizeOAuth(ctx *context.Context) {
|
||||
|
||||
// check if additional scopes
|
||||
ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(form.Scope) != auth.AccessTokenScopeAll
|
||||
ctx.Data["AddedScopes"] = addedScopes
|
||||
|
||||
// show authorize page to grant access
|
||||
ctx.Data["Application"] = app
|
||||
@@ -432,12 +444,10 @@ func GrantApplicationOAuth(ctx *context.Context) {
|
||||
|
||||
audit.Record(ctx, audit_model.UserOAuth2ApplicationGrant, ctx.Doer, "oauth2_application", app.Name, "granted_scope", form.Scope)
|
||||
} else if grant.Scope != form.Scope {
|
||||
handleAuthorizeError(ctx, AuthorizeError{
|
||||
State: form.State,
|
||||
ErrorDescription: "a grant exists with different scope",
|
||||
ErrorCode: ErrorCodeServerError,
|
||||
}, form.RedirectURI)
|
||||
return
|
||||
if err := auth.UpdateGrantScope(ctx, grant, form.Scope); err != nil {
|
||||
handleServerError(ctx, form.State, form.RedirectURI)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(form.Nonce) > 0 {
|
||||
@@ -576,7 +586,7 @@ func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, server
|
||||
}
|
||||
|
||||
token, err := oauth2_provider.ParseToken(form.RefreshToken, serverKey)
|
||||
if err != nil {
|
||||
if err != nil || token.Kind != oauth2_provider.KindRefreshToken {
|
||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
|
||||
ErrorDescription: "unable to parse refresh token",
|
||||
|
||||
@@ -13,6 +13,10 @@ import (
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/egress/policy"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/contexttest"
|
||||
"gitea.dev/services/forms"
|
||||
"gitea.dev/services/oauth2_provider"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -105,3 +109,27 @@ func TestOAuth2AvatarClientBlocksCloudMetadata(t *testing.T) {
|
||||
assert.ErrorIs(t, err, policy.ErrDenied,
|
||||
"avatar client must refuse a link-local cloud-metadata address")
|
||||
}
|
||||
|
||||
func TestOAuth2ScopeChange(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
app := unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{ID: 1})
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("oauth2-scope-change")}
|
||||
authorize := func(scope string) int {
|
||||
ctx, resp := contexttest.MockContext(t, "/login/oauth/authorize", mockOpt)
|
||||
ctx.Doer = doer
|
||||
web.SetForm(ctx, &forms.AuthorizationForm{ResponseType: "code", ClientID: app.ClientID, RedirectURI: app.RedirectURIs[0], State: "state", Scope: scope})
|
||||
AuthorizeOAuth(ctx)
|
||||
return resp.Code
|
||||
}
|
||||
assert.Equal(t, http.StatusSeeOther, authorize(""))
|
||||
assert.Equal(t, http.StatusSeeOther, authorize("profile openid"))
|
||||
assert.Equal(t, http.StatusOK, authorize("openid profile email"))
|
||||
|
||||
ctx, resp := contexttest.MockContext(t, "/login/oauth/grant", mockOpt)
|
||||
ctx.Doer = doer
|
||||
web.SetForm(ctx, &forms.GrantApplicationForm{ClientID: app.ClientID, Granted: true, RedirectURI: app.RedirectURIs[0], State: "state", Scope: "openid profile email"})
|
||||
GrantApplicationOAuth(ctx)
|
||||
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
||||
unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Grant{ID: 1, Scope: "openid profile email"})
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/routers/common"
|
||||
"gitea.dev/services/audit"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
@@ -282,10 +283,9 @@ func MustChangePasswordPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure only requests for users who are eligible to change their password via
|
||||
// this method passes through
|
||||
if !ctx.Doer.MustChangePassword {
|
||||
ctx.ServerError("MustUpdatePassword", errors.New("cannot update password. Please visit the settings page"))
|
||||
if !common.CheckSignedInUser(ctx.Doer, ctx.Session).NeedChangePassword {
|
||||
log.Debug("User %s attempted to access the must change password page, but they are not required to change their password", ctx.Doer.Name)
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/renderhelper"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/services/context"
|
||||
feed_service "gitea.dev/services/feed"
|
||||
|
||||
@@ -28,8 +30,15 @@ func ShowUserFeedAtom(ctx *context.Context) {
|
||||
|
||||
// showUserFeed show user activity as RSS / Atom feed
|
||||
func showUserFeed(ctx *context.Context, formatType string) {
|
||||
includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
|
||||
isOrganisation := ctx.ContextUser.IsOrganization()
|
||||
if !setting.Other.EnableFeed ||
|
||||
isOrganisation && !organization.HasOrgOrUserVisible(ctx, ctx.ContextUser, ctx.Doer) ||
|
||||
!isOrganisation && !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
|
||||
if ctx.IsSigned && isOrganisation && !includePrivate {
|
||||
// When feed is requested by a member of the organization,
|
||||
// include the private repo's the member has access to.
|
||||
|
||||
+3
-23
@@ -17,38 +17,18 @@ import (
|
||||
"gitea.dev/modules/sitemap"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/web/middleware"
|
||||
"gitea.dev/routers/web/auth"
|
||||
"gitea.dev/routers/web/user"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
// tplHome home page template
|
||||
tplHome templates.TplName = "home"
|
||||
)
|
||||
const tplHome templates.TplName = "home"
|
||||
|
||||
// Home render home page
|
||||
func Home(ctx *context.Context) {
|
||||
if ctx.IsSigned {
|
||||
if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
|
||||
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
|
||||
ctx.HTML(http.StatusOK, auth.TplActivate)
|
||||
} else if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin {
|
||||
log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr())
|
||||
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
|
||||
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
|
||||
} else if doerMustChangePassword(ctx) {
|
||||
ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
|
||||
ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password"
|
||||
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
|
||||
} else {
|
||||
user.Dashboard(ctx)
|
||||
}
|
||||
user.Dashboard(ctx)
|
||||
return
|
||||
// Check non-logged users landing page.
|
||||
} else if setting.LandingPageURL != setting.LandingPageHome {
|
||||
// Check non-logged users landing page
|
||||
ctx.Redirect(setting.AppSubURL + string(setting.LandingPageURL))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -642,7 +642,15 @@ func (data *actionRunListData) preparePartialRefreshRuns(ctx *context.Context) b
|
||||
ctx.ServerError("GetRunsByRepoAndID", err)
|
||||
return false
|
||||
}
|
||||
data.ActionRuns = runs
|
||||
runsMap := make(map[int64]*actions_model.ActionRun, len(runs))
|
||||
for _, run := range runs {
|
||||
runsMap[run.ID] = run
|
||||
}
|
||||
for _, id := range data.refreshRunIDs {
|
||||
if run, ok := runsMap[id]; ok {
|
||||
data.ActionRuns = append(data.ActionRuns, run)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
web_context "gitea.dev/services/context"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -74,3 +75,17 @@ func newWorkflowBadgeTestContext(t *testing.T) *web_context.Context {
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestActionRunListData(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
t.Run("preparePartialRefreshRuns", func(t *testing.T) {
|
||||
ctx, _ := contexttest.MockContext(t, "user5/repo4/actions")
|
||||
contexttest.LoadRepo(t, ctx, 4)
|
||||
d := &actionRunListData{refreshRunIDs: []int64{791, 792}}
|
||||
d.preparePartialRefreshRuns(ctx)
|
||||
assert.Equal(t, []int64{791, 792}, []int64{d.ActionRuns[0].ID, d.ActionRuns[1].ID})
|
||||
d = &actionRunListData{refreshRunIDs: []int64{792, 791}}
|
||||
d.preparePartialRefreshRuns(ctx)
|
||||
assert.Equal(t, []int64{792, 791}, []int64{d.ActionRuns[0].ID, d.ActionRuns[1].ID})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -137,21 +136,9 @@ func NewComment(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
err = pull.LoadIssue(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("load the issue of pull request error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if prHeadCommitID != headBranchCommitID {
|
||||
// force push to base repo
|
||||
err := git.PushManaged(ctx, pull.HeadRepo, pull.BaseRepo, git.PushOptions{
|
||||
Branch: pull.HeadBranch + ":" + prHeadRef,
|
||||
Force: true,
|
||||
Env: repo_module.InternalPushingEnvironment(pull.Issue.Poster, pull.BaseRepo),
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("force push error", err)
|
||||
if err := pull_service.PushToBaseRepo(ctx, pull); err != nil {
|
||||
ctx.ServerError("PushToBaseRepo", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -15,6 +13,7 @@ import (
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
@@ -110,6 +109,34 @@ func canSoftDeleteContentHistory(ctx *context.Context, issue *issues_model.Issue
|
||||
return canSoftDelete
|
||||
}
|
||||
|
||||
func diffContentHistory(oldContent, newContent string) template.HTML {
|
||||
// compare the current history revision with the previous one
|
||||
dmp := diffmatchpatch.New()
|
||||
// `checklines=false` makes better diff result
|
||||
diff := dmp.DiffMain(util.NormalizeStringEOL(oldContent), util.NormalizeStringEOL(newContent), false)
|
||||
diff = dmp.DiffCleanupEfficiency(diff)
|
||||
|
||||
// use chroma to render the diff html
|
||||
buf := &htmlutil.HTMLBuilder{}
|
||||
buf.WriteHTML(`<pre class="chroma">`)
|
||||
for _, it := range diff {
|
||||
switch it.Type {
|
||||
case diffmatchpatch.DiffInsert:
|
||||
buf.WriteHTML(`<span class="gi">`)
|
||||
buf.WriteString(it.Text)
|
||||
buf.WriteHTML("</span>")
|
||||
case diffmatchpatch.DiffDelete:
|
||||
buf.WriteHTML(`<span class="gd">`)
|
||||
buf.WriteString(it.Text)
|
||||
buf.WriteHTML("</span>")
|
||||
default:
|
||||
buf.WriteString(it.Text)
|
||||
}
|
||||
}
|
||||
buf.WriteHTML("</pre>")
|
||||
return buf.HTMLString()
|
||||
}
|
||||
|
||||
// GetContentHistoryDetail get detail
|
||||
func GetContentHistoryDetail(ctx *context.Context) {
|
||||
issue := GetActionIssue(ctx)
|
||||
@@ -144,36 +171,11 @@ func GetContentHistoryDetail(ctx *context.Context) {
|
||||
prevHistoryContentText = prevHistory.ContentText
|
||||
}
|
||||
|
||||
// compare the current history revision with the previous one
|
||||
dmp := diffmatchpatch.New()
|
||||
// `checklines=false` makes better diff result
|
||||
diff := dmp.DiffMain(prevHistoryContentText, history.ContentText, false)
|
||||
diff = dmp.DiffCleanupEfficiency(diff)
|
||||
|
||||
// use chroma to render the diff html
|
||||
diffHTMLBuf := bytes.Buffer{}
|
||||
diffHTMLBuf.WriteString("<pre class='chroma'>")
|
||||
for _, it := range diff {
|
||||
switch it.Type {
|
||||
case diffmatchpatch.DiffInsert:
|
||||
diffHTMLBuf.WriteString("<span class='gi'>")
|
||||
diffHTMLBuf.WriteString(html.EscapeString(it.Text))
|
||||
diffHTMLBuf.WriteString("</span>")
|
||||
case diffmatchpatch.DiffDelete:
|
||||
diffHTMLBuf.WriteString("<span class='gd'>")
|
||||
diffHTMLBuf.WriteString(html.EscapeString(it.Text))
|
||||
diffHTMLBuf.WriteString("</span>")
|
||||
default:
|
||||
diffHTMLBuf.WriteString(html.EscapeString(it.Text))
|
||||
}
|
||||
}
|
||||
diffHTMLBuf.WriteString("</pre>")
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"canSoftDelete": canSoftDeleteContentHistory(ctx, issue, comment, history),
|
||||
"historyId": historyID,
|
||||
"prevHistoryId": prevHistoryID,
|
||||
"diffHtml": diffHTMLBuf.String(),
|
||||
"diffHtml": diffContentHistory(prevHistoryContentText, history.ContentText),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDiffContentHistory(t *testing.T) {
|
||||
out := diffContentHistory("<\r\n&\r\n>", "<\nXXX\n>")
|
||||
assert.Equal(t, `<pre class="chroma"><
|
||||
<span class="gd">&</span><span class="gi">XXX</span>
|
||||
></pre>`, string(out))
|
||||
}
|
||||
@@ -1145,7 +1145,7 @@ func MergePullRequest(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := pull_service.Merge(pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if err := pull_service.Merge(ctx, pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if pull_service.IsErrInvalidMergeStyle(err) {
|
||||
ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option"))
|
||||
} else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
|
||||
|
||||
@@ -112,7 +112,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
||||
// if this pr can be merged now, then hide the auto merge
|
||||
generalHideAutoMerge := prInfo.MergeBoxData.canMergeNow && allOverridableChecksOk
|
||||
var mergeStyles []any
|
||||
if pull.IsStatusMergeable() {
|
||||
if pull.IsStatusMergeable() || pull.IsEmpty() {
|
||||
mergeStyles = []any{
|
||||
map[string]any{
|
||||
"name": "merge",
|
||||
@@ -176,7 +176,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
||||
if len(mergeStyles) > 0 {
|
||||
mergeFormProps["mergeStyles"] = mergeStyles
|
||||
prInfo.MergeBoxData.MergeFormProps = mergeFormProps
|
||||
} else if pull.IsStatusMergeable() {
|
||||
} else if pull.IsStatusMergeable() || pull.IsEmpty() {
|
||||
// no merge style was set in repo setting
|
||||
prInfo.MergeBoxData.infoCommitBlockers.AddInfoItem(
|
||||
svg.RenderHTML("octicon-x", 16, "tw-text-red"),
|
||||
|
||||
@@ -660,6 +660,11 @@ func deleteReleaseOrTag(ctx *context.Context, isDelTag bool) {
|
||||
return
|
||||
}
|
||||
|
||||
if isDelTag && !rel.IsTag {
|
||||
ctx.HTTPError(http.StatusConflict, "a tag attached to a release cannot be deleted directly")
|
||||
return
|
||||
}
|
||||
|
||||
if err := release_service.DeleteReleaseByID(ctx, ctx.Repo.Repository, rel, ctx.Doer, isDelTag); err != nil {
|
||||
if release_service.IsErrProtectedTagName(err) {
|
||||
ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected"))
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
@@ -21,6 +22,21 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeleteTagRetainsReleaseAndAttachments(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
ctx, resp := contexttest.MockContext(t, "POST user2/repo1/tags/delete?id=1")
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
contexttest.LoadRepo(t, ctx, 1)
|
||||
release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 1})
|
||||
attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 9, ReleaseID: 1})
|
||||
|
||||
DeleteTag(ctx)
|
||||
|
||||
assert.Equal(t, http.StatusConflict, resp.Code)
|
||||
assert.Equal(t, release, unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 1}))
|
||||
assert.Equal(t, attachment, unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 9}))
|
||||
}
|
||||
|
||||
func TestNewReleasePost(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user